diff options
82 files changed, 6570 insertions, 4680 deletions
@@ -50,3 +50,9 @@ auto-save-list/ /multisession/ /browser-choice.el /client_secret_491339091045-sjje1r54s22vn2ugh45khndjafp89vto.apps.googleusercontent.com.json + +# Documentation folder (session notes, personal workflows) +/docs/ + +# Claude Code configuration (personal settings, commands) +/.claude/ diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..8d9b4b47 --- /dev/null +++ b/Makefile @@ -0,0 +1,301 @@ +# Makefile for Emacs configuration +# +# Usage: +# make help - Show this help message +# make test - Run all tests +# make test-unit - Run unit tests only +# make test-file FILE=test-foo.el - Run specific test file +# make test-name TEST=test-foo-* - Run tests matching pattern +# make validate-parens - Check for unbalanced parentheses +# make validate-modules - Load all modules to verify they compile +# make compile - Byte-compile all modules +# make lint - Run all linters (checkdoc, package-lint, elisp-lint) +# make profile - Profile Emacs startup performance +# make clean - Remove test artifacts and compiled files +# make clean-compiled - Remove .elc/.eln files only +# make clean-tests - Remove test artifacts only +# make reset - Reset to first launch (destructive!) + +# Emacs binary to use (override with: make EMACS=emacs29 test) +EMACS ?= emacs + +# Directories +TEST_DIR = tests +MODULE_DIR = modules +EMACS_HOME = $(HOME)/.emacs.d + +# Test files +UNIT_TESTS = $(filter-out $(TEST_DIR)/test-integration-%.el, $(wildcard $(TEST_DIR)/test-*.el)) +INTEGRATION_TESTS = $(wildcard $(TEST_DIR)/test-integration-%.el) +ALL_TESTS = $(UNIT_TESTS) $(INTEGRATION_TESTS) + +# Module files +MODULE_FILES = $(wildcard $(MODULE_DIR)/*.el) + +# Emacs batch flags +EMACS_BATCH = $(EMACS) --batch --no-site-file --no-site-lisp +EMACS_TEST = $(EMACS_BATCH) -L $(TEST_DIR) -L $(MODULE_DIR) + +# Colors for output +COLOR_GREEN = \033[0;32m +COLOR_RED = \033[0;31m +COLOR_BLUE = \033[0;34m +COLOR_YELLOW = \033[0;33m +COLOR_RESET = \033[0m + +.PHONY: help test test-all test-unit test-integration test-file test-name \ + validate-parens validate-modules compile lint profile \ + clean clean-compiled clean-tests reset + +# Default target +.DEFAULT_GOAL := help + +help: + @echo "Emacs Configuration Targets:" + @echo "" + @echo " $(COLOR_BLUE)Testing:$(COLOR_RESET)" + @echo " make test - Run all tests ($(words $(ALL_TESTS)) files)" + @echo " make test-unit - Run unit tests only ($(words $(UNIT_TESTS)) files)" + @echo " make test-integration - Run integration tests only ($(words $(INTEGRATION_TESTS)) files)" + @echo " make test-file FILE=<filename> - Run specific test file" + @echo " make test-name TEST=<pattern> - Run tests matching pattern" + @echo "" + @echo " $(COLOR_BLUE)Validation:$(COLOR_RESET)" + @echo " make validate-parens - Check for unbalanced parentheses in modules" + @echo " make validate-modules - Load all modules to verify they compile" + @echo " make compile - Byte-compile all module files" + @echo " make lint - Run all linters (checkdoc, package-lint, elisp-lint)" + @echo "" + @echo " $(COLOR_BLUE)Utilities:$(COLOR_RESET)" + @echo " make profile - Profile Emacs startup performance" + @echo " make clean - Remove test artifacts and compiled files" + @echo " make clean-compiled - Remove .elc/.eln files only" + @echo " make clean-tests - Remove test artifacts only" + @echo " make reset - Reset to first launch (DESTRUCTIVE!)" + @echo "" + @echo "Examples:" + @echo " make test-file FILE=test-custom-buffer-file-copy-whole-buffer.el" + @echo " make test-name TEST=test-custom-buffer-file-copy-*" + @echo " make EMACS=emacs29 test # Use specific Emacs version" + +# ============================================================================ +# Testing Targets +# ============================================================================ + +test: test-all + +test-all: + @echo "$(COLOR_BLUE)Running all tests ($(words $(ALL_TESTS)) files)...$(COLOR_RESET)" + @$(MAKE) test-unit + @if [ $(words $(INTEGRATION_TESTS)) -gt 0 ]; then \ + $(MAKE) test-integration; \ + fi + @echo "$(COLOR_GREEN)✓ All tests complete$(COLOR_RESET)" + +test-unit: + @echo "$(COLOR_BLUE)Running unit tests ($(words $(UNIT_TESTS)) files)...$(COLOR_RESET)" + @failed=0; \ + for test in $(UNIT_TESTS); do \ + echo " Testing $$test..."; \ + $(EMACS_TEST) -l ert -l $$test -f ert-run-tests-batch-and-exit || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -eq 0 ]; then \ + echo "$(COLOR_GREEN)✓ All unit tests passed$(COLOR_RESET)"; \ + else \ + echo "$(COLOR_RED)✗ $$failed unit test file(s) failed$(COLOR_RESET)"; \ + exit 1; \ + fi + +test-integration: + @if [ $(words $(INTEGRATION_TESTS)) -eq 0 ]; then \ + echo "$(COLOR_YELLOW)No integration tests found$(COLOR_RESET)"; \ + exit 0; \ + fi + @echo "$(COLOR_BLUE)Running integration tests ($(words $(INTEGRATION_TESTS)) files)...$(COLOR_RESET)" + @failed=0; \ + for test in $(INTEGRATION_TESTS); do \ + echo " Testing $$test..."; \ + $(EMACS_TEST) -l ert -l $$test -f ert-run-tests-batch-and-exit || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -eq 0 ]; then \ + echo "$(COLOR_GREEN)✓ All integration tests passed$(COLOR_RESET)"; \ + else \ + echo "$(COLOR_RED)✗ $$failed integration test file(s) failed$(COLOR_RESET)"; \ + exit 1; \ + fi + +test-file: +ifndef FILE + @echo "$(COLOR_RED)Error: FILE parameter required$(COLOR_RESET)" + @echo "Usage: make test-file FILE=test-custom-buffer-file-copy-whole-buffer.el" + @exit 1 +endif + @echo "$(COLOR_BLUE)Running tests in $(FILE)...$(COLOR_RESET)" + @$(EMACS_TEST) -l ert -l $(TEST_DIR)/$(FILE) -f ert-run-tests-batch-and-exit + @echo "$(COLOR_GREEN)✓ Tests in $(FILE) complete$(COLOR_RESET)" + +test-name: +ifndef TEST + @echo "$(COLOR_RED)Error: TEST parameter required$(COLOR_RESET)" + @echo "Usage: make test-name TEST=test-custom-buffer-file-copy-*" + @exit 1 +endif + @echo "$(COLOR_BLUE)Running tests matching pattern: $(TEST)...$(COLOR_RESET)" + @$(EMACS_TEST) \ + -l ert \ + $(foreach test,$(ALL_TESTS),-l $(test)) \ + --eval '(ert-run-tests-batch-and-exit "$(TEST)")' + @echo "$(COLOR_GREEN)✓ Tests matching '$(TEST)' complete$(COLOR_RESET)" + +# ============================================================================ +# Validation Targets +# ============================================================================ + +validate-parens: + @echo "$(COLOR_BLUE)Checking for unbalanced parentheses in modules...$(COLOR_RESET)" + @failed=0; \ + for file in $(MODULE_FILES); do \ + echo " Checking $$file..."; \ + $(EMACS_BATCH) --eval "(condition-case err \ + (progn \ + (find-file \"$$file\") \ + (check-parens) \ + (kill-emacs 0)) \ + (error (progn \ + (message \"ERROR: %s\" err) \ + (kill-emacs 1))))" 2>&1 > /dev/null || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -eq 0 ]; then \ + echo "$(COLOR_GREEN)✓ All modules have balanced parentheses$(COLOR_RESET)"; \ + else \ + echo "$(COLOR_RED)✗ $$failed module(s) have unbalanced parentheses$(COLOR_RESET)"; \ + exit 1; \ + fi + +validate-modules: + @echo "$(COLOR_BLUE)Loading all modules to verify compilation...$(COLOR_RESET)" + @failed=0; \ + for file in $(MODULE_FILES); do \ + echo " Loading $$file..."; \ + $(EMACS_BATCH) -L $(MODULE_DIR) \ + --eval "(condition-case err \ + (progn \ + (load-file \"$$file\") \ + (message \"OK: %s\" \"$$file\")) \ + (error (progn \ + (message \"ERROR loading %s: %s\" \"$$file\" err) \ + (kill-emacs 1))))" || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -eq 0 ]; then \ + echo "$(COLOR_GREEN)✓ All modules loaded successfully$(COLOR_RESET)"; \ + else \ + echo "$(COLOR_RED)✗ $$failed module(s) failed to load$(COLOR_RESET)"; \ + exit 1; \ + fi + +compile: + @echo "$(COLOR_BLUE)Byte-compiling all modules...$(COLOR_RESET)" + @$(EMACS_BATCH) -L $(MODULE_DIR) \ + --eval "(progn \ + (setq byte-compile-error-on-warn nil) \ + (batch-byte-compile))" $(MODULE_FILES) + @echo "$(COLOR_GREEN)✓ Compilation complete$(COLOR_RESET)" + +lint: + @echo "$(COLOR_BLUE)Running linters on all modules...$(COLOR_RESET)" + @echo "$(COLOR_YELLOW)Note: checkdoc, package-lint, and elisp-lint must be installed$(COLOR_RESET)" + @failed=0; \ + for file in $(MODULE_FILES); do \ + echo " Linting $$file..."; \ + $(EMACS_BATCH) -L $(MODULE_DIR) \ + --eval "(progn \ + (require 'checkdoc nil t) \ + (require 'package-lint nil t) \ + (require 'elisp-lint nil t) \ + (find-file \"$$file\") \ + (when (featurep 'checkdoc) \ + (checkdoc-current-buffer t)) \ + (when (featurep 'package-lint) \ + (package-lint-current-buffer)) \ + (when (featurep 'elisp-lint) \ + (elisp-lint-file \"$$file\")))" || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -eq 0 ]; then \ + echo "$(COLOR_GREEN)✓ All linting checks passed$(COLOR_RESET)"; \ + else \ + echo "$(COLOR_YELLOW)⚠ $$failed module(s) have linting issues$(COLOR_RESET)"; \ + fi + +# ============================================================================ +# Utility Targets +# ============================================================================ + +profile: + @echo "$(COLOR_BLUE)Profiling Emacs startup...$(COLOR_RESET)" + @if [ -f "$(EMACS_HOME)/early-init.el" ]; then \ + $(EMACS) -Q --load "$(EMACS_HOME)/custom/profile-dotemacs.el" \ + --eval "(progn (load-file \"$(EMACS_HOME)/early-init.el\") (profile-dotemacs))"; \ + else \ + echo "$(COLOR_YELLOW)No early-init.el found. Profiling init.el only.$(COLOR_RESET)"; \ + $(EMACS) -Q --load "$(EMACS_HOME)/custom/profile-dotemacs.el" \ + --eval "(profile-dotemacs)"; \ + fi + +clean: clean-tests clean-compiled + @echo "$(COLOR_GREEN)✓ Clean complete$(COLOR_RESET)" + +clean-compiled: + @echo "$(COLOR_BLUE)Removing compiled files (.elc, .eln)...$(COLOR_RESET)" + @find $(EMACS_HOME) -type f \( -name "*.eln" -o -name "*.elc" \) -delete + @echo "$(COLOR_GREEN)✓ Compiled files removed$(COLOR_RESET)" + +clean-tests: + @echo "$(COLOR_BLUE)Removing test artifacts...$(COLOR_RESET)" + @rm -rf $(HOME)/.temp-emacs-tests + @echo "$(COLOR_GREEN)✓ Test artifacts removed$(COLOR_RESET)" + +reset: + @echo "$(COLOR_RED)⚠ DESTRUCTIVE: Resetting to first launch...$(COLOR_RESET)" + @rm -rf $(HOME)/.cache/org-persist/ + @rm -rf $(EMACS_HOME)/.cache/ + @rm -rf $(EMACS_HOME)/.elfeed-db/ + @rm -rf $(EMACS_HOME)/auto-save-list/ + @rm -rf $(EMACS_HOME)/backups/ + @rm -rf $(EMACS_HOME)/crossword/ + @rm -rf $(EMACS_HOME)/dirvish/ + @rm -rf $(EMACS_HOME)/eln-cache/ + @rm -rf $(EMACS_HOME)/elpa/ + @rm -rf $(EMACS_HOME)/emms/ + @rm -rf $(EMACS_HOME)/emojis/ + @rm -rf $(EMACS_HOME)/erc/ + @rm -rf $(EMACS_HOME)/eshell/ + @rm -rf $(EMACS_HOME)/multisession + @rm -rf $(EMACS_HOME)/nov-places/ + @rm -rf $(EMACS_HOME)/persist/ + @rm -rf $(EMACS_HOME)/quelpa/ + @rm -rf $(EMACS_HOME)/request/ + @rm -rf $(EMACS_HOME)/tramp-auto-save/ + @rm -rf $(EMACS_HOME)/transient/ + @rm -rf $(EMACS_HOME)/tree-sitter/ + @rm -rf $(EMACS_HOME)/url/ + @rm -rf $(EMACS_HOME)/var + @rm -f $(EMACS_HOME)/.elfeed-db + @rm -f $(EMACS_HOME)/.emacs-history + @rm -f $(EMACS_HOME)/.lsp-session* + @rm -f $(EMACS_HOME)/.org-generic-id-locations + @rm -f $(EMACS_HOME)/.org-id-locations + @rm -f $(EMACS_HOME)/.pdf-view-restore + @rm -f $(EMACS_HOME)/.scratch + @rm -f $(EMACS_HOME)/forge-database.sqlite + @rm -f $(EMACS_HOME)/history + @rm -f $(EMACS_HOME)/nov-places + @rm -f $(EMACS_HOME)/org-roam.db + @rm -f $(EMACS_HOME)/pomm + @rm -f $(EMACS_HOME)/projectile-bookmarks.eld + @rm -f $(EMACS_HOME)/projects + @rm -f $(EMACS_HOME)/recentf + @rm -f $(EMACS_HOME)/tramp-connection-history + @rm -f $(HOME)/sync/org/emacs-theme.persist + @find $(EMACS_HOME) -name "*.eln" -type f -delete + @find $(EMACS_HOME) -name "*.elc" -type f -delete + @echo "$(COLOR_GREEN)✓ Reset complete$(COLOR_RESET)" diff --git a/assets/abbrev_defs b/assets/abbrev_defs index 3060620f..cd9c6818 100644 --- a/assets/abbrev_defs +++ b/assets/abbrev_defs @@ -33,7 +33,7 @@ ("aethetically" "aesthetically" nil :count 0) ("agression" "aggression" nil :count 0) ("agressive" "aggressive" nil :count 0) - ("ahve" "have" nil :count 14) + ("ahve" "have" nil :count 15) ("aknowledge" "acknowledge" nil :count 0) ("alegiance" "allegiance" nil :count 0) ("allegaince" "allegiance" nil :count 0) @@ -215,7 +215,7 @@ ("hrie" "hire" nil :count 0) ("htey" "they" nil :count 3) ("humerous" "humorous" nil :count 0) - ("hygeine" "hygiene" nil :count 0) + ("hygeine" "hygiene" nil :count 1) ("hygene" "hygiene" nil :count 0) ("hygine" "hygiene" nil :count 0) ("idnetify" "identify" nil :count 0) @@ -294,7 +294,7 @@ ("oppositiion" "opposition" nil :count 0) ("opppsite" "opposite" nil :count 0) ("orignal" "original" nil :count 0) - ("ot" "to" nil :count 41) + ("ot" "to" nil :count 42) ("otehr" "other" nil :count 3) ("otes" "notes" nil :count 0) ("outgoign" "outgoing" nil :count 0) @@ -393,7 +393,7 @@ ("takss" "tasks" nil :count 3) ("talekd" "talked" nil :count 0) ("talkign" "talking" nil :count 6) - ("teh" "the" nil :count 155) + ("teh" "the" nil :count 156) ("tehir" "their" nil :count 5) ("tehre" "there" nil :count 3) ("testimentary" "testamentary" nil :count 1) @@ -428,10 +428,11 @@ ("warant" "warrant" nil :count 0) ("welfair" "welfare" nil :count 0) ("welomce" "welcome" nil :count 0) + ("whcih" "which" nil :count 0) ("whenter" "whether" nil :count 0) ("whenver" "whenever" nil :count 0) ("wierd" "weird" nil :count 0) - ("wihtout" "without" nil :count 0) + ("wihtout" "without" nil :count 1) ("windsheild" "windshield" nil :count 0) ("withdrawls" "withdrawals" nil :count 0) ("withold" "withhold" nil :count 0) diff --git a/gemini.key.gpg b/assets/gemini.key.gpg index 10068445..10068445 100644 --- a/gemini.key.gpg +++ b/assets/gemini.key.gpg diff --git a/docs/SESSION-HANDOFF-ACTIVE-PROJECT.org b/docs/SESSION-HANDOFF-ACTIVE-PROJECT.org deleted file mode 100644 index 379b11a8..00000000 --- a/docs/SESSION-HANDOFF-ACTIVE-PROJECT.org +++ /dev/null @@ -1,263 +0,0 @@ -#+TITLE: 🚨 ACTIVE PROJECT - READ THIS FIRST 🚨 -#+AUTHOR: Claude Code Session Notes -#+DATE: 2025-10-30 - -* CURRENT PROJECT STATUS - -** 🎯 What We're Doing -Working through a systematic approach to clean up and prioritize Craig's Emacs config work: - -1. ✅ *COMPLETE V2MOM* (Vision, Values, Methods, Obstacles, Metrics) - IN PROGRESS -2. ⏳ *TRIAGE todo.org* - Use V2MOM to ruthlessly cancel ~60% of tasks -3. ⏳ *EXECUTE TIER 1* - Ship quick wins (network check removal, Corfu, bug fixes) -4. ⏳ *BUILD OBSERVABILITY* - Create profiling infrastructure (TIER 2) -5. ⏳ *SYSTEMATIC EXECUTION* - Work through prioritized tasks one by one - -** 📍 Where We Are Right Now -*Session Started:* 2025-10-30 -*Current Step:* V2MOM Methods section (60% complete - Vision + Values done) -*Time Committed:* ~1 hour sessions, working systematically -*Status:* PAUSED between sessions - resuming later this evening - -** 📄 Key Documents - -*** Primary Working Documents -- *V2MOM:* [[file:emacs-config-v2mom.org][emacs-config-v2mom.org]] - Strategic framework (ACTIVELY EDITING) -- *Issues Analysis:* [[file:../issues.org][../issues.org]] - Claude's detailed analysis with TIER system and implementations -- *Current Todos:* [[file:../todo.org][../todo.org]] - Craig's existing task list (~50+ items, needs triage) - -*** Reference Documents -- *Config Root:* [[file:../init.el][../init.el]] -- *Modules:* [[file:../modules/][../modules/]] -- *Tests:* [[file:../tests/][../tests/]] - -** 🔑 Key Insights About Craig's Work Patterns - -*** Strengths -- Thoughtful and strategic thinker -- Good research skills (thorough specs, complete code examples) -- Does ship things (dashboard, dirvish, network check fixes) -- Recognizes need for V2MOM framework -- Uses config daily for real work - -*** Patterns to Address -1. *Research > Execution* - Has complete code for Corfu, difftastic, transcription workflow... still TODO -2. *Priority Inflation* - Too many [#A]/[#B] items, unclear what's actually urgent -3. *Incomplete Strategy* - V2MOM structure exists but sections are empty -4. *Hard to Say No* - [#C]/[#D] items should be CANCELLED but remain in list -5. *Side Projects Compete* - Dupre theme work competes with core config maintenance - -*** What Craig Told Us About Himself -> "I am building tools both because they solve problems, but also because I enjoy building." - -This is healthy! But need balance: -- Fix rough edges FIRST (daily pain points) -- Build fun stuff SECOND (after maintenance) -- Cancel distractions ALWAYS (Signal client, minimap, etc.) - -** 🎯 Agreed Goals for This Project - -*** Immediate (Next 2-3 Sessions) -1. ✅ Complete V2MOM (IN PROGRESS) -2. ⏳ Triage todo.org using V2MOM as filter -3. ⏳ Execute quick wins: network check, Corfu migration, bug fixes -4. ⏳ Build debug-profiling.el infrastructure - -*** Short Term (Next Month) -5. Profile and optimize org-agenda performance -6. Ship reveal.js presentation workflow -7. Establish weekly triage ritual - -*** Long Term (Ongoing) -8. Ship more than research -9. Maintain < 20 active todos -10. Measure metrics from V2MOM - -** 📋 TIER System from issues.org - -*** TIER 1: Do These First (High Impact, Low Effort) - 1 weekend -- Remove network check (15 min) -- Fix missing functions (30 min) -- Corfu migration (2 hours) -- Mood-line switch (30 min) -- Bug fixes (1 hour) - -*** TIER 2: Build Observability (HIGHEST VALUE) - 1 week -- Create debug-profiling.el module (3-4 hours) -- Profile org-agenda-rebuild (1 hour) -- Add instrumentation and caching (2 hours) -- Test org-agenda filtering functions (2-3 hours) - -*** TIER 3: Quick Wins (After Profiling) - 1-2 hours each -- Reveal.js presentation workflow (2 hours) -- Difftastic integration (30 min) -- Local package development workflow (1 hour) - -*** TIER 4: Maybe/Someday (Probably Never) -- Code-maat reimplementation (HOLD) -- LaTeX config (HOLD until concrete need) -- Elfeed dashboard (HOLD - unclear if actually used) -- DWIM shell integration (HOLD - current solution works) -- Jumper package (HOLD - already maintaining chime + org-msg) - -** 🚫 Items That Should Be CANCELLED - -From todo.org, these don't serve the vision: -- [#D] Signal Client - Not in vision -- [#D] Awesome-tray / mode-icons - Already have modeline -- [#C] Minimap - Interesting, not important -- [#C] Install Magit TODOs - Already works fine -- [#C] Git Timemachine litters buffers - Minor annoyance -- Many Dupre theme TODOs - Side project competing with maintenance - -## 💡 Key Recommendations for Craig - -### Week 1: Strategy + Quick Wins -1. Complete V2MOM (2-3 hours) -2. Triage todo.org using V2MOM (1-2 hours) -3. Execute items you already have code for (2-3 hours) - -### Week 2: Observability Infrastructure -4. Build debug-profiling.el (3-4 hours) -5. Profile org-agenda (1 hour) - -### Week 3: Fix Performance + Ship Presentation -6. Fix org-agenda based on profiling (2-3 hours) -7. Ship reveal.js workflow (2 hours) - -### Ongoing: Maintenance Discipline -- Weekly triage ritual (30 min every Sunday) -- Measure metrics (startup time, agenda time, todo count) -- Ship > Research - -** 🔄 Next Session Pickup Points - -When starting next session, Claude should: - -1. **Read this document first** to understand context -2. **Check V2MOM status** - If incomplete, continue there -3. **Reference issues.org** for detailed technical recommendations -4. **Reference todo.org** for items to triage -5. **Ask Craig:** "Where did we leave off? V2MOM? Triage? Execution?" - -** 📞 Questions to Ask Craig Next Session - -*IMMEDIATE (when resuming):* -- "Ready to continue V2MOM? We left off at Methods section." -- "How much time do you have?" - -*FOR METHODS SECTION:* -Show Craig the draft list and ask: -- "Which methods do you already do consistently?" -- "Which do you want to do but don't yet?" -- "Am I missing any important methods?" - -*AFTER V2MOM COMPLETE:* -- "Ready to triage todo.org using the V2MOM?" -- "Should we execute quick wins or continue systematic triage?" - -** 🎯 Success Metrics for This Project - -We'll know this is working when: -- ✅ V2MOM is complete and provides clear strategic direction -- ✅ todo.org shrinks from ~50 to < 20 active items -- ✅ Craig ships 3-5 items per week (small but consistent) -- ✅ Craig has profiling infrastructure to measure performance -- ✅ Org agenda rebuild time is measured and improving -- ✅ Weekly triage becomes habit - -** 💬 Craig's Words to Remember - -> "I think you should adjust issues.org with all your recommendations. They are exciting, eye-opening, and just feel right. Add even your guidance on latex. spot on. thanks for your honesty. I did ask for it and am genuinely grateful for your responses. I'll take action on them." - -> "What I need help with is integrating this in with my existing todo.org file... Some of the tasks I've listed should probably just be deleted or better yet, marked CANCELLED." - -> "I have about an hour to devote. You could lead me through it, I could do some questions/answer rounds with you to clarify my thinking." - -Craig is ready to execute. He asked for honesty and took it well. He recognizes the patterns and wants systematic help. - -** 🛠️ Technical Context - -*** Current Pain Points -1. Org agenda is slow (performance bottleneck) -2. Network check adds 1+ seconds to startup (technical debt) -3. Missing functions cause errors (cj/log-silently, cj/goto-git-gutter-diff-hunks) -4. Mail attachments workflow is awkward -5. No profiling infrastructure to measure performance - -*** Items Craig Already Has Code For -These can be executed immediately - just paste and test: -- Transcription workflow (complete bash + elisp in todo.org:2-99) -- Difftastic integration (complete config in todo.org:1211-1223) -- Corfu migration (complete config in todo.org:1611-1639) - -*** Architecture -- Modular structure: modules/*.el -- Good test coverage for utilities -- Modern packages: Vertico/Consult/Embark stack -- Local package development: chime.el, org-msg - -** 📚 Related Reading - -If Craig or Claude need more context: -- [[file:../issues.org::*Second Opinion: Ruthless Prioritization & Reality Checks][Second Opinion section in issues.org]] - Full analysis and recommendations -- [[file:../issues.org::*TIER 1: Do These First][TIER 1-4 breakdown]] - Prioritized task system -- [[file:../quality-engineer.org][quality-engineer.org]] - Testing philosophy (if exists) - -** 🚀 Current Session Notes - -*** 2025-10-30 Session 1 - V2MOM In Progress -*Time:* ~1 hour -*Status:* PAUSED - V2MOM 60% complete - -*What We Completed:* -1. ✅ Created docs/ directory structure -2. ✅ Created SESSION-HANDOFF-ACTIVE-PROJECT.org (this file) -3. ✅ Created emacs-config-v2mom.org -4. ✅ Created values-comparison.org (analysis doc) -5. ✅ Completed Vision (already existed, kept as-is) -6. ✅ Completed Values section (Intuitive, Fast, Simple) - - Intuitive: Muscle memory, mnemonics, which-key timing, "newspaper" code - - Fast: Startup < 2s, org-agenda is THE bottleneck, everything else acceptable - - Simple: Production software practices, simplicity produces reliability - -*What's Next:* -1. ⏳ *Methods* - IN PROGRESS (have draft list, need Craig's input) -2. ⏳ *Obstacles* - TODO -3. ⏳ *Metrics* - TODO -4. ⏳ *Finalize V2MOM* - Review and commit - -*Draft Methods List (Need Craig's Feedback):* -These were proposed but Craig stopped before reviewing: -1. Ruthless prioritization (V2MOM guides triage) -2. Profile before optimizing (build observability first) -3. Test-driven development (tests enable confident refactoring) -4. Ship > Research (execute existing specs before exploring new) -5. Weekly triage ritual (review todos, cancel stale, keep < 20 active) -6. Measure metrics (track startup, agenda, test coverage, todo count) -7. Extract packages (when custom code grows: chime, org-msg pattern) -8. Incremental execution (ship small, test, iterate) - -*Questions to Ask Craig When Resuming:* -- Which methods do you already do consistently? -- Which do you want to do but don't yet? -- Am I missing any important methods? - -*After Methods/Obstacles/Metrics Complete:* -Then move to triage todo.org using completed V2MOM as filter. - -*Key Files Modified This Session:* -- [[file:emacs-config-v2mom.org][emacs-config-v2mom.org]] - Main working document (60% complete) -- [[file:values-comparison.org][values-comparison.org]] - Analysis doc (reference only) -- [[file:SESSION-HANDOFF-ACTIVE-PROJECT.org][SESSION-HANDOFF-ACTIVE-PROJECT.org]] - This file - -*Next Session Starts With:* -1. Read this handoff document -2. Read emacs-config-v2mom.org to see what's complete -3. Ask Craig: "Ready to continue V2MOM with Methods section?" -4. Show Craig the draft Methods list -5. Get feedback and complete Methods -6. Move to Obstacles -7. Move to Metrics -8. Finalize V2MOM -9. Then triage todo.org diff --git a/docs/emacs-config-v2mom.org b/docs/emacs-config-v2mom.org deleted file mode 100644 index e5a09968..00000000 --- a/docs/emacs-config-v2mom.org +++ /dev/null @@ -1,361 +0,0 @@ -#+TITLE: Emacs Configuration V2MOM -#+AUTHOR: Craig Jennings -#+DATE: 2025-10-30 -#+FILETAGS: :strategy:planning:v2mom: - -* What is V2MOM? - -V2MOM is a strategic framework used to clarify goals and priorities: -- *Vision:* What do you want to achieve? -- *Values:* What principles guide decisions? -- *Methods:* How will you achieve the vision? -- *Obstacles:* What's in your way? -- *Metrics:* How will you measure success? - -This document provides strategic clarity for prioritizing Emacs configuration work. - -* Vision - -Your personal ideal Emacs configuration allows you to work at the speed of your own thought. It's stable and never needs edge-case bug fixing. The functionality covers your workflow such that you can work all day in Emacs for any of the following task areas: - -- Programming in any language, including emacs-lisp, common-lisp, scheme, golang, and c -- Reading ebooks, including marginalia handling -- Listening to music, including playlist management -- Listening to podcasts, including saving episodes -- Task organizing and planning -- Calendaring, including syncing with google calendar -- Alerting and notifications on tasks and calendar events -- Timer/stopwatch functionality, including pomodoro presets -- Audio and desktop recording -- Memorization drills -- Email, including email-to-task integration -- Messaging, i.e., IRC -- File-management, including extensive integration, and DWM scratchpad integration outside Emacs -- Shell commands, leveraging vterm and eshell with custom methods, and DWM scratchpad integration outside Emacs - -Anytime you make a change in the config, you have unit tests to tell you quickly if the change created unexpected issues. You have a test-runner that integrates with your workflow. - -* Values - -** Intuitive - -*Definition:* Intuition comes from muscle memory, clear mnemonics, and just-in-time discovery that reinforces learning without blocking productivity. - -*** Keybindings Build Intuition Through Layers -1. *Muscle memory first* - C-; on home row becomes automatic habit -2. *Mnemonics provide "aha!" moments* - C-c **m** for mail creates dopamine hits that cement permanent memory -3. *Which-key teaches through active recall* - Appears after you try (not before), forcing attempt without wasting time - -The timing matters: which-key's delay is calibrated so you're challenged to remember, but not blocked from working. - -*** What Breaks Intuition -- *Physically awkward keybindings* - C-c 1 z C-c f requires hand contortions and offers no mnemonic -- *Syntax overhead* - Parentheses tracking steals time from real learning (logic bugs, architecture understanding) -- *Poor architecture* - Common actions (like "save all attachments") shouldn't require architectural archaeology - -*** Code Reads "Like a Newspaper" -- Each use-package declaration is a *story* (topic = package) -- Configuration settings are *facts* (what it does, how it behaves) -- Clear variable/method naming (yours + maintainers) makes code read like English -- Accessible to intermediate elisp readers without deep archaeology - -*** Principle -*Intuition is earned through repeated success, not instant magic.* The config should make success easy to achieve and memorable when it happens. - - - -** Fast - -*Definition:* Fast means staying in flow. Startup and org-agenda must not make me wait. Everything else is already acceptable. - -*** Performance Targets - -*Startup Time:* -- Current: ~3.8 seconds (real clock time, not Emacs-reported) -- Target: ~1 second (aspirational, may not be realistic given package count) -- Comfortable: ~2 seconds (±) - -*Measurement:* -#+begin_src bash -time emacs --eval '(save-buffers-kill-emacs)' # Real startup time -#+end_src - -*Note:* Emacs-reported time is inaccurate. Messages buffer continues populating and Emacs remains unresponsive after "loaded" message appears. - -*** Critical Performance Issues (Must Fix) - -1. *Emacs startup* - 3.8s is too slow - - Network check adds 1+ seconds (DELETE THIS) - - Eager loading vs lazy loading - -2. *Opening org-agenda for first time* - **THE BOTTLENECK** - - "Takes forever and a full work day" - - Likely: recursive directory scan, no caching, too many files - - Solution: Profile first (build debug-profiling.el), then optimize - -*** Must Be Instant (Flow-Critical) -- Keybinding response -- Changing projects in projectile (already good ✓) -- Opening large org files (already good ✓, except malformed files with stray "*"s) -- Buffer/window operations - -*** Can Take 1-2 Seconds (Acceptable) -- *Mail sync* - Consider putting on timer instead of manual trigger -- *Calibredb* (PDFs/Epubs) - Known solution: shrink inventory to <1k books or contribute optimizations -- *Org-noter* opening PDFs/Epubs - Needs reliability + reasonable speed -- *Looking up org-noter notes* - Should be easy to pull into other documents -- *Initial file indexing* (projectile, etc.) - -*** Disabled Due to Performance (Need Solutions) -- *Grammar checker* - Installed but turned off because it slows everything down - - No task exists to fix this yet - - Breaks concentration when writing - -- *Bibliography management* - Don't know how to do in Emacs - - Worried about broken/slow tools breaking writing flow - - Need solution that "just works" - -*** Principle -*Speed is about flow, not milliseconds.* Startup and agenda are flow-breakers. Everything else is fine. Don't optimize what doesn't hurt. - - - -** Simple - -*Definition:* Simple means clear, maintainable code following production software practices. Simplicity is a discipline that produces reliability. Config is production software - treat it as such. - -*** What Makes Code Simple -1. *Clear naming* - Variables and functions say what they do -2. *Small functions* - Easy to understand, test, and modify -3. *Good comments* - NOT obvious comments ("cat with post-it note saying 'cat'") - -*Good comments:* -- Navigation markers for long files -- Explain complex/non-obvious logic -- Document unobvious impacts of settings -- Answer "why" not "what" - -*Bad comments:* -#+begin_src emacs-lisp -;; declare a variable -(defvar variable 10) -#+end_src - -*** Abstractions and Magic -- *Abstractions are fine* if they serve a purpose -- *Macros are irritating* when unnecessary, but acceptable when warranted -- *Magic is acceptable* if it's reliable and you can build a mental model - -*Principle:* "I want to understand every line of what my config does, or at least have a decent mental model of how it probably works. Never use a tool you can't fully trust." - -*** Package Count -Package count itself isn't a concern. Quality and maintainability matter. - -*Concerns:* -- More packages = more potential breakage -- Need ability to roll back when packages break (localrepo for package snapshots) -- Maintenance burden is real, but manageable with good practices - -*Not concerned about:* -- Absolute number of packages -- Startup time impact (if lazy-loaded properly) -- Cognitive load (if organized well) - -*** Config IS Production Software - -*Key insight:* "My Emacs config IS production software. I use it more than probably every other application combined. The impact when it's broken can crush my productivity." - -This means: -- *Apply production practices:* Testing, modularity, documentation, version control -- *Plan for failure:* Rollback capability (localrepo), error handling, graceful degradation -- *Modular design:* Extract custom code into packages when it grows -- *Long-term maintenance:* Architecture matters, technical debt has real cost - -*** Personal Software Platform -"I see my config as a personal software project built on the software platform called Emacs. Emacs has taught me a great deal about how to build software platforms well." - -*Implications:* -- Config follows software engineering principles -- Custom functionality becomes packages (chime, org-msg, potentially jumper) -- Good architecture enables evolution -- Quality matters because uptime matters - -*** Why Simplicity Matters -When config breaks, productivity is crushed. The discipline of simplicity produces reliability: -- Simple code is easier to fix when it breaks -- Simple architecture enables graceful degradation -- Simple practices (testing, rollback) provide safety nets -- Simple means never using tools you can't trust - -*** Principle -*Simple means maintainable production software, not minimal or primitive.* Complexity is acceptable when it serves the vision and can be understood or trusted. **Simplicity is the practice that produces reliability as an outcome.** - - - -** Summary: Three Core Values - -1. *Intuitive* - Muscle memory, mnemonics, just-in-time discovery. Code reads like a newspaper. -2. *Fast* - Startup and org-agenda must not break flow. Everything else is acceptable. -3. *Simple* - Clear, maintainable, production-quality code. Simplicity produces reliability. - - - -* Methods - -** Method 1: Make Using Emacs Frictionless - -Emacs should never make you wait or break your concentration. This method eliminates daily friction points that disrupt flow. Every item here either removes a bottleneck (startup delay, org-agenda slowness), fixes something broken (org-noter, recording, mail attachments), or adds missing functionality you reach for weekly (diff-buffer-with-file). - -When Method 1 is complete, Emacs starts fast, org-agenda opens instantly, all core workflows work reliably, and nothing makes you context-switch to debug config during work. - -*Concrete actions:* -- Remove network check from startup (saves 1+ seconds every launch) -- Optimize org-agenda performance using built-in profiler (eliminate "forever and a full work day" rebuild time) -- Fix [[https://github.com/weirdNox/org-noter][org-noter]] (reading/annotation workflow currently "so painful") -- Fix video/audio recording module (use it constantly, just broke) -- Fix mail attachment workflow (currently awkward) -- Implement cj/diff-buffer-with-file (compare buffer with saved version - weekly need) -- Fix cj/goto-git-gutter-diff-hunks (missing function causing errors) -- Fix grammar checker performance (currently disabled because it breaks flow) - -** Method 2: Stop Problems Before They Appear - -A stable config comes from proactive maintenance, not reactive fixes. This method replaces aging packages with modern, actively-maintained alternatives before they break. It removes deprecated dependencies and adopts better-designed tools that align with Emacs' evolution. - -When Method 2 is complete, the config uses current best practices, has no deprecated packages lurking as time bombs, and benefits from simpler, more maintainable completion infrastructure. - -*Concrete actions:* -- Migrate from Company to [[https://github.com/minad/corfu][Corfu]] (simpler, modern completion framework - complete config already in todo.org) -- Switch to [[https://gitlab.com/jessieh/mood-line][mood-line]] (lighter modeline, already researched) -- Remove deprecated tree-sitter package and rely on [[https://github.com/renzmann/treesit-auto][treesit-auto]] (already installed, leverages Emacs 29+ built-in treesit) -- Add [[https://github.com/awth13/org-appear][org-appear]] (show emphasis markers only when point is on them - cleaner org files) -- Integrate [[https://github.com/radian-software/prescient.el][prescient]] with Corfu (frequency/recency-based smart sorting - already using with vertico) - -** Method 3: Make *Fixing* Emacs Frictionless - -You can't fix what you can't measure, and you can't trust what you can't test. This method builds observability and testing infrastructure that makes future maintenance systematic instead of guesswork. With proper profiling, testing, and diffing tools in place, debugging becomes fast and confident. - -When Method 3 is complete, you can profile any performance issue in seconds, write integration tests for complex workflows, roll back broken packages instantly, and review config changes with semantic understanding. - -*Concrete actions:* -- Build debug-profiling.el module and develop skills using it (reusable profiling infrastructure for any future performance work) -- Integrate [[https://github.com/jorgenschaefer/emacs-buttercup][Buttercup]] (behavior-driven integration tests for complex config workflows) -- Build localrepo out (package snapshot system for repeatable installs and safe rollbacks) -- Integrate [[https://github.com/Wilfred/difftastic][difftastic]] (structural diffs that show semantic changes, not just line changes) - -** Method 4: Contribute to the Emacs Ecosystem - -Maintaining packages (chime, org-msg, wttrin) means being a good steward of code others depend on. This method establishes professional package development practices: automated linting, CI testing, and coverage reporting. These tools catch issues before users do and make MELPA submissions smooth. - -When Method 4 is complete, every package you maintain has automated quality checks, measurable test coverage, and CI that validates changes before they ship. You contribute back to the community with confidence. - -*Concrete actions:* -- Set up [[https://github.com/purcell/package-lint][package-lint]] for elisp linting (catch packaging issues and style violations automatically) -- Set up [[https://github.com/riscy/melpazoid][melpazoid]] CI (validates packages meet MELPA standards before submission) -- Set up [[https://github.com/leotaku/elisp-check][elisp-check]] GitHub Action (zero-config CI for Emacs packages) -- Integrate [[https://github.com/undercover-el/undercover.el][undercover.el]] (measure and track test coverage over time) - -** Method 5: Be Kind To Your Future Self - -With Emacs stable and maintainable, it's time to add features that expand what's possible. This method builds workflows you'll use repeatedly: transcribing audio for notes and creating presentations directly from org-mode. These aren't fixes—they're investments in future capability. - -When Method 5 is complete, you can transcribe recordings without leaving Emacs and generate beautiful reveal.js presentations from org files. Future you will thank present you for shipping these. - -*Concrete actions:* -- Add transcription workflow (complete code already in todo.org:2-99 - need today and recurring) -- Implement [[https://github.com/yjwen/org-reveal][org-reveal]] presentation workflow (create reveal.js slides from org-mode) - -** Method 6: Develop Disciplined Engineering Practices - -The best infrastructure won't help without disciplined habits. This method builds the practices that make all other methods sustainable: knowing what matters most, shipping over researching, measuring before optimizing. These are skills to develop, not tasks to complete—they evolve throughout the entire V2MOM, and I become a better engineer overall. - -*Concrete practices to develop:* -- *Ruthless prioritization* - Always do the most important thing. Use V2MOM as filter. If it doesn't serve the vision, cancel it. -- *Weekly triage* - Review todos every Sunday (30 min). Items sitting >1 week get shipped or killed. Prevents backlog rot. Keep <20 active items. -- *Measure metrics* - Define success criteria before starting work. Quantify outcomes so you know when you're done (startup time, test coverage, todo count). -- *Profile-before-optimize* - Never guess what's slow. Run profiler, identify hotspot, THEN fix. Avoids wasting time optimizing things that don't matter. -- *Ship-over-research* - Execute existing specs before exploring new ones. Time-box research (30 min → decide: ship or kill). Monthly retrospective on research:shipped ratio. -- *Incremental execution* - Ship small, test, iterate. Keep config working at every step. Avoid big-bang failures, integration hell, and sunk cost traps. - -*How to measure success:* -- *Ruthless prioritization* → todo.org stays under 20 active items, cancelled:completed ratio shows I'm saying "no" -- *Weekly triage* → At least once a week by Sunday, no longer than 7 days between triage (tracked in calendar) -- *Measure metrics* → Every task has defined success criteria before starting, can show actual tracked metrics (startup logs, coverage reports) -- *Profile-before-optimize* → Every performance fix has profiler output proving the bottleneck, zero "I think this is slow" guesses -- *Ship-over-research* → Research:shipped ratio improves monthly (>1:1), complete code in todo.org ships within 1 week -- *Incremental execution* → Config never broken for >2 days, git commits are small and frequent, can roll back any change cleanly - - - -* Obstacles - -1. *Building and researching is more fun than fixing.* But if I don't change this, everything will be broken. - -2. *I get irritated with myself when making mistakes.* But mistakes are how people learn. If I let irritation drive me to give up, I'll never develop the skills these methods require. - -3. *It's hard for me to say "no".* But if I don't say "no" to something, I'll never achieve anything. The only way to make saying "no" easier is to do it repeatedly. - -4. *I can be a perfectionist who wants something just right before I move on.* But perfect is the enemy of shipped. I need to learn that good enough is better than perfect, and iteration beats perfection. - -5. *I don't always have the time for all day coding sessions.* Breaking the work into increments will help me deliver the long term value anyway. Be the tortoise, not the rabbit. - -6. *New disciplines are hard to sustain.* Weekly triage, measuring metrics, and profiling-before-optimizing are new habits. The first few weeks I'll be tempted to skip them when busy. But if I don't practice them consistently, they'll never become automatic, and I'll fall back into old patterns. - - - -* Metrics - -You can't improve what you don't measure. These metrics provide objective evidence that the Methods are working and the Vision is becoming reality. Track them weekly during triage to catch regressions early and celebrate progress. - -** Performance Metrics: -- *Startup time: < 3 seconds* (currently ~6.2s) - Measured with =time emacs --eval '(save-buffers-kill-emacs)'= -- *Org-agenda rebuild time: < 5 seconds* (currently 30+ seconds) - Measured with profiler during first daily open - -** Maintenance Discipline Metrics: -- *Active todo count: < 20 items* (currently ~50+) - Counted during weekly triage -- *Weekly triage consistency: At least once a week by Sunday, no longer than 7 days between triage* - Tracked in calendar -- *Research:shipped ratio: > 1:1* - Can't research next thing until current thing is implemented. Track monthly. -- *Config uptime: Never broken > 2 days* - Allows breathing room for emergencies/travel - -** Package Quality Metrics (chime, org-msg, wttrin): -- *Test coverage: > 70% and all code not covered justifiable* - Uncovered code is 100% risk. Better not be the most-used parts! - - - -* How to Use This V2MOM - -** For Decision Making -When considering a new task, package, or feature: -1. Does it serve the *Vision*? -2. Does it align with the *Values*? -3. Is it addressed in the *Methods*? -4. Does it overcome an *Obstacle*? -5. Does it improve a *Metric*? - -If the answer is "no" to all five → CANCEL the task. - -** For Triage -When reviewing todo.org: -- Tasks aligned with V2MOM → Keep and prioritize -- Tasks not aligned with V2MOM → CANCEL -- Tasks that are interesting but not important → CANCEL -- Tasks with complete code already → Execute immediately - -** For Weekly Review -Every Sunday (30 minutes): -1. Review metrics - Are they improving? -2. Review obstacles - Any new ones? Any resolved? -3. Review active todos - Still aligned with V2MOM? -4. Cancel stale/misaligned tasks -5. Plan next week's focus - -* Completion Status - -- [X] Vision - Complete (kept from original todo.org) -- [X] Values - Complete (Intuitive, Fast, Simple) -- [X] Methods - Complete (6 methods with aspirational bodies and concrete actions) -- [X] Obstacles - Complete (6 honest obstacles with real stakes) -- [X] Metrics - Complete (Performance, Discipline, and Quality metrics defined) - -*Last Updated:* 2025-10-31 (Session 2) -*Status:* ✅ V2MOM COMPLETE - Ready to use for decision making and weekly triage diff --git a/docs/someday-maybe.org b/docs/someday-maybe.org deleted file mode 100644 index 86062ee9..00000000 --- a/docs/someday-maybe.org +++ /dev/null @@ -1,1622 +0,0 @@ -* Emacs Config Open Work -** TODO [#A] Add Transcription Org-capture Workflow - :PROPERTIES: - :CATEGORY: emacs - :END: - -*** Goal - Integrate audio-to-text transcription into Org-capture so meeting recordings - can be turned into timestamped notes automatically. - -*** Steps - -**** 1. Create CLI for transcription - - Option A — OpenAI API (Whisper): - #+begin_src bash - # ~/bin/oai-transcribe - #!/usr/bin/env bash - set -euo pipefail - AUDIO="$1"; LANG="${2:-en}" - : "${OPENAI_API_KEY:?Set OPENAI_API_KEY in your environment}" - curl -s -X POST "https://api.openai.com/v1/audio/transcriptions" \ - -H "Authorization: Bearer $OPENAI_API_KEY" \ - -F "model=whisper-1" \ - -F "response_format=text" \ - -F "language=${LANG}" \ - -F "file=@${AUDIO}" - #+end_src - - - Option B — Local Whisper: - #+begin_src bash - pip install openai-whisper # once - # ~/bin/local-whisper - #!/usr/bin/env bash - set -euo pipefail - AUDIO="$1"; MODEL="${2:-medium}"; LANG="${3:-en}" - whisper "$AUDIO" --model "$MODEL" --language "$LANG" \ - --task transcribe --output_format txt --verbose False >/dev/null - cat "${AUDIO%.*}.txt" - #+end_src - -**** 2. Emacs Lisp configuration - Add to init.el: - - #+begin_src emacs-lisp - (defcustom cj/transcribe-program - (or (executable-find "oai-transcribe") - (executable-find "local-whisper")) - "Program used to transcribe audio to plain text.") - - (defcustom cj/meetings-org-dir "~/org/meetings/" "") - - (defun cj/transcribe-audio (file) - (with-temp-buffer - (call-process shell-file-name nil t nil shell-command-switch - (format "%s %s" - (shell-quote-argument cj/transcribe-program) - (shell-quote-argument (expand-file-name file)))) - (buffer-string))) - - (defun cj/org-capture-meeting-template () - (let* ((audio (read-file-name "Audio: ")) - (participants (read-string "Participants: " "Mark; Craig")) - (location (read-string "Location: " "French Truck Coffee")) - (topic (read-string "Topic: " "Contract discussion")) - (transcript (cj/transcribe-audio audio))) - (format "* Meeting: %s\n:PROPERTIES:\n:DATE: %s\n:LOCATION: %s\n:PARTICIPANTS: %s\n:AUDIO: %s\n:END:\n\n** Summary\n- \n\n** Transcript\n#+begin_quote\n%s\n#+end_quote\n" - topic (format-time-string "%Y-%m-%d %H:%M") - location participants (abbreviate-file-name audio) transcript))) - - (with-eval-after-load 'org - (add-to-list 'org-capture-templates - `("m" "Meeting (transcribe audio)" plain - (file ,(expand-file-name (format-time-string "%Y-meetings.org") - cj/meetings-org-dir)) - "%(cj/org-capture-meeting-template)" - :empty-lines 1 :unnarrowed t))) - #+end_src - -**** 3. Usage - 1. Record meeting as .m4a or .wav - 2. Run `M-x org-capture m` → select the audio file - 3. Org inserts transcript and metadata under - `~/org/meetings/YYYY-meetings.org` - -**** 4. Optional quick recap template - #+begin_src emacs-lisp - (with-eval-after-load 'org - (add-to-list 'org-capture-templates - '("r" "Post-meeting recap" entry - (file+headline - (lambda () (expand-file-name (format-time-string "%Y-meetings.org") - cj/meetings-org-dir)) - "Recaps") - "* %U %^{Title|Meeting w/ Mark (Carrollton)}\n:LOCATION: %^{Location|French Truck (Dryades)}\n** Facts\n- %?\n** Next Steps\n- "))) - #+end_src - -**** 5. Notes - - Keep filenames descriptive: `2025-11-03_mark-frenchtruck.m4a` - - For privacy: use local-whisper variant to keep audio offline. -** TODO [#A] Finish Testing New Org-webclipper -** TODO [#A] Org Reveal.js Presentations From Local Org :enhancement: -*** References -https://revealjs.com/ -https://github.com/hakimel/reveal.js -*** Steps from Zamansky's Videos -https://www.youtube.com/watch?v=psDpCpcIVYs&t=10s -**** Install Org Reveal - -** TODO [#B] Implement additional custom operations -After analyzing all custom-* modules, the following line/paragraph operations are missing and would be useful additions to custom-line-paragraph.el: - -*** Most Useful Operations (High Priority) -- [ ] Copy current line - Duplicate line to kill ring without cutting (like yy in Vim) -- [ ] Sort lines - Sort lines alphabetically, numerically, or by custom key -- [ ] Insert blank line above - Add empty line above current line without moving cursor -- [ ] Insert blank line below - Add empty line below current line without moving cursor - -*** Additional Operations (Medium Priority) -- [ ] Reverse lines - Reverse the order of lines in region or buffer -- [ ] Shuffle lines - Randomly reorder lines in region or buffer -- [ ] Uniquify lines - Remove duplicate lines while preserving order (different from existing remove-duplicates which uses regex) -- [ ] Filter lines matching regex - Keep only lines matching a pattern (inverse of remove-lines-containing) -- [ ] Number/enumerate lines - Add line numbers or enumeration to lines in region -- [ ] Join with separator - Join lines with custom separator (e.g., comma, semicolon) - -*** Lower Priority Operations -- [ ] Kill whole line - Delete entire line including newline (like dd in Vim) -- [ ] Wrap/unwrap at column - Hard wrap at fill-column or unwrap wrapped lines -- [ ] Indent/dedent region - Increase or decrease indentation for region - -Note: Some operations like fill-paragraph, transpose-lines, and basic indentation already exist in Emacs core. This list focuses on commonly-needed operations not yet implemented in custom modules. - -** TODO [#B] investigate unassociated files -- [ ] /home/cjennings/.emacs.d/assets/test/test-misspellings.org -- [ ] /home/cjennings/.emacs.d/assets/test/test.org -- [ ] /home/cjennings/.emacs.d/custom/c-boxes.el -- [ ] /home/cjennings/.emacs.d/custom/edit-indirect.el -- [ ] /home/cjennings/.emacs.d/custom/eplot.el -- [ ] /home/cjennings/.emacs.d/custom/gptel-prompts.el -- [ ] /home/cjennings/.emacs.d/custom/org-checklist.el -- [ ] /home/cjennings/.emacs.d/custom/pdf-continuous-scroll-mode.el -- [ ] /home/cjennings/.emacs.d/custom/pdf-continuous-scroll-mode-latest.el -- [ ] /home/cjennings/.emacs.d/custom/profile-dotemacs.el -- [ ] /home/cjennings/.emacs.d/custom/titlecase.el -- [ ] /home/cjennings/.emacs.d/custom/utilities/vcf-conversion-helpers.el -- [ ] /home/cjennings/.emacs.d/gptel-tools/update_text_file.el -- [ ] /home/cjennings/.emacs.d/modules/ai-conversations.el -- [ ] /home/cjennings/.emacs.d/modules/erc-config.el -- [ ] /home/cjennings/.emacs.d/modules/jumper.el -- [ ] /home/cjennings/.emacs.d/modules/ledger-config.el -- [ ] /home/cjennings/.emacs.d/modules/lipsum-generator.el -- [ ] /home/cjennings/.emacs.d/modules/lorem-generator.el -- [ ] /home/cjennings/.emacs.d/modules/mu4e-org-contacts-integration.el -- [ ] /home/cjennings/.emacs.d/modules/mu4e-org-contacts-setup.el -- [ ] /home/cjennings/.emacs.d/modules/org-noter-config.el -- [ ] /home/cjennings/.emacs.d/modules/prog-lsp.el -- [ ] /home/cjennings/.emacs.d/modules/wip.el -- [ ] /home/cjennings/.emacs.d/themes/dupre-theme.el -- [ ] /home/cjennings/.emacs.d/todo.org -** TODO [#B] Replace customization constructions in modules :bug: -Files using defcustom or other customization-related constructions: -- media-utils.el -- external-open.el -- dashboard-config.el -- lipsum-generator.el -- ui-config.el -- ai-conversations.el -- video-audio-recording.el -- jumper.el -- local-repository.el -- lorem-generator.el -- eww-config.el -** TODO [#B] Test Failures Fixed -*** TODO [#A] Add All ERT Tests Into Separate Directory :bug: -**** List of ERT tests that need consolidation: -Looks like there are only two files that have tests left: - -modules/undead-buffers.el -73 (ert-deftest undead-buffers/kill-or-bury-when-not-in-list-kills () -85 (ert-deftest undead-buffers/kill-or-bury-when-in-list-buries () -107 (ert-deftest undead-buffers/kill-or-bury-adds-to-list-with-prefix () -119 (ert-deftest undead-buffers/kill-buffer-and-window-removes-window () -133 (ert-deftest undead-buffers/kill-other-window-deletes-that-window () -150 (ert-deftest undead-buffers/kill-all-other-buffers-and-windows-keeps-only-current () - -themes/dupre-theme.el -137 (ert-deftest dupre-theme-default-face () -146 (ert-deftest dupre-theme-comment-face-italic () - -**** 2025-09-13 Sat @ 12:18:26 -0500 Gave this task to Codex -Move all ERT tests out of individual files in the modules directories, and move them all to files in the test directories. The files should be named after the functions they test. Move existing ERT tests in the test directories to these files as well, removing those original test files. Provide another elisp file that will read all ERT files, and offer to run all the ERT tests in that file, or all ERT tests from all the files. -**** Goals -Tests should be added to a separate directory rather than the end of each elisp file. -Gather them together, move them there, and make it easy to run tests per module and workflow. -*** TODO [#B] Fix all failing test files ~/.emacs.d/tests/*.disabled :maintenance: -*** TODO [#C] Write missing tests for each module :bug: -** TODO [#B] AI-Config Fixes and Enhancements -*** DOING [#A] Finish Creating and Refactoring GPTel Tools and Tests :maintenance: - - -**** TODO Refactor setup/teardown in all library test files -Use the methods in test-tool-library-general.el -- [ ] tests/test-tool-library-fs--mode-to-permissions.el -- [ ] tests/test-tool-library-fs-directory-entries.el -- [ ] tests/test-tool-library-fs-filter-by-extension.el -- [ ] tests/test-tool-library-fs-format-file-info.el -- [ ] tests/test-tool-library-fs-get-file-info.el -- [ ] tests/test-tool-library-fs-list-directory-recursive-extra.el -- [ ] tests/test-tool-library-fs-list-directory-recursive.el -- [ ] tests/test-tool-library-fs-validate-path.el -***** 2025-09-28 Sun @ 01:33:56 -0500 Issue, Goals, and Discussion -****** cjennings - -When I ran the snippet you provided, I received a backtrace indicating there was no directory of that name. So most likely the test teardown function removed it. - -```backtrace -Debugger entered--Lisp error: (error "Path does not exist: /tmp/fs-lib-testHdl7KY") - error("Path does not exist: %s" "/tmp/fs-lib-testHdl7KY") - (if (file-exists-p expanded) nil (error "Path does not exist: %s" expanded)) - (let* ((expanded (expand-file-name path)) (home (expand-file-name "~")) (tmp-dir (expand-file-name temporary-file-directory))) (if (or (string-prefix-p home expanded) (string-prefix-p tmp-dir expanded)) nil (error "Path is not inside home or temp directory: %s" expanded)) (if (file-exists-p expanded) nil (error "Path does not exist: %s" expanded)) (if (file-readable-p expanded) nil (error "No read permission for path: %s" expanded)) expanded) - cj/fs-validate-path("/tmp/fs-lib-testHdl7KY") - (let* ((expanded (cj/fs-validate-path path)) (entries (directory-files expanded t nil t)) (filtered-entries (cl-remove-if #'(lambda (entry) (or (member ... ...) (and ... ...))) entries))) (if filter-predicate (seq-filter filter-predicate filtered-entries) filtered-entries)) - cj/fs-directory-entries("/tmp/fs-lib-testHdl7KY" t) - (let ((entries (cj/fs-directory-entries "/tmp/fs-lib-testHdl7KY" t))) (cl-remove-if-not #'(lambda (e) (string-prefix-p "." (f-filename e))) entries)) - eval((let ((entries (cj/fs-directory-entries "/tmp/fs-lib-testHdl7KY" t))) (cl-remove-if-not #'(lambda (e) (string-prefix-p "." (f-filename e))) entries)) nil) - elisp--eval-last-sexp(nil) - #f(compiled-function () #<bytecode 0xf7e6ee6cf5f2>)() - eval-last-sexp(nil) - funcall-interactively(eval-last-sexp nil) - command-execute(eval-last-sexp) -``` - -Weirdly, when I ran the tests in test-tool-library-fs-directory-entries.el a second time, they all passed. - -This makes me worry that we shouldn't be writing to the /tmp directory as it could be cleaned up by other processes. Do you have any hypotheses for this intermittent failure? - -****** AI: gpt-4.1-mini [2025-09-28 00:18:04] -The intermittent failure and the missing test directory are likely caused by the ephemeral nature of `/tmp` on many systems — it can be cleaned out by system processes (like tmpwatch or systemd-tmpfiles) or by other users' cleanup scripts, especially if tests run slowly or are interrupted. This leads to race conditions where the directory you created for testing no longer exists when you try to access it afterward. - -Here are some hypotheses and recommendations: - -1. **Ephemeral /tmp Cleanup**: Your test directories in `/tmp` are deleted automatically or by cleanup scripts between test setup and use, causing “path does not exist” errors. - -2. **Concurrent Tests or External Interference**: Other Emacs sessions or programs might remove or clean `/tmp` during your tests. - -3. **Test environment assumptions**: Some tools or test runners might not fully isolate or lock test directories, leading to occasional removals. - ---- - -### Recommendations to improve test stability: - -- **Use a dedicated subdirectory inside your user’s home directory**, e.g., `~/.emacs.d/gptel-test` or `~/.emacs.d/test-temp`, instead of `/tmp`. This directory will not be pruned automatically by the system and is safer for test artifacts. - -- Modify all test setup functions to create temporary directories inside this persistent location, for example: - - ```elisp - (defun my/fs-make-temp-dir () - "Create a user-local temporary directory for tests." - (make-temp-file (expand-file-name "~/.emacs.d/gptel-test/") t)) - ``` - -- Adjust your test setup to use this function instead of `make-temp-file` defaulting to `/tmp`. - -- Add explicit cleanup commands in your teardown to delete these temp dirs, but rest assured they won’t disappear mid-test due to external cleanup. - -- Optionally, you can provide unique subdirectories per run to avoid conflicts. - ---- - -### Additional debugging tips: - -- Add logging in setups and teardowns to confirm directories exist immediately after creation and before tests. - -- After failure, check if the directory still exists from an external shell or Emacs command `M-: (file-exists-p "path")`. - -- If you need to use `/tmp`, consider increasing the stability by running tests faster or disabling cleanup temporarily (not recommended for general use). - ---- - -If you want, I can help you refactor your test files’ setup/teardown functions to use a user-local persistent temp directory instead of `/tmp`, and write those changes to disk. - -Would you like me to proceed with that? - -****** cjennings - -Yes. What you're saying makes sense. Here's what I'd like to do, based on what you've said. - -1. Create a test-tool-library-general.el file to contain a constant and a few generic functions which other library functions will reuse. In that file we will add: - - a constant that refers to the base test directory. This is the directory in which all test assets will be created. - - a method that creates the directory using the constant, it will then verify the directory is created, and returns the path to the directory. - - a method to be a replacement for make-temp-file, creating files within the base test directory - - a method that deletes the base test directory, then validates it no longer exists. This will be the bulk of the teardown methods in other files. - -2. Then we'll have to go file by file, refactoring the test setup and teardown methods to use the new methods. - -3. As we finish refactoring a test file, we'll run it to ensure it passes. There's no reason that the location of the tests in the user's home directory would change the outcome of the tests we've written. As long as our code is correct, we can expect our tests to pass. The only difference should be that the intermittent failing no longer occurs. - -4. Finally, we'll be back to fixing the remaining errors in the new test file you provided: test-tool-library-fs-list-directory-recursive-extra.el - -Any additional thought or suggestions? Is there anything else that should go into test-library-general.el that may be common to other tests? Any other best practice ideas? - -****** AI: gpt-4.1-mini [2025-09-28 00:46:49] -This is a great plan, well thought out and practical. - -### Few additional thoughts and suggestions: - -1. **Additional common utilities in test-tool-library-general.el** - - Beyond the test base directory constant and basic temp-directory management, consider adding: - - - A helper to create files with given content inside the base test directory (to simplify setup code). - - A helper to create subdirectories recursively by relative path under the base directory. - - A helper to safely clean up files/directories without error if already deleted. - - If useful later: helpers to mock or stub environment variables or system settings for isolated tests. - - Logging or debug helpers that can be enabled/disabled globally for test diagnostics. - -2. **Idempotent setup and teardown** - - Make your setup and teardown helpers idempotent and robust against partial failures or leftover files/directories from crashed previous test runs. - -3. **Isolation between tests** - - Ensure each test that needs a temp directory obtains its own unique subdirectory under the base test directory, for full test isolation and to support parallel test runs. - -4. **Configurable base test directory** - - Consider making the base test directory configurable via a defcustom or environment variable so it can be adjusted without editing code. - -5. **Document the helpers** - - Add proper docstrings and commentary to the new test library for clarity and maintainability. - ---- - -### Implementation plan summary for test-tool-library-general.el - -- Define a constant: `cj/test-base-dir` (e.g., `~/.emacs.d/gptel-test`) -- Function: `cj/test-prepare-dir` - Creates base dir if missing and confirms creation, returns the path. -- Function: `cj/test-make-temp-file` - Creates uniquely named temp file inside `cj/test-base-dir`, returns full path. -- Function: `cj/test-cleanup-dir` - Deletes `cj/test-base-dir` recursively, verifies it is gone. - ---- - -### Post general library creation - -- Then systematically refactor all existing test files’ setup and teardown functions (starting with those most affected by `/tmp`) to use these helpers for temp directory creation and cleanup. -- Run tests after each refactor to confirm continued success. -- Finally, return to addressing remaining test failures (e.g., the `test-tool-library-fs-list-directory-recursive-extra.el` file) with reduced risk of intermittent failures. - ---- - -If you confirm this, I will write the full `test-tool-library-general.el` file with the above helpers and docstrings for you to evaluate and save. - -Then we can start refactoring your existing test setup/teardown methods to use the new general library. - -Does this sound good? Anything else you'd want included in that general test library? - -****** cjennings - -These helper methods make sense to me: - -``` quote -- A helper to create files with given content inside the base test directory (to simplify setup code). -- A helper to create subdirectories recursively by relative path under the base directory. -``` - -I have questions and recommendations about the last three helper methods: - -``` quote -- A helper to safely clean up files/directories without error if already deleted. -``` -I don't understand this, but might be missing something. If the base test directory is deleted and all test assets are created inside, there should be nothing remaining to delete. - - -```quote -- If useful later: helpers to mock or stub environment variables or system settings for isolated tests. -``` -This makes sense, but I suggest we write that only when we first need it. - -```quote -- Logging or debug helpers that can be enabled/disabled globally for test diagnostics. -``` -Again, this is a good idea, and we'll probably do it. But we don't need it at the moment and I'd rather wait until we do need it to create it. - -And I'd like to make these method naming changes: - -- Function: `cj/create-test-base-dir` - Creates base dir if missing and confirms creation, returns the path. -- Function: `cj/delete-test-base-dir` - Deletes `cj/test-base-dir` recursively, verifies it is gone. - -These names more directly describe what they do and how they relate to the constant `cj/test-base-dir` - -Unless you think it's a bad idea to have the base directory be a hidden directory, let's have cj/test-base-dir point to "~/.temp-gptel-tests/". - -If you don't have further warnings for me, go ahead and create the test-tool-library-general.el file as described above. - -**** 2025-09-30 Tue @ 17:26:00 -500 Renamed test-tool-library-general.el -CLOSED: [2025-09-30 Tue 17:26] -Why does this just have to do with test-tools? It's actually a generic test library. -**** 2025-09-28 Sun @ 01:46:40 -0500 Status -- create_text_file: not enough tests -- read_text_file: not enough tests -- move_to_trash: not enough tests -- update_text_file: unreliable; not enough tests -- list_directory_files: unreliabile; not enough tests -- fetch_page: not enough tests -- read_buffer: not enough tests -**** 2025-09-28 Sun @ 01:41:36 -0500 Overall Goal -A complete set of tests allowing an LLM to -- perform basic CRUD operations and file listings -- fetch web pages given a URL -- read live Emacs buffers (for info in buffers unassociated with files) - -Once this set of abilities is reliable, LLMs will be able to assist writing ERT tests, writing custom Elisp functions, and modifying my Emacs config. -*** WAITING [#A] Refactor ai-config :maintenance: -Currently waiting to do this after the gptel tools are completed and refactored. -Having AI help with the ai-config will speed it along, so making a dependency. -**** 2025-09-26 Fri @ 21:4-:13 -0500 Goal and Plan Outline -*Goal* - -Refactor to improve modularity, readability, and testability while keeping the current behavior and keybindings. - ---- -***** Refactoring Strategy -****** 1. Separate Backend and API key Management -- Put authentication helper functions and backend creation/initialization in a dedicated file, e.g., =ai-backends.el=. -- This file would export functions like =cj/anthropic-api-key=, =cj/openai-api-key=, and =cj/ensure-gptel-backends=. -- This modularization supports easier testing of API key retrieval and backend initialization logic. -Note: Additional Work Post Refactor -- Add Gemini Backend -- Add Presets (see https://github.com/karthink/gptel?tab=readme-ov-file#option-presets) -****** 2. Conversation and Context Management -- Extract code related to adding files/buffers as context, clearing context, and conversation persistence (saving/loading) into =ai-conversations.el=. -- Include commands like =cj/gptel-add-file=, =cj/gptel-add-buffer-file=, context clearing, and conversation loading or saving. -Additional Work: -- Create cj/gptel-new_conversation -- Loading conversation also saves context, tools, etc. -- Saving conversations also saves context, tools, etc. -****** 3. GPTel User Commands and Backend Switching -- Isolate user-level commands to change backend, model, clear buffers, and toggle GPTel window into =ai-commands.el=. -- This will allow unit testing of interactive functions separately and simpler maintenance. -Additional Work -- Consider removing one of the changing backends/models - two ways of doing the same thing. -****** 4. GPTel Configuration and Org-mode Enhancement -- Have a file, e.g., =ai-gptel-config.el=, that contains all the =use-package= declarations, hooks, and GPTel-specific customizations like org-mode prompt prefixes and post-response functions. -- This remains mostly configuration but separated from command implementations. -****** 5. Keybindings and UI - -- Place the custom AI keymap and global keybindings into =ai-keymap.el=. -- This file imports the commands from =ai-commands.el= and binds them. - -****** 6. Prompts Management -- The GPTel prompts management can be extracted into a separate file. -Additional Work -- Make Prompts Management more stable - ---- - -***** Ensuring Testability -- Each module should export pure or mostly pure functions when possible. -- Functions with side effects like modifying buffers or external state still should be isolated. -- Provide interactive commands that call core helper functions internally. -- Use ERT unit tests for key helper functions such as context addition/removal, backend initialization, model switching logic. -- Use mock authentication backends or mock auth-source for API key tests. ---- -***** Development and Testing Plan -- First, refactor the code into the modules described, structuring with clean =provide= and =require=. -- Next, write ERT tests especially for: - - API key fetching logic (including caching behavior) - - Backend initialization and backend switching commands - - Context addition/removal commands (mocking file system/buffer state) -- Test that the keybindings are preserved and commands are correctly callable. -- Then manually test integrated GPTel functionality through the side chat buffer and context commands. -- Keep the public user interface stable while refactoring. ---- -**** 2025-09-26 Fri @ 21:44:20 -0500 Current Plan for Step One - -Step 1: Extract API Key Helpers and Backend Initialization - -***** Goal -Pull out the API key fetching, caching, and GPTel backend creation logic from ai-config.el into a new separate file, for example =ai-backends.el=. - -***** Responsibilities for =ai-backends.el= -- Provide functions to fetch Anthropic and OpenAI API keys with caching (=cj/anthropic-api-key=, =cj/openai-api-key=). -- Provide a function to lazily initialize the Anthropic and ChatGPT GPTel backends (=cj/ensure-gptel-backends=). -- Maintain the =gptel-claude-backend= and =gptel-chatgpt-backend= variables scoped to this module. -- Provide a common entrypoint to initialize and get available backends. - -***** Integration -- Replace the relevant parts in =ai-config.el= with calls to the new module. -- Adjust the code that calls =cj/ensure-gptel-backends= and sets the default =gptel-backend= to defer fully to the new module. -- Ensure that =cj/toggle-gptel= and other commands rely on this properly. - -***** Testing -- Break out key functionality into testable, side-effect isolated helper functions. -- Write ERT tests for: - - API key retrieval with and without cached value - - Backend creation and caching behavior - - Backend availability and detection function correctness -- Possibly mock auth-source for API key tests. - -***** Manual Verification -- After extraction and integration, reload Emacs and verify: - - GPTel backends initialize properly on toggling AI buffer. - - No regressions in model switching or backend switching commands. - - Cached API keys are respected. - ---- - -** TODO [#B] Decent HTML Exports From Org Mode With Inline CSS :enhancement: -*** fniessen/org-html-themes: Org mode files transformed into stunning HTML documents -[[https://github.com/fniessen/org-html-themes?tab=readme-ov-file#using-a-theme][fniessen/org-html-themes: Transform your Org mode files into stunning HTML documents in minutes with our Org mode HTML theme. Elevate your productivity and impress your readers! #orgmode #html #theme #productivity #design]] -Captured On: [2025-08-18 Mon 14:36] -** TODO [#B] Dirvish/File Manager Fixes/Enhancements :maintenance: -*** DOING [#B] Test all dwim-shell-commands :enhancement: -This way you can also have test assets -**** Test Status for Commands -- [X] convert-audio-to-mp3 () -- [X] convert-audio-to-opus () -- [ ] view-image-exif-metadata () -- [ ] flip-image-horizontally () -- [ ] flip-image-vertically () -- [ ] convert-image-to () -- [ ] convert-svg-to-png () -- [ ] join-images-into-pdf () -- [ ] extract-pdf-page-number () -- [ ] ocr-text-from-image-using-tesseract () -- [ ] convert-video-to-webp () -- [ ] convert-video-to-high-compatibility-mp4 () -- [ ] convert-video-to-hevc-mkv () -- [ ] extract-archive-smartly () -- [ ] zip-file-or-directory () -- [ ] tar-gzip-file-or-directory () -- [ ] epub-to-org () -- [ ] document-to-pdf () -- [ ] pdf-to-txt () -- [ ] resize-image-by-factor () -- [ ] resize-image-in-pixels () -- [ ] pdf-password-protect () -- [ ] pdf-password-unprotect () -- [ ] video-trim () -- [ ] drop-audio-from-video () -- [ ] open-externally () -- [ ] git-clone-clipboard-url () -- [X] open-file-manager () -- [ ] count-words-lines () -- [ ] checksum () -- [ ] backup-with-timestamp () -- [ ] optimize-image-for-web () -- [ ] csv-to-json () -- [ ] json-to-yaml () -- [ ] extract-urls-from-file () -- [ ] extract-emails-from-file () -- [ ] create-gif-from-video () -- [ ] concatenate-videos () -- [ ] create-video-thumbnail () -- [ ] merge-pdfs () -- [ ] split-pdf-by-pages () -- [ ] compress-pdf () -- [ ] ascii-art () -- [ ] text-to-speech () -- [ ] remove-empty-directories () -- [ ] create-thumbnail-from-image () -- [ ] extract-audio-from-video () -- [ ] normalize-audio-volume () -- [ ] remove-zip-encryption () -- [ ] create-encrypted-zip () -- [ ] list-archive-contents () -- [ ] count-words-lines-in-text-file () -- [ ] make-executable () -- [ ] secure-delete () -- [ ] sanitize-filename () -- [ ] number-files-sequentially () -- [ ] git-history () -- [ ] encrypt-with-gpg () -- [ ] decrypt-with-gpg () -- [ ] kill-gpg-agent () -**** Reference -https://github.com/xenodium/dwim-shell-command -** DOING [#B] Eshell Fixes/Enhancements :enhancement: -*** TODO [#C] Add mu4e add file as attachment :enhancement: -*** AI Suggested -**** Improved Directory Navigation with =eshell-z= -#+begin_src emacs-lisp - (use-package eshell-z - :after eshell - :config - (add-hook 'eshell-mode-hook - (lambda () (require 'eshell-z)))) -#+end_src -**** Directory Tracking and Shell Command Execution Improvements -#+begin_src emacs-lisp - ;; Allow Eshell to track directory changes in external shells - (use-package eshell-prompt-extras - :after eshell - :config - (setq eshell-highlight-prompt nil - eshell-prompt-function 'epe-theme-lambda)) - ;; Utility to run shell commands from anywhere - (defun cj/eshell-command-on-region-file (command) - "Run shell COMMAND on the contents of region or current line. - If region is active, use that, otherwise use the current line." - (interactive (list (read-shell-command "Shell command on region: "))) - (let* ((text (if (use-region-p) - (buffer-substring-no-properties (region-beginning) (region-end)) - (buffer-substring-no-properties (line-beginning-position) (line-end-position)))) - (temp-file (make-temp-file "eshell-cmd-")) - (output-buffer (generate-new-buffer "*Shell Command Output*"))) - (with-temp-file temp-file - (insert text)) - (with-current-buffer output-buffer - (insert (shell-command-to-string (format "%s %s" command temp-file))) - (pop-to-buffer output-buffer)) - (delete-file temp-file))) - (global-set-key (kbd "C-c !") 'cj/eshell-command-on-region-file) -#+end_src -**** Enhanced Visual Output and ANSI Colors -#+begin_src emacs-lisp - ;; Better handling of visual commands - (defun cj/eshell-exec-visual-and-kill () - "Execute the command via `eshell-exec-visual' and then kill the eshell buffer." - (interactive) - (let ((command (buffer-substring-no-properties - eshell-last-output-end (point-max)))) - (kill-buffer) - (eshell-exec-visual command))) - ;; Add this to your eshell aliases - (add-hook 'eshell-mode-hook - (lambda () - (eshell/alias "vk" 'cj/eshell-exec-visual-and-kill))) -#+end_src -**** Buffer Management and Layout Improvements -#+begin_src emacs-lisp - ;; Auto rename eshell buffers based on current directory - (defun cj/eshell-rename-buffer-as-dir () - "Rename the eshell buffer to include the current directory." - (let ((dir (abbreviate-file-name (eshell/pwd)))) - (rename-buffer (format "*eshell: %s*" dir) t))) - (add-hook 'eshell-directory-change-hook 'cj/eshell-rename-buffer-as-dir) - ;; Split eshell in a sensible way based on window size - (defun cj/eshell-pop-window () - "Open eshell in a sensible way depending on window dimensions." - (interactive) - (let ((width-percentage 0.3) - (height-percentage 0.3) - (current-window-width (window-width)) - (current-window-height (window-height))) - (if (> current-window-width (* 2 current-window-height)) - ;; For wide windows, split side by side - (let ((width (floor (* current-window-width width-percentage)))) - (split-window-horizontally (- current-window-width width)) - (other-window 1) - (eshell)) - ;; For tall windows, split top/bottom - (let ((height (floor (* current-window-height height-percentage)))) - (split-window-vertically (- current-window-height height)) - (other-window 1) - (eshell))))) - (global-set-key (kbd "C-c e") 'cj/eshell-pop-window) -#+end_src -**** Useful Utility Functions -#+begin_src emacs-lisp - ;; Enhanced eshell clear that preserves history - (defun cj/eshell-clear-buffer () - "Clear the eshell buffer, preserving the prompt and history." - (interactive) - (let ((inhibit-read-only t)) - (erase-buffer) - (eshell-send-input))) - (add-hook 'eshell-mode-hook - (lambda () (local-set-key (kbd "C-l") 'cj/eshell-clear-buffer))) - ;; Function to insert the output of elisp expressions into eshell - (defun cj/eshell-insert-elisp-output (elisp) - "Evaluate ELISP expression and insert its value at point in eshell." - (interactive "sEval Elisp: ") - (let ((result (eval (read elisp)))) - (insert (if (stringp result) - result - (format "%S" result))))) - (add-hook 'eshell-mode-hook - (lambda () (local-set-key (kbd "C-c C-e") 'cj/eshell-insert-elisp-output))) - ;; Quick file manager in eshell - (defun eshell/fman (file) - "Open FILE in a file manager." - (cond - ((eq system-type 'darwin) (shell-command (format "open %s" (expand-file-name default-directory)))) - ((eq system-type 'gnu/linux) (shell-command (format "xdg-open %s" (expand-file-name default-directory)))))) - ;; Enhanced cd command that respects projects - (defun eshell/pcd () - "Change directory to the project root." - (let ((dir (cond - ((fboundp 'projectile-project-root) (projectile-project-root)) - ((fboundp 'project-root) (project-root (project-current))) - (t (error "No project system available"))))) - (if dir - (eshell/cd dir) - (error "Not in a project")))) -#+end_src -*** Steal Some of These Emacs Shell Ideas -[[https://github.com/howardabrams/hamacs/blob/main/ha-eshell.org#ebb-and-flow-output-to-emacs-buffers][hamacs/ha-eshell.org at main · howardabrams/hamacs]] -Captured On: [2025-09-16 Tue 04:33] -** DOING [#B] Migrate Selection Framework :enhancement: -*** References -https://github.com/minad/consult/wiki/Auxiliary-packages -https://www.reddit.com/r/emacs/comments/1aguy8g/how_to_get_consultripgrep_to_remember_previous/ -*** Auxiliary Packages to Explore -https://gitlab.com/OlMon/consult-flyspell -https://github.com/ghosty141/consult-git-log-grep -https://github.com/minad/affe -*** Manual Testing -Here's a comprehensive test checklist we should verify after migration: -**** Core Functionality Tests -- [ ] =M-x= (execute-extended-command) with rich annotations -- [ ] =C-x C-f= (find-file) with file preview -- [ ] =C-x b= (switch-to-buffer) with buffer annotations -- [ ] =C-s= (isearch) replacement with consult-line -- [ ] =C-h f= (helpful-callable) with completing-read -- [ ] =C-h v= (helpful-variable) with completing-read -**** Project/File Navigation Tests -- [ ] =C-c p f= (projectile-find-file) -- [ ] =C-c p p= (projectile-switch-project) -- [ ] =C-c p G= (projectile-ripgrep) -- [ ] Recent files access -- [ ] Bookmarks -**** Window/Buffer Management Tests -- [ ] =M-V= (cj/split-and-follow-right) - buffer selection in new window -- [ ] =M-H= (cj/split-and-follow-below) - buffer selection in new window -- [ ] Kill-ring browsing -- [ ] Imenu navigation -**** Integration Tests -- [ ] =C-c s i= (ivy-yasnippet → consult-yasnippet) -- [ ] Git timemachine revision selection -- [ ] Helpful integration (counsel-describe-/ functions) -- [ ] Projectile integration -**** Search/Replace Tests -- [ ] In-buffer search -- [ ] Project-wide search -- [ ] Search with preview -- [ ] Occur-like functionality -*** Mu4e - Save All Attachments Testing -It's pretty easy if you use vertico + embark. -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. -** TODO [#B] Mail-Config Fixes/Enhancements :bug: -*** VERIFY [#A] Change mu4e view actions -**** Goal: Add actions you use. -the defaults are currently -#+begin_src emacs-lisp - (("capture message" . mu4e-action-capture-message) - ("view in browser" . mu4e-action-view-in-browser) - ("browse online archive" . mu4e-action-browse-list-archive) - ("xview in xwidget" . mu4e-action-view-in-xwidget) - ("show this thread" . mu4e-action-show-thread) - ("org-contact-add" . mu4e-action-add-org-contact)) - ;; Original value was - (("capture message" . mu4e-action-capture-message) - ("view in browser" . mu4e-action-view-in-browser) - ("browse online archive" . mu4e-action-browse-list-archive) - ("xview in xwidget" . mu4e-action-view-in-xwidget) - ("show this thread" . mu4e-action-show-thread)) -#+end_src -The only ones I use are: -#+begin_src emacs-lisp - ("view in browser" . mu4e-action-view-in-browser) - ("capture message" . mu4e-action-capture-message) - ("org-contact-add" . mu4e-action-add-org-contact) -#+end_src -and I would like to add one for save-attachment(s) - -** TODO [#B] Org Agenda Work :enhancement: -*** TODO [#B] In progress tasks (i.e., DOING) in separate section -*** TODO [#C] Add ability to start/stop Gcal sync's :enhancement: -Enable/Disable Idle Timer to do this -- Set the timer (code at bottom of file) -- Kill the timer (ensure this is unit-testable) -** DOING [#B] Org-Contact Fixes/Enhancements :bug: -*** TODO [#B] Add ERT tests for cj/get-all-contact-emails -*** TODO [#B] Consider turning defun cj/get-all-contact-emails into key based finding -Rather than just finding :EMAIL: matches, why not pass in key and have it find :PHONE: or :ADDRESS: also? -*** TODO [#B] Finding Contact should only match Org Header text -*** TODO [#B] Viewing Contacts should just bring up main contacts org file, not table -*** Manual Tests -- [X] Complete addresses with org-contact in to, cc, bcc -- [ ] Complete addresses with new org-contact in to, cc, bcc -- [ ] Complete with forwards/replies in to, cc, bcc -- [ ] Add contact from mail (with new native mu4e function) -- [ ] Create contact via org-contacts capture template "C" -- [ ] Insert a contact in any buffer via keymap C-c C i -- [ ] Add a new contact from anywhere via keymap C-c C n -- [ ] Look up a contact from anywhere via keymap C-c C f -- [ ] View all contacts from anywhere via keymap C-c C v -- [X] Verify birthdays showing on agenda -- [ ] Verify birthdays showing on agenda after adding new contact -- [ ] Link a contact to an org-roam node (cj/org-contacts-link-to-roam) -*** TODO [#C] Finish Org-Contacts/Mail Modules :maintenance: -** DOING [#B] Org Roam Fixes/Enhancements :enhancement: -*** TODO [#B] New Org-Roam type: Book Notes -Part of the integration with org-noter -** TODO [#B] Describe and Implement Personal Workflow Project Actions -What other actions do I do in a project? -*** TODO [#B] Rename and save any file to project/assets directory -use case: -- I receive a load of files in google-drive/dropbox share or email attachments. -- They all belong to a project -- Projects are structured with an assets or data folder (decide which one first) -- I select the file in dirvish and hit a key, -- It brings up completing read for the projects -- After choosing one, it moves selected files into the proper assets/data folder -*** TODO [#B] Override archiving a task to a project archive -use case: -- C-x a archives to a central archive.org file. -- If I'm in a projectile/project.el project it overrides the destination to an resolved.org file -- if the resolved.org file doesn't exist, it creates it. -- If not in a projectile/project.el project, it falls back to the archive.org file. -** TODO [#B] M-y and show-kill-ring don't load first launch -** TODO [#B] create unfill paragraph (paragraph to single line) -#+BEGIN_QUOTE -I like to have a keybind to do the opposite of fill-paragraph. It’s taken from Stefan Monnier. - -(defun unfill-paragraph (&optional region) - "Takes a multi-line paragraph and turns it into a single line" - (interactive (progn (barf-if-buffer-read-only) '(t))) - (let ((fill-column (point-max)) - (emacs-lisp-docstring-fill-column t)) - (fill-paragraph nil region))) -(define-key global-map "\M-Q" 'unfill-paragraph) -#+END_QUOTE -[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]] -Captured On: [2025-10-15 Wed 18:35] -** DOING [#B] Jumper Package :enhancement: -*** Specification -**** variable:custom: jumper-max-locations -maximum number of locations to store: default 10 -**** variable:internal: jumper--registers -vector of used registers: -(make-register jumper-max-locations nil) -**** variable:internal: jumper--last-location-register -register used to store the last location: ?z -**** method:internal: jumper--point-matches-register -#+begin_src emacs-lisp - ;; First store a position in register 'a' - (point-to-register ?a) - ;; Later, check if current position matches what's in register 'a' - (if (cj/point-matches-register ?a) - (message "Current position matches register 'a'") - (message "Current position differs from register 'a'")) - (defun cj/point-matches-register (register) - "Return t if current position matches position stored in REGISTER. - REGISTER is a character representing the register to check against. - Returns nil if the register doesn't exist or doesn't contain position information." - (let ((reg-val (get-register register))) - (when (and reg-val - (markerp reg-val) - (marker-buffer reg-val) - (eq (current-buffer) (marker-buffer reg-val)) - (= (point) (marker-position reg-val))) - t))) -#+end_src -**** method: -**** method: save-last-position -saves location to register z: (point-to-register ?z) -**** method: save-to-next-register -gets next free register in the sequence -calls (save-last-position) -saves using (point-to-register REGISTER &optional ARG) -*** 2025-09-02 Tue @ 14:06:03 -0500 Functionality Description -Tentative Package Name: Jumper -I typically use registers to jump between places, but I often forget the keybindings. Also, I sometimes overwrite registers by hitting the wrong key. Many Emacs users don't even know about registers. I've seen posts from software developers asking how to conveniently store and jump to specific locations in a large code base. -To solve this I'd like to write a little elisp package that leverages Emacs vanilla register functionality to make jumping between locations using registers simple and transparent. The user won't have to think about registers or what character they've stored their location in at all. All keybindings will be based on keys from home row of the keyboard. -Preliminaries: -We will map the functionality to the keybinding prefix C-; j, but the prefix should be easy to configure. -Let's imagine a set of characters in a sequence. They could be any characters, but for the sake of simplicity we'll use the numbers from 0 to 9, and a one more character used for the "last location" -- the character "z". -What follows is a description of the workflow: -**** Adding Locations -keybinding: <prefix> space (store destination) -If the user is visiting a buffer and presses <prefix> space, -- the current location is stored in the next free register. if this is the first time they are storing a location, the first free register is 0. -user feedback: a message is displayed in the echo area saying "location stored." -If the user stays on the same location and presses prefix <space> again, they should receive a message in the echo area saying the location is already stored, and no changes to the register is made. -If the user moves to a new location and presses prefix <space> again, the next free register is used to store the location, in this case it would be 1. They can then iterate and store additional locations up to the last character in the sequence up to the limit of 10 registers (0 through 9). If they try to store a register after all 10 registers are filled, they will receive a message ("sorry - all jump locations are filled!"). -**** Jump-To A Location -keybinding: <prefix> j (jump to destination) -NOTE: Whenever the user presses <prefix> j, that location is automatically stored in the "last location" register z. -***** When only one location is stored, and the user presses <prefix> j: -If there is only one location stored, the user IS at the location, and they press <prefix> j, they see an echo area message "you're already at the stored location." -If there is only one location stored in the sequence, and the user is NOT at that location, their location is stored in register z, then the user is immediately to their destination location via (jump-to-register). -user feedback: a message is displayed in the echo area saying "jumped to location." -If the user presses <prefix> J again, they are automatically taken back to the location in register z. and the same user feedback message is displayed. -In other words, after the user stores one location and moves elsewhere, pressing <prefix> j will jump back and forth between the two places. -***** When multiple locations are stored, and the user presses <prefix> j: -A completing read is displayed with all the locations between 0 and 9 along with register z (their last location) at the bottom. -Each line contains the letter as well as the content that the register would normally display. In other words, it could just be the register list is displayed, but narrowed down to 0 - 9 and z. -When the user chooses a location 0 -9, z from completing read: -- The current location is stored in register z, replacing their last location -- They are taken to the location via (jump-to-register). -user feedback: a message is displayed in the echo area saying "jumped to location." -**** Removing Locations -keybinding: <prefix> d -A completing read is displayed with the first item (where the cursor is on) being "Cancel". -The rest of the registers are displayed in descending order, i.e., from 9 to 0. -Note: the z register is not displayed. -Selecting a register from the list: -- removes that item from the list, and removes the location stored in the register. -- if the item is NOT the top one in the register, it reorders the rest of the sequence - in other words, if 0 - 9 are all stored, and: - the user removes item 7: - - location 8 is restored in 7 - - location 9 is stored in 8 - the user removes item 0 - - location 1 is stored in 0 - - location 2 is stored in 1 - - location 3 is stored in 2 - - location 4 is stored in 3 - - location 5 is stored in 4 - - location 6 is stored in 5 - - location 7 is stored in 6 - - location 8 is stored in 7 - - location 9 is stored in 8 -user feedback: a message is displayed in the echo area saying "location removed." -**** Open Questions -- Are there buffers which the user should not be able to store in a register? -- How can we prevent the user from creating issues by adding to the registers 0 through 9 by M-x point-to-register or it's keybinding? Is there a way to block or reserve those registers? Or is it better to just choose a sequence that is the least likely to be used by a human user? If so, what would that sequence be? -- Is 10 registers enough for a normal developer? -- I should probably -- Can I use a vector/list internally and map to registers behind the scenes? -Depending on context, can I add the -- Function name at point -- File name + line number -- First few words of the line -Do we think the reordering behavior when deleting locations might confuse users? How to simplify? -What happens if a buffer is deleted that doesn't have a file associated with it? If we're using registers underneath, how do registers handle this? -** TODO [#B] Get Tufte.css working and as a separate entry -Below is one way to get Org-mode’s HTML exporter to play nicely with Tufte-CSS. The basic recipe is: -1. Inject Tufte’s stylesheet into every HTML export -2. Teach Org to emit the little “margin-toggle” + “sidenote” markup that tufte.css expects for footnotes -3. (Optionally) wrap images in <figure> so you get tufte-style captions out of the box -Along the way you’ll see where Org’s default HTML‐classes line up with tufte.css and where you have to override them. -— 1 Inject tufte.css into your exports -Put tufte.css somewhere your exported HTML can see it (for example -~/.emacs.d/assets/tufte.css or a URL on your webserver). Then in your init.el: -(with-eval-after-load 'ox-html -;; 1a) tell Org to link in your tufte.css -(setq org-html-head-extra -"<link rel=\"stylesheet\" href=\"/assets/tufte.css\" type=\"text/css\"/>") -;; 1b) enable HTML5 “fancy” output (so you get <figure> around images) -(setq org-html-html5-fancy t -org-html-inline-images t)) -— 2 Turn Org footnotes into Tufte sidenotes -By default Org emits -<sup class=…><a href="#fn:1" id="fnref:1">[1]</a></sup> -…and then a big =<div id="footnotes">= at the bottom. -Tufte-CSS wants each footnote inline, wrapped in -<label class="margin-toggle">⊕</label> -<input type="checkbox" class="margin-toggle"/> -<span class="sidenote">…your note…</span> -We can override two Org variables: -(with-eval-after-load 'ox-html -;; format of each inline footnote reference -(setq org-html-footnote-format -(concat -"<label for=\"%1$s\" class=\"margin-toggle\">" -"⊕</label>" -"<input type=\"checkbox\" id=\"%1$s\" class=\"margin-toggle\"/>" -"<span class=\"sidenote\">%2$s</span>")) -;; drop Org’s default footnote list at the end -(setq org-html-footnote-separator "")) -Once you do that, exporting an Org file with footnotes will generate the markup tufte.css needs to float them in the margin. -— 3 (Optionally) get <figure> + <figcaption> around images -If you set =org-html-html5-fancy= to t (see step 1) Org will automatically emit: -<figure> -<img src="…"/> -<figcaption>Your caption</figcaption> -</figure> -and tufte.css already has rules for =<figure class="figure">= etc. -— 4 Common pitfalls -• Make sure your href in =org-html-head-extra= actually points to the css that the browser can load (absolute vs. relative). -• If you still see a “Footnotes” section at the bottom, double-check that =org-html-footnote-separator= is set to the empty string and that your init-file got re-evaluated. -• On Windows or if you’re testing locally, run e.g. =python3 -m http.server= inside your export folder so your browser can fetch the CSS. -— 5 Unit test for your footnote hack -Drop this in =~/.emacs.d/tests/test-org-tufte.el= and run =M-x ert RET t RET=: -(require 'ert) -;; load your config; adjust the path if necessary -(load-file "~/.emacs.d/init.el") -(ert-deftest org-tufte-footnote-format-test () -"Ensure each footnote reference becomes a margin-toggle + sidenote." -(let/ ((id "fn:42") -(content "My note.") -(html (format org-html-footnote-format id content))) -(should (string-match-p "class=\"margin-toggle\"" html)) -(should (string-match-p "<span class=\"sidenote\">My note\\.</span>" html)) -;; it must not accidentally reintroduce Org’s bottom-of-page footnote div -(should-not (string-match-p "div id=\"footnotes\"" html)))) -Once that test passes, you know your footnotes are being rewritten into Tufte-style side notes. From there, you can sprinkle in additional filters (e.g. wrap =<blockquote>= in a =.sidenote= class, override list/p table styles, etc.) or just let the rest of tufte.css style Org’s default tags (h1, p, ul, table, code, etc.). -Enjoy your beautifully-typeset Org → HTML exports in true Tufte style! -** TODO [#B] Get Dupre Theme Working :bug: -*** AI Suggestioms -Here are some ideas for making dupre-theme a bit more “complete” and future-proof as an Emacs theme. You don’t have to do all of them, of course, but most “modern” themes ship a fair number of these extra faces and integrations. -**** TODO [#A] Fill out the “standard” Emacs faces -• error / warning / success -• link / link-visited / shadow -• highlight / secondary-selection -• tooltip / tooltip-selection / tooltip-tip -• underline / strike-through -• match / search / lazy-highlight (you already have some isearch) -• info-title-1…info-title-5, info-header-node, help-*-face -**** TODO [#A] Cosmetic / code-style notes -• Factor out a helper macro to reduce repetition: -#+begin_src elisp - (defmacro dupre-face (face &rest specs) - `(,face ((t ,specs)))) -#+end_src -• Use =custom-theme-set-faces= only once; wrap your =when (>= emacs-major-version…)= inside it rather than nesting top-level. -• Move your ERT tests into a separate file like =dupre-theme-test.el= so that the main theme file stays lean. -**** TODO [#B] Make sure you define a “minimum package” set of faces so that folks don’t see glaring unstyled text in any part of Emacs, even if they never install extra packages. -**** TODO [#B] Improve terminal support: -• Detect true-color (=(display-graphic-p)=) vs 256-color vs 16-color and fall-back gracefully. -• Provide “256-color” approximations (you already do this), but you might want a simpler palette for legacy terminals. -**** TODO [#B] Other Packages -The rest is just more of the same—pick the most common modes/packages you and your users rely on and give them a quick coat of paint. Once you’ve rounded out those faces you’ll have hit what most people expect from a “complete” modern Emacs theme. -Based on a quick skim of your modules/ directory (and the packages they pull in via use-package), here’s a non-exhaustive list of the major packages you’re using—along with the faces you should think about adding to dupre-theme so that nothing ends up looking “unstyled.” -1. completion/mini-frameworks - • vertico (vertico-current, vertico-group-title, vertico-separator, vertico-doc) - • orderless (orderless-match-face-0…3) - • consult (consult-preview-line, consult-narrow, consult-region, consult-file) - • marginalia (marginalia-annotation, marginalia-document, marginalia-cache) - • embark (embark-general, embark-heading, embark-target, embark-helpful) - • corfu + cape (corfu-default, corfu-current, corfu-bar, cape-file, cape-keyword etc.) - • company (company-tooltip, company-tooltip-selection, company-scrollbar-thumb) -2. ivy/counsel/swiper (if you ever load them side-by-side) - • ivy-current-match, ivy-minibuffer-match-face-1…4 - • counsel-outline, swiper-match-face-1… -3. syntax checkers & LSP - • flycheck (flycheck-error, flycheck-warning, flycheck-info) - • eglot or lsp-mode (lsp-face-highlight-textual, lsp-face-highlight-read, lsp-face-semantic-type-…) - • tree-sitter (tree-sitter-hl-face:function, :variable, :property, …) -4. git & diffs - • magit (magit-section, magit-section-title, magit-hunk-heading, magit-branch, magit-log) - • diff-hl / git-gutter (diff-hl-face-insert, diff-hl-face-delete, git-gutter:added, git-gutter:deleted) - • diff-mode (diff-added, diff-removed, diff-context, diff-file-header) -5. project management & navigation - • projectile (projectile-project-name, projectile-project-root) - • treemacs (treemacs-root-face, treemacs-directory-face, treemacs-file-face) -6. Org-mode - • org-document-title, org-level-1…org-level-8 - • org-checkbox, org-todo, org-done, org-warning, org-date, org-code, org-block - • plus any “pretty” add-ons you use (org-bullets, org-modern, etc.) -7. Hydra / which-key / key-help - • which-key (which-key-key-face, which-key-group-description-face, which-key-local-map-description-face) - • hydra (hydra-face-red, hydra-face-blue, hydra-face-teal, hydra-face-amaranth) -8. visual helpers - • rainbow-delimiters-depth-1…9 - • highlight-indent-guides-odd/even - • highlight-parentheses-/ - • beacon (beacon), whitespace (whitespace-space, whitespace-tab, whitespace-newline) - • display-line-numbers (line-number, line-number-current-line—you already have these) -9. UI polishers - • all-the-icons (all-the-icons-blue, all-the-icons-red, …) on modeline or treemacs - • doom-modeline (doom-modeline-bar, doom-modeline-buffer-path) - • fancy-splash/dashboard-faces if you use a startup screen -10. terminals & shells - • vterm (vterm-copy-region-highlight) - • ansi-colors (ansi-color-face-bold, ansi-color-face-italic, ansi-color-[0…7]) -To figure out exactly which ones you have in your modules, you can do for example: -#+begin_src elisp - ;; from your ~/.emacs.d - (let (pkgs) - (dolist (f (directory-files "~/.emacs.d/modules" t "\\.el$")) - (with-temp-buffer - (insert-file-contents f) - (goto-char (point-min)) - (while (re-search-forward "use-package +\\([^ ]+\\)" nil t) - (push (intern (match-string 1)) pkgs)))) - (delete-dups pkgs)) -#+end_src -Then for each of those packages, grep in their repo for =defface= or check their manual to see which faces they define, and add reasonable dupre-colours for them. Once you’ve covered that list, you’ll have a “complete” theme that won’t leave any of your modules half-unstyled. -**** TODO [#C] Support the built-in “tab-bar” and “tab-group” faces (Emacs 27+) -• tab-bar / tab-bar-tab / tab-bar-tab-inactive -• tab-bar-close / tab-bar-separator -• tab-group-title / tab-group-separator -**** TODO [#C] Add faces for =dired=, =diff=, =flycheck=/=flymake=, =magit= (or git-gutter), -=ediff=, =erc= etc. For example: -• diff-added, diff-removed, diff-context, diff-file-header -• flycheck-error, flycheck-warning, flycheck-info -• magit-section, magit-log, magit-hunk-heading, magit-branch, etc. -**** TODO [#C] Org-mode -• org-level-1…org-level-8, org-document-title -• org-checkbox, org-code, org-date, org-todo, org-done, org-warning -• org-block, org-block-begin-line, org-block-end-line -**** TODO [#C] Completion frameworks & minibuffer enhancements -• ivy-current-match / ivy-minibuffer-match-face-1…4 -• swiper-line-face -• vertico-current / marginalia-… -• corfu-default / corfu-bar / cape / orderless-match-face-… -**** TODO [#C] LSP / tree-sitter / syntax-aware faces -• lsp-face-highlight-textual, lsp-face-highlight-read, etc. -• tree-sitter-hl-face:variable, :function, :property, etc. -**** TODO [#C] Which-Key, Hydras, rainbow-delimiters, etc. -• which-key-key-face, which-key-local-map-description-face -• hydra-face-red / teal / blue -• rainbow-delimiters-depth-1…9 -**** TODO [#C] Theme Metadata & Packaging -• Add =;; Package-Requires: ((emacs "26.1"))= if you need a minimum. -• Consider adding a README with screenshots. -• A simple Makefile or script to refresh “theme metadata” (=M-x theme-inspect=, MELPA recipe). -**** TODO [#C] Tests -• Extend your ERT suite to cover more faces (org, diff, flycheck…). -• You might write a small helper that loops over a list of faces and asserts they’re defined without errors. -**** Example Sample Faces -Below is a small excerpt showing how you might start adding some of the “missing” standard faces and a couple org faces: -#+begin_src elisp - (custom-theme-set-faces - 'dupre - ;; … your existing faces … - ;; standard global faces - `(error ((t (:foreground ,dupre-intense-red :weight bold)))) - `(warning ((t (:foreground ,dupre-yellow+1 :weight bold)))) - `(success ((t (:foreground ,dupre-green+1 :weight bold)))) - `(link ((t (:foreground ,dupre-blue+1 :underline t)))) - `(shadow ((t (:foreground ,dupre-gray)))) - `(highlight ((t (:background ,dupre-bg+1)))) - `(tooltip ((t (:foreground ,dupre-fg :background ,dupre-bg+2)))) - `(tooltip-selection ((t (:background ,dupre-bg+0)))) - ;; Org-mode sample - `(org-document-title ((t (:foreground ,dupre-yellow+2 :weight bold :height 1.2)))) - `(org-level-1 ((t (:foreground ,dupre-blue+2 :weight bold)))) - `(org-level-2 ((t (:foreground ,dupre-green+1)))) - `(org-checkbox ((t (:foreground ,dupre-yellow :weight bold)))) - `(org-todo ((t (:foreground ,dupre-red :weight bold)))) - `(org-done ((t (:foreground ,dupre-green+2 :weight bold)))) - ) -#+end_src -** TODO [#B] Emacs Add Difftastic Functionality :enhancement: -Linux Binary Install -https://github.com/Wilfred/difftastic -https://www.wilfred.me.uk/blog/2022/09/06/difftastic-the-fantastic-diff/ -https://difftastic.wilfred.me.uk/installation.html (basically $ sudo pacman -S difftastic) -Emacs Package -https://github.com/pkryger/difftastic.el -#+begin_src emacs-lisp - (use-package difftastic - :defer t - :vc (:url "https://github.com/pkryger/difftastic.el.git" - :rev :newest)) -#+end_src -** TODO [#B] Emacs error if no file in buffer-and-file operations :bug: -** TODO [#B] Add Hugo Blogging Functionality :enhancement: -** TODO [#C] Miscellaneous Maintenance -*** TODO [#C] Move persistence and history files into subdir :enhancement: -*** DOING [#B] Implement Performance Recommendations :bug: -**** Lazy-load configuration modules :complex4:impact5: -Loading every module with `require` forces all code and packages to initialize during startup. Switching to `use-package` with `:defer` or `:commands` loads modules only when their functionality is invoked, greatly reducing startup time. -#+begin_src emacs-lisp - (use-package dirvish-config - :load-path "modules" - :commands (dirvish dirvish-side)) -#+end_src -**** DONE Defer Dashboard initialization :complex2:impact4: -CLOSED: [2025-09-08 Mon 14:09] -The dashboard package is loaded eagerly via `:demand`, adding a noticeable delay. Load it after startup and open it on the first idle event instead. -#+begin_src emacs-lisp - (use-package dashboard - :defer t - :hook (emacs-startup . dashboard-open)) -#+end_src -**** CANCELLED Replace synchronous network ping with non-blocking check :complex3:impact2: -CLOSED: [2025-09-08 Mon 16:07] -- State "CANCELLED" from [2025-09-08 Mon 16:07] -`internet-up-p` spawns a blocking `ping` process at startup. Using `make-network-process` avoids shelling out and lets the check run asynchronously. -#+begin_src emacs-lisp - (defun internet-up-p (&optional host) - "Non-blocking network availability check." - (make-network-process - :name "net-check" :host (or host "www.google.com") :service 80 - :sentinel (lambda (proc _) - (setq cj/network-available (eq (process-status proc) 'open)) - (delete-process proc)))) -#+end_src - -This actually added 15 seconds to the launch time. -**** Postpone package refreshing to idle time :complex1:impact3: -Refreshing ELPA archives during startup adds I/O overhead. Defer this check to an idle timer so it runs after Emacs is ready. -#+begin_src emacs-lisp - (add-hook 'emacs-startup-hook - (lambda () (run-with-idle-timer 60 nil #'package-refresh-contents))) -#+end_src - -**** Enable package quickstart caching :complex1:impact2: -Precomputing autoloads with package quickstart reduces the cost of loading package code. -#+begin_src emacs-lisp - (setq package-quickstart t) - (package-quickstart-refresh) -#+end_src - -**** Byte-compile configuration files :complex1:impact2: -Byte-compiled Emacs Lisp loads faster than source. Recompile the configuration directory when changes are made. -#+begin_src emacs-lisp - (byte-recompile-directory user-emacs-directory 0) -#+end_src - -**** Manage garbage collection with GCMH :complex1:impact2: -After startup, `gcmh` dynamically adjusts GC thresholds to minimize pauses without manual tuning. -#+begin_src emacs-lisp - (use-package gcmh - :hook (after-init . gcmh-mode) - :config - (setq gcmh-idle-delay 5 - gcmh-high-cons-threshold (* 16 1024 1024))) -#+end_src - -**** DONE Load Dirvish on demand :complex2:impact3: -CLOSED: [2025-09-13 Sat 20:56] -`dirvish-config` requires Dirvish during initialization, negating deferral. Let `use-package` autoload the commands and enable overrides when Dired loads. -#+begin_src emacs-lisp - (use-package dirvish - :commands (dirvish dirvish-side) - :hook (dired-mode . dirvish-override-dired-mode)) -#+end_src - -**** DONE Start Org-roam lazily :complex3:impact3: -CLOSED: [2025-09-08 Mon 16:10] -Org-roam and its database sync run at startup. Load Org-roam only when Org is active, and start autosync after initialization. -#+begin_src emacs-lisp - (use-package org-roam - :after org - :commands (org-roam-node-find org-roam-node-insert) - :hook (after-init . org-roam-db-autosync-mode)) -#+end_src - -*** DOING [#C] Fix all docstring and elisp linting issues :bug: -- [X] user-constants -- [X] host-environment -- [ ] config-utilities -- [ ] system-defaults -- [ ] keybindings -- [ ] auth-config -- [ ] custom-functions -- [ ] chrono-tools -- [ ] file-config -- [ ] keyboard-macros -- [ ] system-utils -- [ ] text-config -- [ ] undead-buffers -- [ ] ui-config -- [ ] ui-theme -- [ ] ui-navigation -- [ ] font-config -- [ ] diff-config -- [ ] eshell-vterm-config -- [ ] flyspell-and-abbrev -- [ ] help-utils -- [ ] help-config -- [ ] latex-config -- [ ] modeline-config -- [ ] pdf-config -- [ ] selection-framework -- [ ] tramp-config -- [ ] show-kill-ring -- [ ] calibredb-epub-config -- [ ] dashboard-config -- [ ] dirvish-config -- [ ] dwim-shell-config -- [ ] elfeed-config -- [ ] erc-config -- [ ] eww-config -- [ ] httpd-config -- [ ] mail-config -- [ ] markdown-config -- [ ] weather-config -- [ ] prog-general -- [ ] vc-config -- [ ] flycheck-config -- [ ] prog-lsp -- [ ] prog-training -- [ ] prog-c -- [ ] prog-go -- [ ] prog-lisp -- [ ] prog-shell -- [ ] prog-python -- [ ] prog-webdev -- [ ] prog-yaml -- [ ] org-config -- [ ] org-agenda-config -- [ ] org-babel-config -- [ ] org-capture-config -- [ ] org-refile-config -- [ ] org-drill-config -- [ ] org-export-config -- [ ] org-roam-config -- [ ] org-contacts-config -- [ ] ai-config -- [ ] reconcile-open-repos -- [ ] video-audio-recording -- [ ] local-repository -- [ ] eradio-config -- [ ] games-config -- [ ] wrap-up - -** TODO [#C] Git Timemachine Litters Empty Buffers :bug: -Don't choose a revision and you'll see a blank buffer that needs to be killed -** TODO [#C] capture windows should pop up from bottom and 1/3 at most -** TODO [#C] install visible mark so you know where the mark is -#+BEGIN_QUOTE -Showing the location of the mark, probably helps with learning to use mark better. - -(use-package visible-mark - :init - (global-visible-mark-mode) - :custom - (visible-mark-faces '(visible-mark-face1 visible-mark-face2)) - (visible-mark-forward-faces '(visible-mark-face1 visible-mark-face2)) - (visible-mark-max 2)) -#+END_QUOTE -[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]] -Captured On: [2025-10-15 Wed 18:33] -** TODO [#C] Edit Indirect (Custom Code) Bugs and Improvements -*** Bugs (by Severity) - -*** High Severity - -*Race Condition in =edit-indirect--commit=* -The commit function modifies the parent buffer while iterating through hooks that may also modify the buffer. The use of markers helps, but there's still a potential race condition if hooks modify the region being replaced. The =save-match-data= approach with markers could fail if the hooks significantly alter the buffer structure between marker creation and replacement. - -*Potential Data Loss in =edit-indirect--clean-up=* -The function calls =delete-overlay= before setting =edit-indirect--overlay= to nil. If =delete-overlay= signals an error (e.g., if the overlay was already deleted), the overlay reference won't be cleared, potentially causing infinite loops in =edit-indirect--abort-on-kill-buffer=. - -*** Medium Severity - -*Missing Buffer Validation in =edit-indirect--commit=* -The function doesn't check if the parent buffer still exists before attempting to modify it. If the parent buffer was killed while the indirect buffer was being edited, this will cause an error. - -*Hook Execution Order Issue* -The =edit-indirect--rebind-save-hooks= function is added to =after-change-major-mode-hook= globally but never removed. This means it will be called for every buffer that changes major mode, not just edit-indirect buffers, causing unnecessary overhead. - -*** Low Severity - -*Inefficient Overlay Search in =edit-indirect--search-for-edit-indirect=* -The function uses =overlays-in= which returns all overlays, then filters them. For buffers with many overlays, this is inefficient. - -*Missing Error Handling in =edit-indirect--display-buffer=* -The function doesn't handle cases where =display-buffer= returns nil (which can happen if all windows are dedicated or display is inhibited). - -*** Recommended Improvements - -*** Easy Implementation - -*Add Buffer Existence Check* -Add a check in =edit-indirect--commit= to ensure the parent buffer still exists: -#+begin_src emacs-lisp -(unless (buffer-live-p (overlay-buffer edit-indirect--overlay)) - (error "Parent buffer no longer exists")) -#+end_src - -*Improve Hook Management* -Remove the global hook when not needed: -#+begin_src emacs-lisp -(defun edit-indirect--rebind-save-hooks () - (when (edit-indirect-buffer-indirect-p) - (setq buffer-offer-save t) - (add-hook 'write-contents-functions #'edit-indirect--commit-on-save nil t) - ;; Remove global hook after use - (remove-hook 'after-change-major-mode-hook #'edit-indirect--rebind-save-hooks))) -#+end_src - -*Add Safety to Clean-up* -Wrap cleanup operations in condition-case: -#+begin_src emacs-lisp -(defun edit-indirect--clean-up () - (condition-case nil - (delete-overlay edit-indirect--overlay) - (error nil)) - (setq edit-indirect--overlay nil) - ...) -#+end_src - -*Add Display Buffer Error Handling* -Check display-buffer return value: -#+begin_src emacs-lisp -(defun edit-indirect--display-buffer (buffer) - (with-current-buffer buffer - (setq-local edit-indirect--should-quit-window t)) - (let ((window (display-buffer buffer))) - (if window - (select-window window) - (switch-to-buffer buffer))) - nil) -#+end_src - -*** Medium Implementation - -*Optimize Overlay Search* -Use a more efficient search strategy: -#+begin_src emacs-lisp -(defun edit-indirect--search-for-edit-indirect (beg end) - (cl-loop for overlay in (overlays-in beg end) - when (overlay-get overlay 'edit-indirect-buffer) - return overlay)) -#+end_src - -*Add Undo Boundary Management* -Add undo boundaries to make commits atomic: -#+begin_src emacs-lisp -(defun edit-indirect--commit () - (undo-boundary) - ;; ... rest of commit logic ... - (undo-boundary)) -#+end_src - -*Improve Buffer Naming* -Make buffer names more informative: -#+begin_src emacs-lisp -(defun edit-indirect--create-indirect-buffer (beg end overlay) - (let* ((mode-name (symbol-name major-mode)) - (buffer-name (format "*edit-indirect %s [%s]*" - (buffer-name) - (replace-regexp-in-string "-mode$" "" mode-name)))) - ...)) -#+end_src - -*** Hard Implementation - -*Add Support for Nested Indirect Edits* -Allow editing a region within an already indirect buffer by tracking parent chain. - -*Implement Diff Preview* -Add a command to show differences between the indirect buffer and parent region before committing: -#+begin_src emacs-lisp -(defun edit-indirect-diff-preview () - "Show differences between indirect buffer and parent region." - (interactive) - ;; Implementation using diff-mode - ) -#+end_src - -*Add Persistent Session Support* -Save and restore indirect editing sessions across Emacs restarts using desktop.el integration. - -*Implement Conflict Resolution* -Handle cases where the parent buffer was modified while editing in the indirect buffer, similar to version control merge conflicts. - -*Add Support for Multiple Regions* -Allow editing multiple non-contiguous regions in a single indirect buffer, useful for refactoring similar code blocks simultaneously. - -** TODO [#C] Review Titlecase Functionality -added in custom. Came from: https://codeberg.org/acdw/titlecase.el -Originally seen at https://emacselements.com/true-titlecase-in-emacs.html -** TODO [#C] Revisit and Refactor Localrepo Functionality -** TODO [#C] Fix Exporting Documents to HTML. -This appears to work with emacs -q. What's up with your config? -** TODO [#C] Rename 'sync-dir' Variable in Init File to 'org-dir' -[[https://github.com/ryuslash/mode-icons][ryuslash/mode-icons: Show icons instead of mode names]] -Captured On: [2025-06-07 Sat 13:29] -** TODO [#C] Emacs Config: Narrowing Org doesn't allow refresh by type -After generating an org agenda list, I can narrow the type to all DONE or IN-PROGRESS. However when I use my custom functions to just get the agenda for the current buffer, I can't. The scope has now widened and the refresh is for all buffers. -** TODO [#C] Add a Restrict to Subtree Org Agenda Command -In Org-mode the “agenda” machinery by default doesn’t pay any attention to your buffernarrow — it always scans whole files in your =org-agenda-files=. However, you can teach Org-agenda to only look between two points (the start and end of your current subtree) by using the built-in “restrict” hooks: -1. =org‐agenda‐restrict= – turn on restriction -2. =org‐agenda‐restrict‐begin= – a marker or position where scanning begins -3. =org‐agenda‐restrict‐end= – where it ends -4. (optionally) set =org-agenda-files= to just your current file -Here is a drop-in =org-agenda-custom-commands= entry that will give you a “Subtree TODOs” view limited to the subtree you’re on. Put this in your Emacs init: -#+begin_src emacs-lisp - ;; ------------------------------------------------------------------- - ;; 1) Define a custom agenda command "C" (hit C-c a C) - ;; that shows only TODO entries in the current subtree. - ;; ------------------------------------------------------------------- - (with-eval-after-load 'org - (add-to-list 'org-agenda-custom-commands - '("C" ; the key you press after C-c a - "Subtree TODOs" ; a descriptive name - ((todo "" ; match any TODO keyword - ;; === per-block settings === - ;; only look in this file - (org-agenda-files (list buffer-file-name)) - ;; enable the restriction engine - (org-agenda-restrict t) - ;; start at the top of the current heading - (org-agenda-restrict-begin - (progn (org-back-to-heading t) (point))) - ;; end at the end of this subtree - (org-agenda-restrict-end - (progn (org-end-of-subtree t) (point)))) - ;; you can add more blocks here if you like - )))) -#+end_src -How this works, step by step: -• We bind a new custom command under the key “C” (so you’ll type =C-c a C=). -• In that command we use the =todo= matcher =""= to catch every headline whose state is one of your TODO keywords. -• We dynamically set -– =org-agenda-files= to just the current buffer’s file, -– =org-agenda-restrict= to non-nil so Org will honor the begin/end markers, -– =org-agenda-restrict-begin= to the position of the current heading, -– =org-agenda-restrict-end= to the end of the subtree. -When you invoke it (=C-c a C=), Org will only scan headlines in that slice of the file and will build you a mini-agenda of TODO items from exactly that subtree. -Quick alternative: if you don’t need the full agenda machinery (dates, deadlines, etc.) but just want a fast outline of your TODOs under the current heading, you can also use the sparse-tree command: -• =C-c / t= ⇒ shows only the TODO headings in the narrowed or whole buffer as an indented outline. -— Tips & Pitfalls — -• Make sure your file is saved and in =org-agenda-files= (the snippet above forces it). -• Remember to call =org-narrow-to-subtree= (or let the command compute its own begin/end via =org-back-to-heading=/=org-end-of-subtree=). -• If you rename or move your file, Emacs must still see =buffer-file-name= valid. -• You can duplicate the above snippet for other TODO states, agenda views, or matchers (deadlines, tags, etc.) -With this in place you effectively get an agenda tailored to exactly the bit of tree you’re working on. -** TODO [#C] Find Another Keymapping for Emojify -** TODO [#C] Emacs: Add Reverso Functionality :enhancement: -https://sqrtminusone.xyz/packages/reverso/ -check his config for a usage example -Reverso -reverso.el is a package of mine that provides Emacs interface for https://reverso.net. -#+begin_src emacs-lisp - (use-package reverso - :straight (:host github :repo "SqrtMinusOne/reverso.el") - :init - (my-leader-def "ar" #'reverso) - :commands (reverso) - :config - (setq reverso-languages '(russian english german spanish french portuguese)) - (reverso-history-mode)) -#+end_src -** TODO [#C] Install Magit TODOs -#+BEGIN_QUOTE -It’s nice for magit to display in-tree TODOs in the status buffer: - -(use-package magit-todos - :init (magit-todos-mode) - :custom - (magit-todos-exclude-globs '(".git/" "*.gz" "*.html" "*.fastq" "*.fq")) - :after (magit)) -#+END_QUOTE -[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]] -Captured On: [2025-10-15 Wed 18:40] -** TODO [#C] Move from company to corfu -#+BEGIN_QUOTE -Completion -A completion framework—I used to use company but corfu seems to integrate with stock Emacs better. - -(use-package corfu - :custom - (corfu-cycle t) - :init - (global-corfu-mode) - :config - (with-eval-after-load 'savehist - (corfu-history-mode 1) - (add-to-list 'savehist-additional-variables 'corfu-history))) -Use more completion at point functions from cape. dabbrev completion means that you can complete from words already present in the buffer, which helps for documents with long words that get used a lot. - -(use-package cape - :init - (dolist (fn (list #'cape-file - (cape-capf-super #'cape-dabbrev #'cape-dict #'org-block-capf #'cape-keyword))) - (add-to-list 'completion-at-point-functions fn)) - :config - (let ((wordlist (getenv "WORDLIST"))) - (setq - cape-dict-file wordlist - ispell-alternate-dictionary wordlist))) -#+END_QUOTE -[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]] -Captured On: [2025-10-15 Wed 18:37] -** TODO [#C] Config recentf to exclude mail -#+BEGIN_QUOTE -recentf provides a persistent list of recently-opened files. - -(use-package recentf - :ensure nil - :init (recentf-mode) - :config - (setq recentf-exclude '("Maildir"))) - -#+END_QUOTE -[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]] -Captured On: [2025-10-15 Wed 18:36] -** TODO [#C] consider installing minimap -#+BEGIN_QUOTE -A mini-map (miniaturised window showing more of the buffer than the main window) is nice for longer documents: - -(use-package minimap - :custom - (minimap-major-modes '(org-mode prog-mode)) - (minimap-window-location 'right) - :bind ("C-+" . minimap-mode)) -#+END_QUOTE -[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]] -Captured On: [2025-10-15 Wed 18:31] -** TODO [#C] check into org-download for drag/drop images into org-buffers -#+BEGIN_QUOTE -Drag and drop images into Org buffers to insert links: - -(use-package org-download - :custom - (org-download-image-dir "./img")) -#+END_QUOTE -[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]] -Captured On: [2025-10-15 Wed 18:30] -** TODO [#D] BrainCurses Emacs Mastermind Game -[[https://sourceforge.net/projects/braincurses/][BrainCurses download | SourceForge.net]] -Captured On: [2025-09-26 Fri 00:38] -** TODO [#D] Emacs: org project should be yellow or green like todo -** TODO [#D] Emacs Signal Client -*** TODO investigate: net/signel.org · master · undefined · GitLab -[[https://gitlab.com/jaor/elibs/-/blob/master/net/signel.org][net/signel.org · master · undefined · GitLab]] -Captured On: [2025-05-29 Thu 04:24] -*** TODO investigate: signel, a barebones signal chat on top of signal-cli -[[https://jao.io/blog/signel.html][signel, a barebones signal chat on top of signal-cli]] -Captured On: [2025-05-29 Thu 04:23] -** TODO [#D] Add test document to test graphviz-dot-mode -** TODO [#D] Consider Replicating Some of This Auctex and PDF Tools Code -[[https://www.reddit.com/r/emacs/comments/cd6fe2/how_to_make_emacs_a_latex_ide/][How to make Emacs a Latex IDE? : r/emacs]] -Captured On: [2025-08-14 Thu 03:43] -** TODO [#D] Install ZOxide Integration into Emacs -[[https://gitlab.com/Vonfry/zoxide.el][Vonfry / zoxide.el · GitLab]] -Captured On: [2025-06-07 Sat 17:11] -** TODO [#D] manateelazycat/awesome-tray: Hide mode-line, display necessary information at right of minibuffer. -[[https://github.com/manateelazycat/awesome-tray][manateelazycat/awesome-tray: Hide mode-line, display necessary information at right of minibuffer.]] -Captured On: [2025-06-07 Sat 13:29] -** TODO [#D] ryuslash/mode-icons: Show icons instead of mode names diff --git a/docs/values-comparison.org b/docs/values-comparison.org deleted file mode 100644 index 128c6710..00000000 --- a/docs/values-comparison.org +++ /dev/null @@ -1,107 +0,0 @@ -#+TITLE: Values Comparison - Simple vs Reliability -#+DATE: 2025-10-30 - -* What Currently Supports Each Value - -| Theme/Practice | Simple | Reliability | -|---------------------------------------+--------+-------------| -| *Code Quality* | | | -| Clear naming | ✓ | | -| Small functions | ✓ | | -| Good comments (why not what) | ✓ | | -| Abstractions when warranted | ✓ | | -|---------------------------------------+--------+-------------| -| *Architecture* | | | -| Modular design | ✓ | | -| Extract custom code into packages | ✓ | | -| Good architecture enables evolution | ✓ | | -| Technical debt has real cost | ✓ | | -|---------------------------------------+--------+-------------| -| *Production Practices* | | | -| Testing | ✓ | ✓ | -| Documentation | ✓ | | -| Version control | ✓ | | -|---------------------------------------+--------+-------------| -| *Failure Planning* | | | -| Rollback capability (localrepo) | ✓ | ✓ | -| Error handling | ✓ | ✓ | -| Graceful degradation | ✓ | ✓ | -|---------------------------------------+--------+-------------| -| *Trust & Stability* | | | -| Never use tools you can't trust | ✓ | ✓ | -| Uptime matters | | ✓ | -| Config breakage crushes productivity | | ✓ | -| More packages = more breakage risk | | ✓ | -| Need confidence to refactor | | ✓ | -|---------------------------------------+--------+-------------| - -* Key Quotes Supporting Each - -** Simple -- "Config IS production software - treat it as such" -- "Clear naming, small functions, good comments" -- "Apply production practices: Testing, modularity, documentation" -- "Simple means maintainable production software, not minimal or primitive" - -** Reliability/Resilience -- "Never use a tool you can't fully trust" -- "The impact when it's broken can crush my productivity" -- "More packages = more potential breakage. Need ability to roll back" -- "Plan for failure: Rollback capability, error handling, graceful degradation" -- "Quality matters because uptime matters" - -* Analysis - -** If Reliability is part of Simple: -*Simple means:* Maintainable, trustworthy, production-quality code that fails gracefully - -*Pros:* -- Keeps to 3 values -- Failure planning is a consequence of good engineering (already in Simple) -- Less redundancy - -*Cons:* -- "Simple" might not clearly convey the "must not break" urgency -- Rollback/resilience themes could get lost - -** If Reliability is its own value: -*Simple means:* Clear, maintainable code -*Reliability means:* Never breaks, fails gracefully, always recoverable - -*Pros:* -- Explicit focus on uptime/stability -- Clear that this is mission-critical (crushes productivity when broken) -- Resilience practices (localrepo, testing, error handling) have clear home - -*Cons:* -- 4 values instead of 3 -- Some overlap with Simple - -* Decision Framework - -Ask yourself: - -1. When choosing between two approaches, would "reliability" change your decision differently than "simple"? - - Example: Complex caching solution that's faster but harder to debug - - Simple says: "Too complex, harder to maintain" - - Reliability says: "If it breaks less often and has rollback, worth the complexity" - -2. Does "config can't break" deserve equal weight with Fast and Intuitive? - -3. Is the "trust" theme strong enough to be its own value? - -* Recommendation - -If you had to choose, I'd suggest: **Keep Simple, absorb Reliability into it.** - -Why? Most of your reliability practices (testing, rollback, error handling) are already listed under "production software practices" in Simple. The "never use tools you can't trust" is already there too. - -**But** - you could rename Simple to "**Reliable**" or "**Solid**" if the stability theme is more important than the simplicity theme. - -Alternative value names that might capture both: -- *Solid* - Stable, maintainable, trustworthy -- *Resilient* - Fails gracefully, recovers easily, maintainable -- *Dependable* - Simple enough to trust, robust enough to rely on - -What resonates? diff --git a/early-init.el b/early-init.el index 79ff7816..c1cd54c8 100644 --- a/early-init.el +++ b/early-init.el @@ -58,8 +58,12 @@ ;; ------------------------------ Bug Workarounds ------------------------------ -;; Prevent org-element from being natively compiled again by adding the line -(setq native-comp-jit-compilation-deny-list '(".*org-element.*")) +;; Disable async native compilation to prevent "Selecting deleted buffer" errors +;; This is a known issue in Emacs 30.x where async compilation buffers get +;; deleted before the compilation process completes. Synchronous compilation +;; is slower initially but avoids these race conditions. +(setq native-comp-deferred-compilation nil) ;; Disable async/deferred compilation +(setq native-comp-async-report-warnings-errors nil) ;; Silence async warnings ;; --------------------------- Warning Notifications --------------------------- @@ -84,6 +84,7 @@ (require 'pdf-config) ;; pdf display settings (require 'quick-video-capture) ;; download videos with a browser bookmark (require 'video-audio-recording) ;; desktop and/or audio recording via ffmpeg +(require 'transcription-config) ;; audio transcription using Whisper (require 'weather-config) ;; utility to display the weather ;; -------------------------------- Programming -------------------------------- diff --git a/issues.org b/issues.org deleted file mode 100644 index c61b8517..00000000 --- a/issues.org +++ /dev/null @@ -1,1879 +0,0 @@ -#+TITLE: Emacs Configuration Issues & Enhancement Roadmap -#+AUTHOR: Claude Code Analysis -#+DATE: 2025-10-30 - -* New Open Work - -** TODO [#A] Duplicate variable declaration in early-init.el :bug:architecture:critical: -:PROPERTIES: -:FILE: early-init.el -:LINES: 76-78, 104-105 -:END: - -The variable =cj/network-available= is declared twice (lines 76-78 and 104-105). - -*Impact:* Confusing code structure, potential initialization issues. - -*Fix:* Remove the duplicate declaration at lines 104-105. The first declaration at line 76 is sufficient. - -** TODO [#A] Missing function definition in vc-config.el :bug:critical: -:PROPERTIES: -:FILE: modules/vc-config.el -:LINE: 123 -:END: - -The keymap binding references =cj/goto-git-gutter-diff-hunks= (line 123) but this function is never defined anywhere in the config. - -*Impact:* Keybinding =C-; v d= will fail with "Symbol's function definition is void" error. - -*Fix:* Either implement the missing function or remove the keybinding. Likely should use =git-gutter:popup-hunk= or create a wrapper that calls =consult-line= filtered to lines with git-gutter markers. - -** TODO [#A] Missing function in wrap-up.el :bug:critical: -:PROPERTIES: -:FILE: modules/wrap-up.el -:LINE: 28 -:END: - -Calls =cj/log-silently= but function is never defined in the config. - -*Impact:* Init will fail at startup with undefined function error. - -*Fix:* Define =cj/log-silently= in system-defaults or config-utilities, or replace with a simple =(message ...)= call. - -** TODO [#A] Network check runs synchronously during startup :performance:critical: -:PROPERTIES: -:FILE: early-init.el -:LINES: 189, 206 -:END: - -The =cj/internet-up-p= function is called *synchronously* during early-init (lines 189, 206), which means Emacs startup blocks waiting for ping or network connection attempts. - -*Impact:* Adds 1+ seconds to every startup, even when network is available. Completely unacceptable UX. - -*Fix:* -1. Default =cj/network-available= to =t= (assume network is available) -2. Run network check asynchronously in background after startup -3. Only use network check when user explicitly runs =package-refresh-contents= -4. Consider removing network check entirely - let package.el handle its own errors - -Modern approach: Remove =cj/use-online-repos= flag entirely. Let package operations fail gracefully if network is down, rather than preemptively checking. - -** TODO [#A] Commented-out non-commented variable in org-config.el :bug:functionality: -:PROPERTIES: -:FILE: modules/org-config.el -:LINE: 129-130 -:END: - -Line 129 has a TASK comment about removing a non-existent variable =org-indent-indentation-per-level=, but line 130 actually tries to set it. - -*Impact:* Setting an undefined variable that does nothing. - -*Fix:* Remove lines 129-130 entirely if the variable doesn't exist. Verify in org-mode source whether this was renamed or removed. - -** TODO [#B] Replace Company with Corfu for better performance and maintenance :enhancement:architecture: -:PROPERTIES: -:FILE: modules/selection-framework.el -:LINES: 202-236 -:END: - -Currently using Company for completion, but Corfu is the more modern choice: - -*Why Corfu is better:* -- ~3x faster than Company (uses child frames vs overlays) -- Better maintained (company-mode development has slowed) -- Smaller, simpler codebase -- Works seamlessly with modern completion frameworks (Vertico/Consult) -- Native corfu-popupinfo is cleaner than company-quickhelp -- Better integration with Cape for completion-at-point backends - -*Migration path:* -1. Replace =company= with =corfu= + =corfu-popupinfo= -2. Use =cape= for additional backends (cape-file, cape-keyword, cape-dabbrev) -3. Keep company-mode disabled in mu4e (corfu already respects this) -4. Much simpler configuration, no company-box needed - -*Estimated effort:* 30 minutes. High value/low effort. - -** TODO [#B] Consolidate debug logging functions :architecture:maintainability: -:PROPERTIES: -:FILES: modules/org-agenda-config-debug.el, modules/wrap-up.el -:END: - -Debug logging is inconsistent: -- =org-agenda-config-debug.el= defines =cj/log-silently= -- =wrap-up.el= *calls* =cj/log-silently= but it's not loaded yet -- =config-utilities.el= has debug functions but not logging - -*Fix:* Create =modules/debug-logging.el= with consistent logging infrastructure: -- =cj/log-debug=, =cj/log-info=, =cj/log-warning=, =cj/log-error= -- Buffer-based logging with timestamps -- Easy toggle for debug mode -- Load early in init.el before other modules - -** TODO [#B] Missing error handling in org-agenda rebuild :stability: -:PROPERTIES: -:FILE: modules/org-agenda-config.el -:LINES: 91-105 -:END: - -=cj/build-org-agenda-list= recursively searches directories but has no error handling for: -- Permission denied errors -- Circular symlinks -- Very deep directory trees -- Network filesystem timeouts (if projects-dir is on NFS/CIFS) - -*Impact:* Agenda rebuild can hang or fail silently. - -*Fix:* Add condition-case wrapper with specific handlers: -#+begin_src elisp -(condition-case err - (cj/add-files-to-org-agenda-files-list projects-dir) - (file-error - (message "Warning: Could not scan %s: %s" projects-dir err)) - (error - (message "Error building agenda: %s" err))) -#+end_src - -** TODO [#B] Unify path handling functions :architecture:maintainability: -:PROPERTIES: -:FILES: modules/dirvish-config.el, modules/custom-buffer-file.el -:END: - -Path handling is duplicated across modules: -- =cj/get-project-root= in dirvish-config.el (lines 408-426) -- Similar logic needed in custom-buffer-file for relative paths -- Different approaches to home directory abbreviation - -*Fix:* Create =modules/path-utils.el= with: -- =cj/get-project-root= (shared implementation) -- =cj/abbreviate-path= (handles ~, project-relative, absolute) -- =cj/path-relative-to= (generalized relative path function) -- Consistent path handling across all modules - -** TODO [#B] Treesit-auto grammar pinning is fragile :stability:maintenance: -:PROPERTIES: -:FILE: modules/prog-general.el -:LINES: 106-113 -:END: - -Go grammar is pinned to v0.19.1 by directly modifying the treesit-auto-recipe-list vector with =aset=. - -*Issues:* -- Hardcoded slot index (6) will break if treesit-auto changes struct layout -- No validation that the recipe exists or that slot 6 is :revision -- Comment says "Emacs 30.2" but doesn't check version -- Will fail silently if treesit-auto changes - -*Fix:* -1. Use proper accessor functions if available (treesit-auto-recipe-revision setter) -2. Add version check: =(when (version< emacs-version "30.3") ...)= -3. Add error handling and warning if pinning fails -4. Document why pinning is needed in comments - -** TODO [#B] Test coverage gaps in critical modules :testing:quality: -:PROPERTIES: -:FILES: modules/org-agenda-config.el, modules/mail-config.el, modules/ai-config.el -:END: - -Excellent test coverage for utility functions (custom-*, undead-buffers, org-roam helpers), but missing tests for: - -*High-value untested areas:* -1. =org-agenda-config.el=: - - =cj/build-org-agenda-list= (file discovery logic) - - =cj/org-skip-subtree-if-*= functions (agenda filtering) - - =cj/org-agenda-skip-subtree-if-not-overdue= (date logic) - -2. =mail-config.el=: - - =cj/mu4e-toggle-remote-images= (security critical) - - Email header parsing/address completion logic - -3. =ai-config.el=: - - =cj/gptel-add-file= (file path handling) - - =cj/gptel--fresh-org-prefix= (timestamp formatting) - -4. =org-roam-config.el=: - - =cj/move-org-branch-to-roam= (complex string manipulation) - - Already has tests for helpers, needs integration test - -*Recommendation:* Prioritize testing the org-agenda filtering functions - they're date-sensitive and easy to break. - -** TODO [#B] System-defaults autoload has wrong function name :bug:minor: -:PROPERTIES: -:FILE: modules/system-defaults.el -:LINE: 24 -:END: - -Line 24 has =autoload= for =env-bsd-p= from =host-environment=, but this file *requires* host-environment at compile time anyway (line 20). - -*Impact:* Redundant autoload, no functional issue. - -*Fix:* Remove the autoload statement. The eval-when-compile require is sufficient. - -** TODO [#B] Inconsistent coding system declarations :maintainability: -:PROPERTIES: -:FILES: early-init.el, modules/system-defaults.el -:END: - -UTF-8 coding system is set in two places: -- =early-init.el= lines 252-261 (before package loading) -- =system-defaults.el= lines 60-69 (after package loading) - -*Impact:* Redundant configuration, harder to maintain, unclear which takes precedence. - -*Fix:* Consolidate all coding system setup in early-init.el since it needs to be set before packages load. Remove from system-defaults.el. - -** TODO [#B] Chime notification times documentation mismatch :documentation:usability: -:PROPERTIES: -:FILE: modules/org-agenda-config.el -:LINES: 268-270 -:END: - -Comment says "5 minutes before and at event time (0 minutes)" but the value is ='(5 0)=. - -*Issue:* It's unclear if 0 means "at event time" or "immediately". Better documentation needed. - -*Fix:* Improve comment: -#+begin_src elisp -;; Alert 5 minutes before event and again at event time (0 = event start) -(setq chime-alert-time '(5 0)) -#+end_src - -** TODO [#C] Org-roam template paths use string concatenation :maintainability: -:PROPERTIES: -:FILE: modules/org-roam-config.el -:LINES: 43, 48, 53, 144, 150 -:END: - -Template file paths are built with =concat= instead of using =expand-file-name= or constants. - -*Impact:* Harder to change template location, no validation that files exist. - -*Fix:* Define template constants at top of file: -#+begin_src elisp -(defconst cj/org-roam-template-dir - (expand-file-name "org-roam-templates" user-emacs-directory)) -(defconst cj/org-roam-template-v2mom - (expand-file-name "v2mom.org" cj/org-roam-template-dir)) -;; etc... -#+end_src - -Validate files exist during config load. - -** TODO [#C] Dirvish-config has long function that should be split :refactor:maintainability: -:PROPERTIES: -:FILE: modules/dirvish-config.el -:LINES: 350-406 -:END: - -=cj/dired-copy-path-as-kill= is 57 lines with multiple responsibilities: -- Validation -- Project root detection -- Home directory handling -- Org-link formatting -- Clipboard operations -- User feedback - -*Fix:* Extract smaller functions: -- =cj/--path-type-for-file= (returns :project-relative, :home-relative, or :absolute) -- =cj/--format-as-org-link= (wraps path in org syntax) -- =cj/dired-copy-path-as-kill= (orchestrates the above) - -Better testability, clearer logic flow. - -** TODO [#C] Consider Embark for file operations instead of custom dirvish bindings :enhancement:usability: -:PROPERTIES: -:FILE: modules/dirvish-config.el -:LINES: 298-334 -:END: - -Dirvish config defines many custom keybindings (d, l, L, o, O, f, P) for file operations. - -*Alternative approach:* Use Embark for contextual file actions: -- Embark already knows about files in dired -- Can define actions once, use everywhere (not just dired) -- More discoverable (embark-act shows all options) -- Less cognitive load (one keybinding to remember: C-.) - -*Recommendation:* Keep the custom functions (good implementations), but expose them via embark-act instead of direct keybindings. Cleaner keymap, more flexible. - -** TODO [#C] Missing :ensure nil for more built-in packages :correctness:minor: -:PROPERTIES: -:FILE: Multiple modules -:END: - -Some built-in package use-package declarations are missing =:ensure nil=: -- =selection-framework.el=: =delsel= (line 54) -- Several others throughout - -*Impact:* Minimal - use-package won't try to install built-ins anyway. But inconsistent with the pattern used elsewhere. - -*Fix:* Add =:ensure nil= for consistency, or document why some have it and others don't. - -** TODO [#C] Consider lazy-loading more packages :performance:startup: -:PROPERTIES: -:FILE: modules/selection-framework.el -:LINES: 22, 40, 48, 151, 166, 240-253 -:END: - -Several completion packages use =:demand t= (vertico, marginalia, orderless, prescient): -- Vertico: Reasonable to demand (core UI) -- Marginalia: Could be :after vertico -- Orderless: Could be :after vertico -- Prescient: Could be :after vertico -- Company-prescient: Could be :after (vertico company) - -*Impact:* All load at startup, adds ~100ms. Not critical, but could be optimized. - -*Fix:* Profile with esup to see actual impact. If it's <100ms, probably not worth the complexity. - -** TODO [#C] Add validation for chime.el local path :stability:enhancement: -:PROPERTIES: -:FILE: modules/org-agenda-config.el -:LINE: 264 -:END: - -Config loads chime from local directory =~/code/chime.el= without checking if it exists. - -*Impact:* If directory doesn't exist, load fails silently or with cryptic error. - -*Fix:* Add existence check with helpful error message: -#+begin_src elisp -:load-path (lambda () - (let ((path "~/code/chime.el")) - (if (file-directory-p path) - path - (warn "chime.el not found at %s" path) - nil))) -#+end_src - -** TODO [#C] Document why org-msg uses local fork :documentation:maintenance: -:PROPERTIES: -:FILE: modules/mail-config.el -:LINE: 287 -:END: - -Loading org-msg from local path =/home/cjennings/code/org-msg= instead of MELPA. - -*Question:* Is this a temporary fork with patches? Does it need to stay local? - -*Fix:* Add comment explaining: -- What changes are in local fork -- Whether it's temporary or permanent -- Link to upstream PR if patches are submitted -- When it can switch back to MELPA - -** TODO [#C] Performance: Company idle-delay is very high :performance:usability: -:PROPERTIES: -:FILE: modules/selection-framework.el -:LINE: 212 -:END: - -Company idle-delay is set to 2 seconds, which is quite slow. Modern editors typically use 0.1-0.3s. - -*Impact:* Completion feels sluggish, requires manual triggering often. - -*Recommendation:* -- If keeping Company: Try 0.3 seconds -- If switching to Corfu: Default 0.1s is perfect - -User preference, but current setting feels unresponsive. - -** TODO [#C] org-protocol may not be fully configured :functionality:enhancement: -:PROPERTIES: -:FILE: modules/org-config.el -:LINES: 94-95, 116 -:END: - -=org-protocol= is loaded (line 95) and added to org-modules (line 116), but: -- No desktop entry file mentioned -- No documentation on how to use it -- org-webclipper exists but unclear if it uses org-protocol - -*Fix:* Either: -1. Document org-protocol setup (desktop file, browser integration) -2. Remove if unused (org-webclipper might be the preferred approach) - -** TODO [#D] Consider adding use-package :diminish for minor modes :enhancement:ui: -:PROPERTIES: -:FILE: Multiple modules -:END: - -Many minor modes appear in the modeline even though doom-modeline hides them. - -*Enhancement:* Add =:diminish= to use-package declarations for: -- =symbol-overlay-mode= -- =git-gutter-mode= -- =flycheck-mode= -- =yas-minor-mode= -- =ws-butler-mode= -- =rainbow-mode= - -Cleaner modeline if doom-modeline isn't used or fails to load. - -** TODO [#D] Add .dir-locals.el for project-specific settings :enhancement:devex: -:PROPERTIES: -:FILE: N/A (new file) -:END: - -Create =.dir-locals.el= in =~/.emacs.d= to: -- Set =fill-column= to 80 for elisp files -- Enable =flycheck-mode= for all elisp -- Set =checkdoc= settings locally -- Configure =outline-minor-mode= for easy navigation - -Helps maintain consistency when editing config. - -** TODO [#D] Consider moving from doom-modeline to mood-line :enhancement:performance: -:PROPERTIES: -:FILE: modules/modeline-config.el -:END: - -Doom-modeline is feature-rich but heavy. For the minimal info you're displaying, mood-line might be better: - -*Mood-line advantages:* -- ~10x faster than doom-modeline -- Pure elisp (no external dependencies) -- Very simple, hackable codebase -- Shows exactly what you need: filename, position, major-mode -- No nerd-icons dependency (can still use them if wanted) - -*Doom-modeline advantages:* -- More battle-tested -- Better git integration -- More features (even if disabled) - -*Recommendation:* Try mood-line. If you miss doom-modeline features, easy to switch back. Performance difference is noticeable on older hardware. - -** TODO [#D] Add commentary headers to test utility files :documentation:testing: -:PROPERTIES: -:FILES: tests/testutil-general.el, tests/testutil-filesystem.el -:END: - -Test utility files should have Commentary sections explaining: -- What utilities are provided -- When to use each utility -- Examples of usage -- Relationship to quality-engineer.org guidelines - -Helps future test authors understand available infrastructure. - -** TODO [#D] keybindings.el trains user away from C-x u :ux:philosophy: -:PROPERTIES: -:FILE: modules/keybindings.el -:LINES: 126-130 -:END: - -The rebinding of =C-x u= to a message telling users to use =C-/= instead is... philosophically interesting. - -*Discussion points:* -- Enforces good habits (C-/ is indeed faster) -- But violates principle of least surprise -- Could frustrate users who know Emacs bindings -- Muscle memory is hard to retrain - -*Alternative:* Just leave =C-x u= working. Add which-key hint that C-/ is faster. - -** TODO [#D] Missing which-key labels for some custom keymaps :usability:discoverability: -:PROPERTIES: -:FILES: Multiple modules -:END: - -Some custom keymaps lack which-key descriptions: -- =cj/vc-map= is well documented (vc-config.el) -- =cj/jump-map= is well documented (keybindings.el) -- =cj/org-table-map= is well documented (org-config.el) - -But check all custom keymaps for completeness. Run: -#+begin_src elisp -(apropos-variable "^cj/.*-map$") -#+end_src - -And verify each has which-key labels. - -** TODO [#D] early-init.el could benefit from more section comments :documentation:maintainability: -:PROPERTIES: -:FILE: early-init.el -:END: - -File has good top-level commentary but individual sections could use more context: -- Why network check exists (explain offline package strategy) -- Why GC threshold is increased (explain impact) -- Benchmark-init section is good, replicate that style - -Makes it easier for others (or future you) to understand decisions. - -** TODO [#D] Consider adding persistent-scratch package :enhancement:usability: -:PROPERTIES: -:FILE: N/A -:END: - -The *scratch* buffer is very useful but loses content on restart. - -*Enhancement:* Add =persistent-scratch= package to: -- Auto-save scratch buffer contents -- Restore on startup -- Optionally persist multiple scratch buffers - -Useful for quick notes, code experiments, calculations that span sessions. - -* Priority Explanation - -** Priority A (Critical) -Issues that: -- Prevent functionality from working -- Cause errors or crashes -- Block startup or major features -- Have security implications -- Significantly impact performance (>1s delay) - -** Priority B (High) -Issues that: -- Affect architecture or maintainability significantly -- Better alternative packages exist -- Moderate performance impact (>100ms) -- Testing gaps in critical code -- Risk of future breakage - -** Priority C (Medium) -Issues that: -- Affect code quality or consistency -- Minor refactoring opportunities -- Documentation improvements -- Small usability enhancements -- Package optimization opportunities - -** Priority D (Low) -Issues that: -- Nice-to-have features -- Polish and refinement -- Alternative approaches to consider -- Documentation expansion -- Style/convention improvements - -* Package Recommendations Summary - -** Recommended Changes -1. *Company → Corfu*: Better performance, more modern, better maintained -2. *Consider mood-line over doom-modeline*: Much faster for minimal info needs -3. *Add Cape*: Modern completion-at-point backends (if switching to Corfu) -4. *Consider persistent-scratch*: QOL improvement for scratch buffer - -** Packages to Keep -- Vertico: Excellent choice, fast, well-maintained -- Consult: Perfect companion to Vertico -- Orderless: Best completion style available -- Embark: Powerful, could be used more extensively -- Marginalia: Great for annotations -- Magit: Industry standard, no alternatives -- Org-roam: Best Zettelkasten for Emacs -- Which-key: Essential for discoverability - -** Packages to Evaluate -- Dirvish: Heavy package. Consider if dired + nerd-icons + embark is sufficient -- Doom-modeline: Feature-rich but heavy. Mood-line is lighter alternative -- Projectile vs project.el: You're using Projectile. Project.el is built-in and lighter, but Projectile is more mature. Keep unless performance is an issue. - -* Testing Recommendations - -Based on your quality-engineer.org guidelines and existing test coverage: - -** Well-Tested Areas ✓ -- All custom-* utility functions (excellent coverage) -- undead-buffers (comprehensive tests) -- org-roam helpers (good coverage) -- keyboard-macros -- Test utilities themselves - -** Priority Testing Gaps - -*** High Priority -1. *org-agenda-config.el filtering functions* - - =cj/org-skip-subtree-if-habit= - - =cj/org-skip-subtree-if-not-overdue= (date-sensitive!) - - =cj/build-org-agenda-list= (file discovery) - -2. *custom-buffer-file.el interactive functions* - - Split =cj/move-buffer-and-file= into =cj/--move-buffer-and-file= (testable core) and interactive wrapper - - Same for =cj/rename-buffer-and-file= - - Same for =cj/delete-buffer-and-file= - -*** Medium Priority -3. *org-roam-config.el integration test* - - =cj/move-org-branch-to-roam= workflow test - - Test actual file creation and org-roam db update - -4. *path-utils consolidation* (after creating module) - - Test project-relative, home-relative, absolute path logic - - Test with symlinks, non-existent paths, permission errors - -5. *ai-config.el GPTel context management* - - =cj/gptel-add-file= with various file types - - =cj/gptel--fresh-org-prefix= timestamp formatting - -*** Lower Priority -6. *mail-config.el address completion* -7. *Browser/external open logic* -8. *Theme persistence functions* - -** Testing Patterns to Follow - -Your existing tests follow excellent patterns from quality-engineer.org: -- One file per function for unit tests -- Clear normal/boundary/error case organization -- Good use of test utilities (testutil-general, testutil-filesystem) -- Proper setup/teardown -- Descriptive test names - -*Keep this pattern!* It's working well for you. - -** Test-Driven Refactoring Opportunities - -When fixing Priority A-B issues, write tests first: -1. Test for the bug/missing function -2. See it fail -3. Fix the code -4. See it pass -5. Refactor with confidence - -This is especially important for: -- The missing =cj/goto-git-gutter-diff-hunks= function -- The duplicate =cj/network-available= variable -- Network check refactoring (mock the network!) - -* Architecture Observations - -** What You're Doing Right ✓ - -1. *Excellent module organization*: Clean separation of concerns -2. *Good use of user-constants.el*: Centralized configuration -3. *Comprehensive custom utilities*: Well-tested, reusable functions -4. *Modern completion framework*: Vertico/Consult/Embark stack is excellent -5. *Debug infrastructure pattern*: org-agenda-config-debug.el is a good model -6. *Test coverage philosophy*: Following your own guidelines well -7. *Early-init optimization*: Good understanding of startup optimization -8. *Custom keymap prefix*: C-; is a good choice, well-organized - -** Architectural Patterns to Consider - -1. *Consolidate common patterns*: - - Path handling → path-utils.el - - Debug logging → debug-logging.el - - File validation → file-utils.el - -2. *Reduce coupling*: - - Some modules depend on specific implementations (e.g., projectile vs project.el) - - Consider adapter pattern for switching between implementations - -3. *Error handling strategy*: - - Add condition-case wrappers around file operations - - Log errors consistently to *Messages* or debug buffer - - Fail gracefully (warn user but don't break session) - -4. *Configuration validation*: - - Add startup checks for critical paths/files - - Warn about missing optional dependencies - - Provide helpful error messages with solutions - -** Code Smells Observed - -1. *String concatenation for paths*: Use =expand-file-name= consistently -2. *Silent failures*: Some functions fail without user feedback -3. *Synchronous network operations*: Blocks startup unnecessarily -4. *Duplicate logic*: Path handling, project root detection -5. *Large functions*: Some functions >50 lines, should be split -6. *Global state*: Some functions modify global variables without clear ownership - -* Next Steps Recommendation - -** Week 1: Fix Critical Bugs (Priority A) -1. Fix duplicate =cj/network-available= declaration -2. Implement missing =cj/goto-git-gutter-diff-hunks= -3. Define =cj/log-silently= or remove usage -4. Fix network check blocking (biggest impact) - -** Week 2: Architectural Improvements (Priority B) -1. Evaluate Company vs Corfu (try Corfu for a week) -2. Consolidate debug logging -3. Create path-utils.el module -4. Add error handling to org-agenda rebuild - -** Week 3: Testing (Priority B) -1. Add tests for org-agenda filtering functions -2. Split interactive/non-interactive in custom-buffer-file -3. Add integration test for org-roam branch extraction - -** Week 4: Polish (Priority C-D) -1. Consolidate coding system setup -2. Improve documentation -3. Add which-key labels where missing -4. Clean up minor issues - -** Ongoing -- Add tests for any new functionality -- Profile startup time with esup periodically -- Review package updates for breaking changes -- Keep org-gcal, chime.el forks in sync with upstream - -* Questions for You - -1. *Company vs Corfu*: Do you have strong feelings about Company? Willing to try Corfu? - Answer: I'd be excited to try Corfu. I don't know how I'd configure it yet and it seems like a big change. However, I planned to move to Corfu sometime soon. - -2. *Network check*: Do you actually need to support offline operation, or can we simplify this? - Answer: We can simplify. There was a time when I was traveling a lot and didn't have network access. The current offline operation seemed to help startup performance. The sooner we realized we were offline, the sooner I'd have a working Emacs. Now, I don't think it provides much benefit. - -3. *Local forks*: Are chime.el and org-msg local forks temporary? When can they move to MELPA? - Answer: These are packages I currently either maintain or are in the process of creating. I need the local-paths set so I can test. If you have better ideas on how I can switch them from using a local package to using the stable version using vc-install, I'd be grateful. - -4. *Doom-modeline*: Are you using features beyond what I saw in config? Would mood-line be sufficient? - No, I'm not. Mood-line would be welcomed. - -5. *Testing coverage*: What modules do you most want tests for? Anything you're nervous about breaking? - I'll probably look for large methods with lots of LOC and multiple tasks, then break them up for better testability. I was hoping you would point those out for me so I could focus on them. Will you? - -6. *Performance*: Any particular operations feel slow? (Agenda rebuild? Mail sync? Completion?) - Org Agenda is problematic and slow. I could get some insights into how to optimize it if it were well tested. - - -Other than that, here's my roadmap for fixing/improving -- video/audio config could use some testing around which sinks to attach to. It doesn't matter to me that this is Linux centric since it's my config. -- I'm not sure the UX in flyspell and abbrev is right. It could use some more attention. -- Same with Elfeed. While I had Elfeed dashboard at some point, it doesn't bring any joy to see a mostly blank screen when I check Elfeed. -- DWIM shell commands is alright. I wonder if I can leverage them better into eshell. -- calibredb is slow, but that's the package working on 14k+ pdfs and epubs. The right thing to do is to shrink the ebook inventory to < 1k or so books. -- I was thinking of investigating bettter diffs with difftastic. If it's straightforward and simple, I should jump at it. -- I would like to implement code-maat and code-compass within Emacs -- It would be good to have a solid profiler workflow so I can see what's going on inside my own methods and packages. For instance, I don't have the tools to tell me why it takes so long to generate my daily agenda. There's little observability. -- I absolutely need a workflow so I can go from org-mode file to reveal.js presentation using httpd server in Emacs with the press of a button. I know it can be done. Export the org file with injected css into the httpd directory, start the httpd server, then open a browser fullscreen on the file. That's pretty much 90% of the work right there. -- If I could figure out the latex-config so that I could author something non-trivial, I'd be happy. Right now, it's a bunch of cut/paste snippets from everyone else's config and it doesn't work. -- mail-config: the whole attachments workflow is awkward, but so necessary for daily life. It really needs improvement. -- then there's the whole org-contacts issue working with chime.el and birthdays. sexp expressions for dates? I should either figure them out and support them in chime, or I should disable them and find another way. -- it would be good to work with llms the way I do with claude code. gptel feels like a step in that direction and you can switch backends besides. but I need to either build a bunch of tools or install an MCP server. Which way to go? -- I would like to extract the code in local-repository that creates the repo so I can unit test the hell out of it and use it as a library for my own local repo. Seems like there's too much extraneous functionality there. -- lorem seems to have an error pop up each session about the markhov chain resetting. either that's bad and should be fixed or it's not bad and lorem should stfu. -- I believe jumper should probably be my next package, but I haven't had the time to spend on it. - -* Final Thoughts - -Your config is **quite good overall**: -- Well-organized modular structure -- Excellent test coverage for utilities -- Modern package choices (mostly) -- Good documentation and comments -- Following elisp idioms reasonably well - -The critical issues (Priority A) are straightforward fixes. The architectural suggestions (Priority B) are about making the codebase more maintainable long-term. Everything else is refinement. - -Focus on: -1. Fix the bugs that break functionality -2. Consider Corfu migration (biggest ROI for effort) -3. Add tests for date-sensitive agenda logic -4. Everything else is gravy - -You're clearly thoughtful about your config and willing to refactor when needed. These issues are normal for a mature, actively-used configuration. Keep up the good work! - -* Second Opinion: Ruthless Prioritization & Reality Checks -:PROPERTIES: -:DATE: 2025-10-30 -:END: - -After reviewing your answers and additional roadmap items, here's honest feedback on what matters, what doesn't, and what you should actually do. - -** Key Insights from Your Answers - -*** Company → Corfu Migration: GREEN LIGHT -You're excited, you were planning it anyway. Corfu configuration is actually *simpler* than Company, not harder. This is a quick win with immediate UX improvement. - -*Reality:* 2 hours of work, noticeable performance improvement. Do it this weekend. - -*** Network Check: DELETE IT IMMEDIATELY -You confirmed it's technical debt from when you traveled. It's adding 1+ seconds to every startup for a problem you no longer have. - -*Reality:* 15 minutes to delete ~100 lines of code. Instant gratification. Do this first. - -*** Local Package Development Workflow: NEEDS PATTERN -You maintain chime.el and org-msg locally for testing. Current approach works but isn't elegant. See implementation pattern below. - -*** Mood-line Switch: EASY WIN -You don't need doom-modeline features. Mood-line is 10x faster and simpler. - -*Reality:* 30 minutes. Do it. - -*** Large Functions to Break Up: MOSTLY FINE -Analyzed your codebase. Most functions are reasonably sized. The ones you mentioned: -- =dirvish-config.el:350-406= =cj/dired-copy-path-as-kill= (57 lines) - Could split, but works fine -- =org-roam-config.el:250-298= =cj/move-org-branch-to-roam= (49 lines) - Actually well-designed with helpers -- =org-agenda-config.el:91-105= =cj/build-org-agenda-list= (15 lines) - Small but THIS IS YOUR PERFORMANCE BOTTLENECK - -*Reality:* Your code organization is fine. Your problem is *performance observability*, not function length. - -*** Org Agenda Performance: ROOT CAUSE IDENTIFIED -This is your actual pain point. But you're flying blind without profiling infrastructure. - -*Hypothesis:* =cj/build-org-agenda-list= recursively scans entire projects directory on every agenda open with: -- No caching (even when files unchanged) -- No error handling -- Synchronous file I/O blocking UI -- Probably hundreds of file-exists-p calls - -*Reality:* You need profiling tools FIRST, then optimization. Don't guess, measure. - -** TIER 1: Do These First (High Impact, Low Effort) -*Time investment: One weekend* -*Status: START HERE* - -*** TODO [#A] Remove network check entirely :performance:critical: -:PROPERTIES: -:FILE: early-init.el -:LINES: 76-78, 104-105, 189, 206 -:EFFORT: 15 minutes -:IMPACT: Removes 1+ seconds from every startup -:END: - -Delete the following: -1. =cj/network-available= variable declarations (both) -2. =cj/internet-up-p= function and all calls -3. =cj/use-online-repos= flag logic -4. Any package.el conditionals based on network status - -Let package.el handle its own errors gracefully. Modern Emacs does this fine. - -*** TODO [#A] Fix missing cj/log-silently definition :bug:critical: -:PROPERTIES: -:FILE: modules/wrap-up.el -:LINE: 28 -:EFFORT: 5 minutes -:END: - -Either: -1. Move =cj/log-silently= from =org-agenda-config-debug.el= to =config-utilities.el= (load early) -2. Or replace with simple =(message ...)= call - -The function is only used in one place, so simple message might be sufficient. - -*** TODO [#A] Remove duplicate cj/network-available declaration :bug:critical: -:PROPERTIES: -:FILE: early-init.el -:LINES: 104-105 -:EFFORT: 2 minutes -:END: - -Delete the duplicate at lines 104-105. Keep only the first declaration at line 76. - -*Note:* This becomes moot if you delete network check entirely (recommended). - -*** TODO [#A] Fix missing cj/goto-git-gutter-diff-hunks function :bug:critical: -:PROPERTIES: -:FILE: modules/vc-config.el -:LINE: 123 -:EFFORT: 15 minutes -:END: - -Keybinding =C-; v d= references undefined function. - -Implement as: -#+begin_src elisp -(defun cj/goto-git-gutter-diff-hunks () - "Jump to git-gutter diff hunks using consult." - (interactive) - (require 'git-gutter) - (consult-line "^[+\\-]")) ;; Or use git-gutter:popup-hunk -#+end_src - -Or remove the keybinding if not needed. - -*** TODO [#B] Migrate Company to Corfu :enhancement:performance: -:PROPERTIES: -:FILE: modules/selection-framework.el -:LINES: 202-236 -:EFFORT: 2 hours -:IMPACT: 3x faster completion, simpler config -:END: - -*Why Corfu:* -- ~3x faster (child frames vs overlays) -- Better maintained -- Simpler codebase (~500 LOC vs ~3000 LOC) -- Works seamlessly with Vertico/Consult -- Better integration with Cape for backends - -*Migration:* -#+begin_src elisp -(use-package corfu - :ensure t - :demand t - :bind (:map corfu-map - ("TAB" . corfu-next) - ("S-TAB" . corfu-previous)) - :custom - (corfu-auto t) - (corfu-auto-delay 0.1) ;; Much faster than your 2s company delay! - (corfu-auto-prefix 2) - (corfu-quit-no-match 'separator) - :init - (global-corfu-mode)) - -(use-package corfu-popupinfo - :after corfu - :hook (corfu-mode . corfu-popupinfo-mode) - :custom - (corfu-popupinfo-delay '(0.5 . 0.2))) - -(use-package cape - :ensure t - :after corfu - :init - (add-to-list 'completion-at-point-functions #'cape-dabbrev) - (add-to-list 'completion-at-point-functions #'cape-file) - (add-to-list 'completion-at-point-functions #'cape-keyword)) -#+end_src - -Remove entire company configuration. Keep mu4e completion settings (Corfu respects them). - -*Configuration is actually simpler than Company.* Just do it. - -*** TODO [#B] Switch doom-modeline to mood-line :enhancement:performance: -:PROPERTIES: -:FILE: modules/modeline-config.el -:EFFORT: 30 minutes -:IMPACT: 10x faster modeline updates -:END: - -Replace doom-modeline config with: -#+begin_src elisp -(use-package mood-line - :ensure t - :demand t - :config - (mood-line-mode)) -#+end_src - -That's it. Mood-line shows: buffer name, position, major-mode, vc status. Pure elisp, very fast. - -If you miss doom-modeline features, easy to switch back. But try mood-line for a week. - -*** TODO [#C] Fix lorem markov chain error :bug:minor: -:PROPERTIES: -:FILE: modules/lorem-config.el (or wherever lorem is configured) -:EFFORT: 5 minutes -:END: - -The error message is probably harmless initialization noise. Either: -1. Wrap lorem initialization in =(with-demoted-errors ...)= -2. Configure lorem to suppress the warning -3. Disable lorem if you don't use it - -Check your config for lorem setup and add error suppression. - -** TIER 2: Build Observability Infrastructure (HIGHEST VALUE) -*Time investment: One week* -*Status: CRITICAL DEPENDENCY FOR EVERYTHING ELSE* - -*** TODO [#A] Create debug-profiling.el module :infrastructure:performance: -:PROPERTIES: -:FILE: modules/debug-profiling.el (new file) -:EFFORT: 3-4 hours -:IMPACT: Unlocks all performance optimization work -:END: - -This is your #1 blocker for fixing org-agenda and other performance issues. - -Create comprehensive profiling infrastructure: - -#+begin_src elisp -;;; debug-profiling.el --- Profiling and performance debugging tools -*- lexical-binding: t; -*- - -;;; Commentary: -;; Unified profiling infrastructure for measuring and optimizing performance. -;; Provides helpers for: -;; - Function timing (individual and batch) -;; - Memory profiling -;; - Startup profiling -;; - Interactive profiling sessions -;; -;; Usage: -;; M-x cj/profile-function RET cj/build-org-agenda-list RET -;; M-x cj/profile-startup -;; (cj/time-it "agenda rebuild" (cj/build-org-agenda-list)) - -;;; Code: - -(require 'profiler) -(require 'benchmark) - -(defvar cj/profiling-log-buffer "*CJ Profile Log*" - "Buffer name for profiling results.") - -(defvar cj/profiling-enabled nil - "When non-nil, enable automatic timing of instrumented functions.") - -(defun cj/time-it (description &rest body) - "Execute BODY and log timing with DESCRIPTION." - (let* ((start-time (current-time)) - (result (car body)) - (elapsed (float-time (time-subtract (current-time) start-time)))) - (with-current-buffer (get-buffer-create cj/profiling-log-buffer) - (goto-char (point-max)) - (insert (format "[%s] %s: %.3f sec\n" - (format-time-string "%H:%M:%S") - description - elapsed))) - (message "%s: %.3f sec" description elapsed) - result)) - -(defmacro cj/measure (&rest body) - "Measure execution time of BODY, return result and timing." - `(let ((start-time (current-time)) - (result (progn ,@body)) - (end-time (current-time))) - (cons result (float-time (time-subtract end-time start-time))))) - -(defun cj/profile-function (function &optional runs) - "Profile FUNCTION by calling it RUNS times (default 1). -Shows results in dedicated buffer with call tree." - (interactive - (list (intern (completing-read "Function to profile: " obarray 'fboundp t)) - (when current-prefix-arg - (read-number "Number of runs: " 10)))) - (setq runs (or runs 1)) - (profiler-start 'cpu) - (dotimes (_ runs) - (funcall function)) - (profiler-stop) - (profiler-report) - (message "Profiled %s (%d run%s)" function runs (if (> runs 1) "s" ""))) - -(defun cj/profile-startup () - "Profile Emacs startup by restarting with profiler enabled. -Results are saved to profile.el in user-emacs-directory." - (interactive) - (let ((profile-file (expand-file-name "startup-profile.el" user-emacs-directory))) - (profiler-start 'cpu+mem) - (message "Restart Emacs to profile startup. Results will be in %s" profile-file) - (add-hook 'emacs-startup-hook - (lambda () - (profiler-report) - (profiler-report-write-profile profile-file) - (profiler-stop) - (message "Startup profiling complete. See %s" profile-file))))) - -(defun cj/benchmark-function (function &optional iterations) - "Benchmark FUNCTION over ITERATIONS (default 100). -Shows average, min, max, and total time." - (interactive - (list (intern (completing-read "Function to benchmark: " obarray 'fboundp t)) - (read-number "Iterations: " 100))) - (setq iterations (or iterations 100)) - (let ((times '())) - (dotimes (_ iterations) - (let* ((result (cj/measure (funcall function))) - (elapsed (cdr result))) - (push elapsed times))) - (let* ((total (apply #'+ times)) - (avg (/ total iterations)) - (min-time (apply #'min times)) - (max-time (apply #'max times))) - (message "%s (%d iterations): avg=%.4fs min=%.4fs max=%.4fs total=%.2fs" - function iterations avg min-time max-time total)))) - -(defun cj/instrument-package (package) - "Instrument all functions in PACKAGE for profiling with elp." - (interactive - (list (intern (completing-read "Package to instrument: " obarray)))) - (elp-instrument-package (symbol-name package)) - (message "Instrumented package: %s. Run M-x elp-results to see data." package)) - -(defun cj/profile-agenda-rebuild () - "Profile org-agenda rebuild with detailed timing." - (interactive) - (require 'org-agenda) - (cj/time-it "Full agenda rebuild" (cj/build-org-agenda-list))) - -(defun cj/show-profile-log () - "Display the profiling log buffer." - (interactive) - (display-buffer (get-buffer-create cj/profiling-log-buffer))) - -(defun cj/clear-profile-log () - "Clear the profiling log buffer." - (interactive) - (with-current-buffer (get-buffer-create cj/profiling-log-buffer) - (erase-buffer))) - -;; Keybindings (add to your keybindings.el) -;; (global-set-key (kbd "C-; p f") #'cj/profile-function) -;; (global-set-key (kbd "C-; p b") #'cj/benchmark-function) -;; (global-set-key (kbd "C-; p l") #'cj/show-profile-log) -;; (global-set-key (kbd "C-; p a") #'cj/profile-agenda-rebuild) - -(provide 'debug-profiling) -;;; debug-profiling.el ends here -#+end_src - -Load this early in init.el: -#+begin_src elisp -(when (or (eq cj/debug-modules t) - (memq 'profiling cj/debug-modules)) - (require 'debug-profiling (expand-file-name "modules/debug-profiling" user-emacs-directory))) -#+end_src - -*** TODO [#A] Profile org-agenda-rebuild to find bottleneck :performance:critical: -:PROPERTIES: -:FILE: modules/org-agenda-config.el -:EFFORT: 1 hour -:DEPENDS: debug-profiling.el -:END: - -Once profiling infrastructure exists: - -1. Run: =M-x cj/profile-function RET cj/build-org-agenda-list RET= -2. Run: =M-x cj/benchmark-function RET cj/build-org-agenda-list RET 10= -3. Instrument org-agenda package: =M-x cj/instrument-package RET org-agenda RET= -4. Review results with =M-x elp-results= - -*Hypothesis to test:* -- Is time spent in file I/O (directory-files-recursively)? -- Is it parsing org files? -- Is it just too many files? -- Is there a slow helper function being called repeatedly? - -*Expected findings:* -- Probably 80%+ time in =cj/add-files-to-org-agenda-files-list= -- Likely no caching, runs every time -- Possible N+1 file access pattern - -*Next steps after profiling:* -- Add file-notify watchers to invalidate cache only when projects-dir changes -- Cache the file list -- Run rebuild async on startup - -*** TODO [#B] Add instrumentation to cj/build-org-agenda-list :performance:observability: -:PROPERTIES: -:FILE: modules/org-agenda-config.el -:EFFORT: 30 minutes -:DEPENDS: debug-profiling.el, profiling results -:END: - -After profiling identifies bottleneck, add instrumentation: - -#+begin_src elisp -(defun cj/build-org-agenda-list () - "Rebuilds the org agenda list with instrumentation." - (interactive) - (let ((start-time (current-time))) - ;; reset org-agenda-files - (setq org-agenda-files (list inbox-file schedule-file gcal-file)) - - ;; Time the expensive operation - (let ((scan-start (current-time))) - (cj/add-files-to-org-agenda-files-list projects-dir) - (message "Scanned projects-dir in %.3f sec" - (float-time (time-subtract (current-time) scan-start)))) - - (let ((total-time (float-time (time-subtract (current-time) start-time)))) - (message "Rebuilt org-agenda-files in %.3f sec (%d files)" - total-time - (length org-agenda-files))))) -#+end_src - -This gives you observability on every agenda rebuild. - -*** TODO [#B] Add caching to org-agenda rebuild :performance:critical: -:PROPERTIES: -:FILE: modules/org-agenda-config.el -:EFFORT: 2 hours -:DEPENDS: Profiling results -:END: - -*Only do this after profiling confirms file scanning is the bottleneck.* - -Implement caching with file-notify: - -#+begin_src elisp -(defvar cj/org-agenda-cache nil - "Cached list of org-agenda files.") - -(defvar cj/org-agenda-cache-dirty t - "When non-nil, agenda cache needs rebuilding.") - -(defun cj/invalidate-agenda-cache (&rest _) - "Mark agenda cache as dirty." - (setq cj/org-agenda-cache-dirty t)) - -(defun cj/build-org-agenda-list-cached () - "Build org-agenda list with caching." - (interactive) - (when (or (not cj/org-agenda-cache) - cj/org-agenda-cache-dirty) - (let ((start-time (current-time))) - (setq cj/org-agenda-cache (list inbox-file schedule-file gcal-file)) - (cj/add-files-to-org-agenda-files-list projects-dir) - (setq cj/org-agenda-cache org-agenda-files) - (setq cj/org-agenda-cache-dirty nil) - (message "Rebuilt agenda cache in %.3f sec (%d files)" - (float-time (time-subtract (current-time) start-time)) - (length cj/org-agenda-cache)))) - (setq org-agenda-files cj/org-agenda-cache)) - -;; Set up file watching -(require 'filenotify) -(file-notify-add-watch projects-dir - '(change) - #'cj/invalidate-agenda-cache) -#+end_src - -*** TODO [#B] Test org-agenda filtering functions :testing:critical: -:PROPERTIES: -:FILE: tests/org-agenda-config-test.el (new file) -:EFFORT: 2-3 hours -:END: - -These functions are date-sensitive and break silently. High-value tests: - -#+begin_src elisp -;;; org-agenda-config-test.el --- Tests for org-agenda-config -*- lexical-binding: t; -*- - -(require 'ert) -(require 'org-agenda-config) - -(ert-deftest test-cj/org-skip-subtree-if-habit () - "Test habit filtering." - (with-temp-buffer - (org-mode) - (insert "* TODO Test\n:PROPERTIES:\n:STYLE: habit\n:END:\n") - (goto-char (point-min)) - (should (cj/org-skip-subtree-if-habit)))) - -(ert-deftest test-cj/org-skip-subtree-if-not-overdue () - "Test overdue task detection." - (with-temp-buffer - (org-mode) - ;; Task scheduled yesterday (overdue) - (insert "* TODO Overdue\nSCHEDULED: <2025-10-29>\n") - (goto-char (point-min)) - (should-not (cj/org-skip-subtree-if-not-overdue)) - - ;; Task scheduled tomorrow (not overdue) - (erase-buffer) - (insert "* TODO Future\nSCHEDULED: <2025-10-31>\n") - (goto-char (point-min)) - (should (cj/org-skip-subtree-if-not-overdue)))) -#+end_src - -** TIER 3: Quick Wins (After Tier 1 & 2) -*Time investment: 1-2 hours each* -*Status: Only tackle after profiling infrastructure is done* - -*** TODO [#B] Implement org-to-reveal.js workflow :enhancement:usability: -:PROPERTIES: -:FILE: modules/org-config.el or new modules/org-present-config.el -:EFFORT: 2 hours -:IMPACT: Solves specific, concrete need -:END: - -You said: "I know it can be done. Export to reveal.js, start httpd, open browser." - -You're right, it's straightforward: - -#+begin_src elisp -(use-package ox-reveal - :ensure t - :after org - :custom - (org-reveal-root "https://cdn.jsdelivr.net/npm/reveal.js")) - -(use-package simple-httpd - :ensure t - :custom - (httpd-port 8080) - (httpd-root "~/presentations")) - -(defun cj/org-present-reveal () - "Export current org file to reveal.js and present via httpd." - (interactive) - (unless (eq major-mode 'org-mode) - (user-error "Not in an org-mode buffer")) - - (let* ((httpd-dir (expand-file-name httpd-root)) - (output-file (expand-file-name - (concat (file-name-base) ".html") - httpd-dir))) - - ;; Ensure output directory exists - (make-directory httpd-dir t) - - ;; Export to reveal.js HTML - (org-reveal-export-to-html) - - ;; Move to httpd directory - (let ((exported-file (concat (file-name-base) ".html"))) - (rename-file exported-file output-file t)) - - ;; Start httpd if not running - (unless (process-status "httpd") - (httpd-start) - (message "Started httpd server on port %d" httpd-port)) - - ;; Open in browser (fullscreen) - (let ((url (format "http://localhost:%d/%s" - httpd-port - (file-name-nondirectory output-file)))) - (browse-url url) - (message "Presenting: %s" url)))) - -(with-eval-after-load 'org - (define-key org-mode-map (kbd "C-c C-v p") #'cj/org-present-reveal)) -#+end_src - -*That's it.* 30 lines. One keybinding. Done. - -Test with a simple org file: -#+begin_src org -,#+TITLE: Test Presentation -,#+REVEAL_THEME: moon - -,* Slide 1 -Content here - -,* Slide 2 -More content -#+end_src - -Press =C-c C-v p= and you're presenting. - -*** TODO [#B] Set up difftastic integration :enhancement:usability: -:PROPERTIES: -:FILE: modules/vc-config.el or modules/magit-config.el -:EFFORT: 30 minutes -:END: - -Install difftastic: =sudo pacman -S difftastic= or =cargo install difftastic= - -Then add to magit: - -#+begin_src elisp -(use-package magit-difftastic - :ensure t - :after magit - :bind (:map magit-status-mode-map - ("C-c d" . magit-difftastic-show)) - :custom - (magit-difftastic-executable "difft")) -#+end_src - -Or for native integration: -#+begin_src elisp -(setq magit-diff-refine-hunk 'all) ;; Already have this probably - -(defun cj/magit-difftastic-diff (args files) - "Show diff using difftastic." - (interactive (magit-diff-arguments)) - (require 'magit) - (let ((command (concat "difft " (mapconcat #'identity args " ")))) - (magit-git-command command))) - -(transient-append-suffix 'magit-diff '(-1 -1) - '("D" "Difftastic diff" cj/magit-difftastic-diff)) -#+end_src - -Simple and useful. Just do it. - -*** TODO [#C] Implement local package development workflow :enhancement:architecture: -:PROPERTIES: -:FILES: modules/org-agenda-config.el, modules/mail-config.el -:EFFORT: 1 hour -:END: - -You maintain chime.el and org-msg. You need to switch between local dev and stable versions easily. - -*Clean pattern:* - -In =user-constants.el=: -#+begin_src elisp -(defcustom cj/development-packages '() - "List of packages to load from local development paths. -When a package symbol is in this list, load from ~/code/<package>. -Otherwise use stable version via vc-install or MELPA. - -Example: (setq cj/development-packages '(chime org-msg))" - :type '(repeat symbol) - :group 'cj) - -(defun cj/use-local-package-p (package) - "Return non-nil if PACKAGE should be loaded from local path." - (memq package cj/development-packages)) -#+end_src - -In your package configs: -#+begin_src elisp -;; Chime configuration -(if (cj/use-local-package-p 'chime) - ;; Development mode: use local path - (use-package chime - :load-path "~/code/chime.el" - :demand t - ;; ... rest of config ... - ) - ;; Production mode: use stable version - (use-package chime - :vc (:url "https://github.com/yourusername/chime.el") - :demand t - ;; ... rest of config ... - )) -#+end_src - -To switch modes, just toggle the symbol in =cj/development-packages= and restart Emacs. - -*Benefits:* -- Clear intent: variable name says what it does -- Easy to toggle: add/remove symbols from list -- Version controlled: your config documents which packages you're actively developing -- No path duplication: define path logic once - -** TIER 4: Maybe/Someday (Proceed with Caution) -*Time investment: Unknown, possibly infinite* -*Status: Only do if you have concrete use case* - -*** HOLD [#D] Investigate code-maat/code-compass in Emacs :enhancement:tooling: -:PROPERTIES: -:REASON: Complexity vs value unclear -:END: - -*Hard truth:* This is a big project. Code-maat is a mature Java tool with significant functionality. - -*Questions before investing time:* -1. What would Emacs integration give you that CLI doesn't? -2. How often do you actually use code-maat? -3. Is this solving a real problem or is it intellectually interesting? - -*Recommendation:* Use the CLI tools. Call them from Emacs if needed: -#+begin_src elisp -(defun cj/code-maat-analyze (git-log-file) - "Run code-maat analysis on GIT-LOG-FILE." - (interactive "fGit log file: ") - (async-shell-command - (format "code-maat -l %s -c git2 -a coupling" git-log-file) - "*Code Maat Results*")) -#+end_src - -Don't reimplement it in elisp unless you have a very specific need the CLI can't meet. - -*** HOLD [#D] Fix LaTeX configuration :documentation:enhancement: -:PROPERTIES: -:REASON: No concrete use case yet -:END: - -You said: "Right now, it's a bunch of cut/paste snippets from everyone else's config and it doesn't work." - -*Hard truth:* LaTeX config is complex because LaTeX is complex. Don't invest time until you have a specific document you need to write. - -*When you do need it:* -1. Start with one concrete document (paper? letter? resume?) -2. Get that one document working -3. Extract the config that made it work -4. Then generalize - -*Don't* try to build a complete LaTeX environment speculatively. You'll waste time on edge cases you'll never hit. - -*Minimal starting point when you need it:* -#+begin_src elisp -(use-package auctex - :ensure t - :defer t - :mode ("\\.tex\\'" . LaTeX-mode) - :custom - (TeX-auto-save t) - (TeX-parse-self t) - (TeX-master nil)) -#+end_src - -That's it. Start there. Add more when you hit actual problems. - -*** HOLD [#D] Improve Elfeed dashboard UX :enhancement:ui: -:PROPERTIES: -:REASON: Need to clarify actual usage patterns -:END: - -You said: "it doesn't bring any joy to see a mostly blank screen when I check Elfeed." - -*Questions:* -1. Do you actually use RSS regularly, or is it aspirational? -2. If blank screen = no new items, is that a problem? -3. Would you use RSS more if the UX was better, or is RSS not part of your workflow? - -*Options:* -1. *If you don't use RSS:* Remove Elfeed. One less thing to maintain. -2. *If you want to use RSS more:* Start by curating better feeds, not fixing UI -3. *If blank screen bothers you:* Add a "no new items" message or show last N items regardless of read status - -Don't fix tooling for a workflow you're not sure you want. - -*** HOLD [#D] DWIM shell + eshell integration :enhancement:shell: -:PROPERTIES: -:REASON: Current solution works fine -:END: - -You have 815 lines of dwim-shell-config. It works. Leave it alone. - -*Hard truth:* This is a working system. "I wonder if I can leverage them better" is not a problem statement. - -Unless you have a specific workflow that's currently painful, don't touch this. - -*** HOLD [#D] Calibredb performance optimization :performance: -:PROPERTIES: -:REASON: Root cause is data volume, not code -:END: - -You correctly identified: "The right thing to do is to shrink the ebook inventory to < 1k or so books." - -*You already know the answer.* Don't optimize code when the problem is 14k+ files. - -Curate your library. Delete what you won't read. This is a data problem, not a code problem. - -*** HOLD [#D] Flyspell/abbrev UX improvements :enhancement:usability: -:PROPERTIES: -:REASON: Vague complaint, no specific pain points -:END: - -You said: "I'm not sure the UX is right. It could use some more attention." - -*This is not actionable.* Come back when you can say: -- "Pressing X does Y but I want Z" -- "It's too slow when..." -- "It conflicts with..." - -Until then, it's bikeshedding. - -*** HOLD [#D] Video/audio config sink testing :testing:enhancement: -:PROPERTIES: -:REASON: Nice to have, not blocking anything -:END: - -You said: "could use some testing around which sinks to attach to" - -*Questions:* -1. Does it currently work for your use case? -2. Do you switch audio sinks often? -3. Is this actually painful or just "would be nice"? - -If it works, leave it. If it doesn't work, describe the failure mode first. - -*** HOLD [#D] Build jumper package :project:packaging: -:PROPERTIES: -:REASON: Another package to maintain, unclear value -:END: - -You said: "I believe jumper should probably be my next package, but I haven't had the time." - -*Hard questions:* -1. What problem does jumper solve? -2. Do existing navigation packages (avy, ace-window, consult-line) not solve it? -3. Are you building this for yourself or for others? -4. How will you maintain it alongside chime and org-msg? - -If you're excited about it: Build it. But recognize it's a significant time investment. - -If you're not excited: Don't. You already maintain two packages. - -*** HOLD [#D] Extract local-repository creation logic :refactor:library: -:PROPERTIES: -:REASON: Unclear benefit -:END: - -You said: "extract the code... so I can unit test the hell out of it and use it as a library" - -*Questions:* -1. What would you use the library for beyond local-repository? -2. Is local-repository not working currently? -3. Are you planning to build other tools that need this? - -Don't extract libraries speculatively. Extract when you have two concrete use cases. - -*** HOLD [#D] Org-contacts + chime.el birthdays integration :enhancement:integration: -:PROPERTIES: -:REASON: Should be fixed in chime, not config -:END: - -You said: "I should either figure out sexp expressions and support them in chime, or disable them" - -*This is a chime.el issue, not a config issue.* - -File it as a chime.el feature request or bug. Your config shouldn't work around package limitations. - -** What NOT To Do - -*** DON'T: Optimize Before Profiling -You can't fix performance issues you can't measure. Build profiling infrastructure FIRST. - -*** DON'T: Build Speculative Libraries -Extract code into libraries when you have 2+ concrete use cases. Not before. - -*** DON'T: Fix Vague Complaints -"UX could be better" is not actionable. Come back with specific pain points. - -*** DON'T: Reimplement Mature CLI Tools -code-maat is Java. It works. Don't rewrite it in elisp. - -*** DON'T: Configure for Hypothetical Use Cases -LaTeX doesn't work? Come back when you need to write a specific LaTeX document. - -*** DON'T: Maintain More Packages Than You Can Support -You have chime and org-msg. That's plenty. Think hard before adding jumper. - -** Large Functions Analysis - -You asked me to identify large, complex functions that need breaking up. - -*Reality check:* Your functions are mostly fine. Here's what I found: - -*** Functions You Mentioned - -*1. dirvish-config.el:350-406 =cj/dired-copy-path-as-kill= (57 lines)* -- Has 6 responsibilities: validation, project detection, path formatting, org-link formatting, clipboard, feedback -- *Status:* Works fine, moderately complex -- *Recommendation:* Could extract helpers (=cj/--determine-path-type=, =cj/--format-path-for-display=), but not urgent -- *Priority:* [#C] - Refactor for testability when you have time - -*2. org-roam-config.el:250-298 =cj/move-org-branch-to-roam= (49 lines)* -- Already well-decomposed with helper functions! -- Uses =cj/org-link-get-description=, =cj/--generate-roam-slug=, =cj/--demote-org-subtree=, =cj/--format-roam-node= -- *Status:* Good design, clear logic flow -- *Recommendation:* Add integration test, but don't refactor further -- *Priority:* [#B] - Add tests, keep implementation - -*3. org-agenda-config.el:91-105 =cj/build-org-agenda-list= (15 lines)* -- Small function, but THIS IS YOUR PERFORMANCE BOTTLENECK -- Not because of size, but because it recursively scans directories without caching -- *Status:* CRITICAL ISSUE -- *Recommendation:* Profile it, add caching, add error handling -- *Priority:* [#A] - Profile and optimize ASAP - -*** Other Large Files - -*dwim-shell-config.el (815 lines)* -- Collection of DWIM commands -- *Status:* Works fine -- *Recommendation:* Leave it alone unless something breaks - -*custom-comments.el (636 lines)* -- Well-tested utility functions -- *Status:* No issues -- *Recommendation:* Keep as is - -*music-config.el (484 lines)* -- EMMS configuration -- *Recommendation:* Review when you mentioned video/audio sink testing - -*** The Real Issue - -Your problem isn't function length. Your problem is *performance observability*. - -You need: -1. Profiling infrastructure (debug-profiling.el) -2. Instrumentation in critical paths -3. Caching where appropriate -4. Error handling in file operations - -Focus on TIER 2 (observability infrastructure) before worrying about function decomposition. - -** Concrete Action Plan: This Weekend - -*** Saturday Morning (2-3 hours) - -1. *Delete network check* (15 min) - - Remove =cj/internet-up-p=, =cj/network-available=, all related code - - Test startup, should be 1+ seconds faster - -2. *Fix Priority A bugs* (45 min) - - Fix duplicate =cj/network-available= (if not deleted above) - - Implement =cj/goto-git-gutter-diff-hunks= or remove binding - - Fix =cj/log-silently= (move to config-utilities or use message) - -3. *Fix lorem error* (5 min) - - Find lorem config, suppress error message - -4. *Test everything still works* (15 min) - -*** Saturday Afternoon (2-3 hours) - -5. *Corfu migration* (2 hours) - - Remove company configuration - - Add corfu + corfu-popupinfo + cape - - Test completion in elisp, org, mu4e - - Verify mu4e still excludes completion - -6. *Switch to mood-line* (30 min) - - Remove doom-modeline - - Add mood-line configuration - - Restart, verify modeline works - -*** Sunday Morning (3-4 hours) - -7. *Create debug-profiling.el* (3 hours) - - Use the template I provided above - - Add to modules/ - - Configure in user-constants.el with =cj/debug-modules= - - Test basic commands work - -8. *Profile org-agenda* (30 min) - - Run =M-x cj/profile-agenda-rebuild= - - Run =M-x cj/profile-function RET cj/build-org-agenda-list= - - Document findings in *CJ Profile Log* - -9. *Write up findings* (30 min) - - What's slow? - - Why is it slow? - - What's the fix? - -*** Sunday Afternoon (Optional, 1-2 hours) - -10. *Reveal.js workflow* (2 hours) - - Implement =cj/org-present-reveal= - - Test with simple presentation - - Bind to key - -11. *Difftastic* (30 min) - - Install difftastic - - Configure magit integration - - Test on a real diff - -** Expected Outcomes - -After this weekend: -- ✅ Startup 1+ seconds faster (network check removed) -- ✅ No more broken keybindings or missing functions -- ✅ Completion is faster and more responsive (Corfu) -- ✅ Modeline updates are faster (mood-line) -- ✅ You have profiling infrastructure for all future optimization -- ✅ You understand why org-agenda is slow -- ✅ You have concrete data to guide optimization -- ✅ (Optional) You can present org files as reveal.js slides - -*** After This Weekend - -*Then stop and reassess.* - -- How do you feel about the changes? -- What's still bothering you? -- Is org-agenda faster after caching? (if you implement it) -- What's the next pain point? - -Don't commit to more work until you've lived with these changes for a week. - -** My Question for You - -Looking at your list of improvements, I see someone who: -- Likes building tools (jumper, code-maat integration, etc.) -- Gets excited by new technologies (difftastic, MCP, reveal.js) -- Has many "wouldn't it be cool if..." ideas - -But I also see someone who: -- Already has a working, sophisticated config -- Knows what's actually painful (org-agenda performance, mail attachments) -- Has limited time (maintaining chime, org-msg) - -*So here's my question:* - -*Are you building tools because they solve real problems, or because you enjoy building?* - -Both are valid answers! But the answer changes what you should prioritize: - -*If solving real problems:* -- Focus on TIER 1 & 2 (bugs, performance, observability) -- Ignore most of TIER 4 -- Build reveal.js workflow (concrete need) -- Fix mail attachments (daily pain) - -*If you enjoy building:* -- Pick ONE project (jumper? code-compass? profiling infrastructure?) -- Go deep on it -- Accept that other stuff will wait -- Be honest that it's for learning/fun, not necessity - -*My read:* You're mostly problem-solving, but you get distracted by shiny tools. - -*My advice:* Fix the pain points first (TIER 1 & 2). Then build one fun project. Then reassess. - -** gptel vs MCP: Specific Guidance - -You asked: "I need to either build a bunch of tools or install an MCP server. Which way to go?" - -*Answer: Build gptel functions. Here's why:* - -*** Why gptel + elisp Functions - -*Advantages:* -1. *Simpler:* Just elisp functions, no external server -2. *Integrated:* Native Emacs, shares your environment -3. *Debuggable:* Use edebug, step through code -4. *Maintainable:* You understand elisp, you wrote the config -5. *Flexible:* Can use any elisp API (org, dired, magit, etc.) -6. *Portable:* Works on any system with Emacs + gptel - -*What you can build:* -#+begin_src elisp -;; Example gptel tools -(defun cj/gptel-add-project-context () - "Add project README and key files to gptel context." - (interactive) - (let ((readme (expand-file-name "README.md" (project-root)))) - (when (file-exists-p readme) - (cj/gptel-add-file readme)))) - -(defun cj/gptel-analyze-function-at-point () - "Send function at point to LLM for analysis." - (interactive) - (let ((func (elisp--fnsym-in-current-sexp))) - (gptel-send (format "Analyze this function: %s" (symbol-function func))))) - -(defun cj/gptel-git-diff-review () - "Send current git diff to LLM for review." - (interactive) - (let ((diff (shell-command-to-string "git diff HEAD"))) - (gptel-send (concat "Review this diff:\n\n" diff)))) -#+end_src - -*This is much simpler than MCP and covers 90% of use cases.* - -*** When MCP Makes Sense - -*Only use MCP if:* -1. You want to share tools across multiple applications (VS Code + Emacs + CLI) -2. You need sandboxed execution for security -3. You're building reusable infrastructure for others -4. You want to use existing MCP servers someone else maintains - -*For Emacs-only workflows: Use gptel + elisp.* - -*** Recommended Approach - -1. Start with gptel + simple elisp functions -2. Build 3-5 useful commands (add-file, analyze-function, git-diff, org-context) -3. Use them for a month -4. If you find yourself wanting features MCP provides, reassess - -*Don't build MCP infrastructure speculatively.* Build it when gptel + elisp isn't enough. - -** Final Thoughts: Adjusted Priorities - -Your original document was thorough and well-analyzed. My additions: - -1. *Ruthless prioritization* - 4 tiers instead of flat list -2. *Observability first* - Can't optimize what you can't measure -3. *Reality checks* - Some "nice to haves" are rabbit holes -4. *Concrete implementations* - Not just "do this" but "here's the code" -5. *Hard questions* - Before building, ask "why?" and "for whom?" - -*Your config is good.* The issues are normal for a mature system. - -*Focus on:* -1. Fix the bugs (TIER 1) -2. Build profiling infrastructure (TIER 2) -3. Pick ONE fun project (reveal.js or difftastic) -4. Ignore everything else for now - -*Then live with it for a month* and see what still bothers you. - -You're thoughtful, skilled, and self-aware. You'll make good decisions. Just don't try to do everything at once. - -Good luck! 🚀 diff --git a/modules/auth-config.el b/modules/auth-config.el index 6b8a8ddb..2b52087e 100644 --- a/modules/auth-config.el +++ b/modules/auth-config.el @@ -24,9 +24,11 @@ :ensure nil ;; built in :demand t ;; load this package immediately :config - (setenv "GPG_AGENT_INFO" nil) ;; disassociate with external gpg agent - (setq auth-sources `(,authinfo-file)) ;; use authinfo.gpg (see user-constants.el) - (setq auth-source-debug t)) ;; echo debug info to Messages + ;; USE gpg-agent for passphrase caching (400-day cache from gpg-agent.conf) + ;; (setenv "GPG_AGENT_INFO" nil) ;; DISABLED: was preventing gpg-agent cache + (setq auth-sources `(,authinfo-file)) ;; use authinfo.gpg (see user-constants.el) + (setq auth-source-debug t) ;; echo debug info to Messages + (setq auth-source-cache-expiry 86400)) ;; cache decrypted credentials for 24 hours ;; ----------------------------- Easy PG Assistant ----------------------------- ;; Key management, cryptographic operations on regions and files, dired @@ -40,5 +42,79 @@ ;; (setq epa-pinentry-mode 'loopback) ;; emacs request passwords in minibuffer (setq epg-gpg-program "gpg2")) ;; force use gpg2 (not gpg v.1) +;; ---------------------------------- Plstore ---------------------------------- +;; Encrypted storage used by oauth2-auto for Google Calendar tokens. +;; CRITICAL: Enable passphrase caching to prevent password prompts every 10 min. + +(use-package plstore + :ensure nil ;; built-in + :demand t + :config + ;; Cache passphrase indefinitely (relies on gpg-agent for actual caching) + (setq plstore-cache-passphrase-for-symmetric-encryption t) + ;; Allow gpg-agent to cache the passphrase (400 days per gpg-agent.conf) + (setq plstore-encrypt-to nil)) ;; Use symmetric encryption, not key-based + +;; ------------------------ Authentication Reset Utility ----------------------- + +(defun cj/reset-auth-cache (&optional include-gpg-agent) + "Reset authentication caches when wrong password was entered. + +By default, only clears Emacs-side caches (auth-source, EPA file +handler) and leaves gpg-agent's long-term cache intact. This preserves +your 400-day cache for GPG and SSH passphrases. + +With prefix argument INCLUDE-GPG-AGENT (\\[universal-argument]), also +clears gpg-agent's password cache. Use this when gpg-agent itself has +cached an incorrect password. + +Clears: +1. auth-source cache (Emacs-level credential cache) +2. EPA file handler cache (encrypted file cache) +3. gpg-agent cache (only if INCLUDE-GPG-AGENT is non-nil) + +Use this when you see errors like: + - \"Bad session key\" + - \"Decryption failed\" + - GPG repeatedly using wrong cached password" + (interactive "P") + (message "Resetting authentication caches...") + + ;; Clear auth-source cache (Emacs credential cache) + (auth-source-forget-all-cached) + + ;; Clear EPA file handler cache + (when (fboundp 'epa-file-clear-cache) + (epa-file-clear-cache)) + + ;; Only clear gpg-agent cache if explicitly requested + (if include-gpg-agent + (let ((result (shell-command "echo RELOADAGENT | gpg-connect-agent"))) + (if (zerop result) + (message "✓ Emacs and gpg-agent caches cleared. Next access will prompt for password.") + (message "⚠ Warning: Failed to clear gpg-agent cache"))) + (message "✓ Emacs caches cleared. GPG/SSH passphrases preserved for session."))) + +(defun cj/kill-gpg-agent () + "Force kill gpg-agent (it will restart automatically on next use). + +This is a more aggressive reset than `cj/reset-auth-cache'. Use this +when gpg-agent is stuck or behaving incorrectly. + +The gpg-agent will automatically restart on the next GPG operation." + (interactive) + (let ((result (shell-command "gpgconf --kill gpg-agent"))) + (if (zerop result) + (message "✓ gpg-agent killed. It will restart automatically on next use.") + (message "⚠ Warning: Failed to kill gpg-agent")))) + +;; Keybindings +(with-eval-after-load 'keybindings + (keymap-set cj/custom-keymap "A" #'cj/reset-auth-cache)) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; A" "reset auth cache")) + (provide 'auth-config) ;;; auth-config.el ends here. diff --git a/modules/config-utilities.el b/modules/config-utilities.el index 32018371..2af3effa 100644 --- a/modules/config-utilities.el +++ b/modules/config-utilities.el @@ -33,8 +33,7 @@ "C-c d i b" "info build" "C-c d i p" "info packages" "C-c d i f" "info features" - "C-c d r" "reload init" - "C-c d a" "reset auth cache")) + "C-c d r" "reload init")) ;;; --------------------------------- Profiling --------------------------------- @@ -283,15 +282,6 @@ Recompile natively when supported, otherwise fall back to byte compilation." (load-file user-init-file)) (keymap-set cj/debug-config-keymap "r" 'cj/reload-init-file) -;; ----------------------------- Reset-Auth-Sources ---------------------------- - -(defun cj/reset-auth-cache () - "Clear Emacs auth-source cache." - (interactive) - (auth-source-forget-all-cached) - (message "Emacs auth-source cache cleared.")) -(keymap-set cj/debug-config-keymap "a" 'cj/reset-auth-cache) - ;; ------------------------ Validate Org Agenda Entries ------------------------ (defun cj/validate-org-agenda-timestamps () diff --git a/modules/custom-buffer-file.el b/modules/custom-buffer-file.el index 2d2fa919..105ed4ff 100644 --- a/modules/custom-buffer-file.el +++ b/modules/custom-buffer-file.el @@ -9,11 +9,17 @@ ;; - moving/renaming/deleting buffer files ;; - diffing buffer contents with saved file version ;; - copying file paths and file:// links to the kill ring -;; - copying entire buffer contents +;; - copying buffer contents (whole buffer, to top of buffer, to bottom of buffer) ;; - clearing buffer contents from point to top or bottom. ;; ;; The PostScript printing auto-detects the system print spooler (lpr or lp) -;; and prints with face/syntax highlighting. Bound to keymap prefix ~C-; b~. +;; and prints with face/syntax highlighting. +;; +;; Keybindings under ~C-; b~: +;; - Copy buffer content submenu at ~C-; b c~ +;; - ~C-; b c w~ copy whole buffer +;; - ~C-; b c t~ copy from beginning to point +;; - ~C-; b c b~ copy from point to end ;; ;;; Code: @@ -58,14 +64,21 @@ auto-detect once per session.") (defun cj/print-buffer-ps (&optional color) "Print the buffer (or active region) as PostScript to the default printer. -With prefix argument COLOR, print in color; otherwise print in monochrome. +With prefix argument COLOR, print in color and skip confirmation; otherwise +print in monochrome with confirmation prompt. Sends directly to the system spooler with no header." (interactive "P") (unless (require 'ps-print nil t) (user-error "Cannot print: ps-print library not found")) (let* ((spooler (cj/print--resolve-spooler)) (want-color (not (null color))) - (have-region (use-region-p))) + (have-region (use-region-p)) + (skip-confirm color)) ; C-u skips confirmation + ;; Confirm unless C-u was used + (when (and (not skip-confirm) + (not (y-or-n-p (format "Send %s to printer? " + (if have-region "region" "buffer"))))) + (user-error "Printing cancelled")) (let ((ps-lpr-command spooler) (ps-printer-name nil) ; default system printer (ps-lpr-switches nil) @@ -200,6 +213,24 @@ is created. A message is displayed when done." (kill-new contents) (message "Buffer contents copied to kill ring"))) +(defun cj/copy-to-bottom-of-buffer () + "Copy text from point to the end of the buffer to the kill ring. +Point and mark are left exactly where they were. No transient region +is created. A message is displayed when done." + (interactive) + (let ((contents (buffer-substring-no-properties (point) (point-max)))) + (kill-new contents) + (message "Copied from point to end of buffer"))) + +(defun cj/copy-to-top-of-buffer () + "Copy text from the beginning of the buffer to point to the kill ring. +Point and mark are left exactly where they were. No transient region +is created. A message is displayed when done." + (interactive) + (let ((contents (buffer-substring-no-properties (point-min) (point)))) + (kill-new contents) + (message "Copied from beginning of buffer to point"))) + (defun cj/clear-to-bottom-of-buffer () "Delete all text from point to the end of the current buffer. This does not save the deleted text in the kill ring." @@ -221,45 +252,41 @@ Do not save the deleted text in the kill ring." (message "Copied: %s" (buffer-name))) (defun cj/diff-buffer-with-file () - "Compare the current modified buffer with the saved version. -Uses unified diff format (-u) for better readability. -Signal an error if the buffer is not visiting a file. - -TODO: Future integration with difftastic for structural diffs (Method 3)." + "Compare the current modified buffer with the saved version using ediff. +Uses the same ediff configuration from diff-config.el (horizontal split, j/k navigation). +Signal an error if the buffer is not visiting a file." (interactive) - (let ((file-path (buffer-file-name))) - (cond - ((not file-path) - (user-error "Current buffer is not visiting a file")) - ((not (file-exists-p file-path)) - (user-error "File %s does not exist on disk" file-path)) - ((not (buffer-modified-p)) - (message "Buffer has no unsaved changes")) - (t - (let ((diff-switches "-u")) ; unified diff format - (diff-buffer-with-file (current-buffer))))))) + (if (buffer-file-name) + (ediff-current-file) + (user-error "Current buffer is not visiting a file"))) ;; --------------------------- Buffer And File Keymap -------------------------- +;; Copy buffer content sub-keymap +(defvar-keymap cj/copy-buffer-content-map + :doc "Keymap for copy buffer content operations." + "w" #'cj/copy-whole-buffer + "b" #'cj/copy-to-bottom-of-buffer + "t" #'cj/copy-to-top-of-buffer) + ;; Buffer & file operations prefix and keymap (defvar-keymap cj/buffer-and-file-map :doc "Keymap for buffer and file operations." "m" #'cj/move-buffer-and-file "r" #'cj/rename-buffer-and-file - "p" #'cj/print-buffer-ps + "p" #'cj/copy-path-to-buffer-file-as-kill "d" #'cj/delete-buffer-and-file "D" #'cj/diff-buffer-with-file - "c" #'cj/copy-whole-buffer + "c" cj/copy-buffer-content-map "n" #'cj/copy-buffer-name + "l" #'cj/copy-link-to-buffer-file + "P" #'cj/print-buffer-ps "t" #'cj/clear-to-top-of-buffer "b" #'cj/clear-to-bottom-of-buffer "x" #'erase-buffer "s" #'mark-whole-buffer "S" #'write-file ;; save as - "g" #'revert-buffer - - "l" #'cj/copy-link-to-buffer-file - "P" #'cj/copy-path-to-buffer-file-as-kill) + "g" #'revert-buffer) (keymap-set cj/custom-keymap "b" cj/buffer-and-file-map) (with-eval-after-load 'which-key @@ -267,19 +294,22 @@ TODO: Future integration with difftastic for structural diffs (Method 3)." "C-; b" "buffer and file menu" "C-; b m" "move file" "C-; b r" "rename file" - "C-; b p" "print to PS" + "C-; b p" "copy file path" "C-; b d" "delete file" "C-; b D" "diff buffer with file" - "C-; b c" "copy buffer" + "C-; b c" "buffer copy menu" + "C-; b c w" "copy whole buffer" + "C-; b c b" "copy to bottom" + "C-; b c t" "copy to top" "C-; b n" "copy buffer name" + "C-; b l" "copy file link" + "C-; b P" "print to PS" "C-; b t" "clear to top" "C-; b b" "clear to bottom" "C-; b x" "erase buffer" "C-; b s" "select whole buffer" "C-; b S" "save as" - "C-; b g" "revert buffer" - "C-; b l" "copy file link" - "C-; b P" "copy file path")) + "C-; b g" "revert buffer")) (provide 'custom-buffer-file) diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el index 44e87d5a..3f7e273f 100644 --- a/modules/dashboard-config.el +++ b/modules/dashboard-config.el @@ -64,7 +64,7 @@ ;; a useful startup screen for Emacs (use-package dashboard - :defer t + :demand t :hook (emacs-startup . cj/dashboard-only) :bind ("<f1>" . cj/dashboard-only) :custom @@ -75,7 +75,7 @@ (bookmarks . dashboard-insert-bookmarks))) (dashboard-items '((projects . 5) - (bookmarks . 15))) + (bookmarks . 10))) (dashboard-startupify-list '(dashboard-insert-banner diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el index 441ff61b..b10c97f0 100644 --- a/modules/dirvish-config.el +++ b/modules/dirvish-config.el @@ -8,15 +8,15 @@ ;; ediff, playlist creation, path copying, and external file manager integration. ;; ;; Key Bindings: -;; - d: Duplicate file at point (adds "-copy" before extension) -;; - D: Delete marked files immediately (dired-do-delete) +;; - d: Delete marked files (dired-do-delete) +;; - D: Duplicate file at point (adds "-copy" before extension) ;; - g: Quick access menu (jump to predefined directories) ;; - G: Search with deadgrep in current directory ;; - f: Open system file manager in current directory ;; - o/O: Open file with xdg-open/custom command ;; - l: Copy file path (project-relative or home-relative) ;; - L: Copy absolute file path -;; - P: Create M3U playlist from marked audio files +;; - P: Copy file path (same as 'l', replaces dired-do-print) ;; - M-D: DWIM menu (context actions for files) ;; - TAB: Toggle subtree expansion ;; - F11: Toggle sidebar view @@ -120,9 +120,9 @@ Filters for audio files, prompts for the playlist name, and saves the resulting (setq dired-listing-switches "-l --almost-all --human-readable --group-directories-first") (setq dired-dwim-target t) (setq dired-clean-up-buffers-too t) ;; offer to kill buffers associated deleted files and dirs - (setq dired-clean-confirm-killing-deleted-buffers t) ;; don't ask; just kill buffers associated with deleted files - (setq dired-recursive-copies (quote always)) ;; “always” means no asking - (setq dired-recursive-deletes (quote top))) ;; “top” means ask once + (setq dired-clean-confirm-killing-deleted-buffers nil) ;; don't ask; just kill buffers associated with deleted files + (setq dired-recursive-copies (quote always)) ;; "always" means no asking + (setq dired-recursive-deletes (quote top))) ;; "top" means ask once ;; note: disabled as it prevents marking and moving files to another directory ;; (setq dired-kill-when-opening-new-dired-buffer t) ;; don't litter by leaving buffers when navigating directories @@ -322,13 +322,14 @@ regardless of what file or subdirectory the point is on." ("M-p" . dirvish-peek-toggle) ("M-s" . dirvish-setup-menu) ("TAB" . dirvish-subtree-toggle) - ("d" . cj/dirvish-duplicate-file) + ("d" . dired-do-delete) + ("D" . cj/dirvish-duplicate-file) ("f" . cj/dirvish-open-file-manager-here) ("g" . dirvish-quick-access) ("o" . cj/xdg-open) ("O" . cj/open-file-with-command) ; Prompts for command to run ("r" . dirvish-rsync) - ("P" . cj/dired-create-playlist-from-marked) + ("P" . cj/dired-copy-path-as-kill) ("s" . dirvish-quicksort) ("v" . dirvish-vc-menu) ("y" . dirvish-yank-menu))) diff --git a/modules/flycheck-config.el b/modules/flycheck-config.el index ea19f08f..e2e8abe9 100644 --- a/modules/flycheck-config.el +++ b/modules/flycheck-config.el @@ -6,30 +6,30 @@ ;; This file configures Flycheck for on-demand syntax and grammar checking. ;; - Flycheck starts automatically only in sh-mode and emacs-lisp-mode -;; - This binds a custom helper (=cj/flycheck-list-errors=) to “C-; ?” +;; - This binds a custom helper (=cj/flycheck-list-errors=) to "C-; ?" ;; for popping up Flycheck's error list in another window. -;; - It also customizes Checkdoc to suppress only the “sentence-end-double-space” -;; and “warn-escape” warnings. +;; - It also customizes Checkdoc to suppress only the "sentence-end-double-space" +;; and "warn-escape" warnings. -;; - It registers a Proselint checker for prose files -;; (text-mode, markdown-mode, gfm-mode). +;; - It registers LanguageTool for comprehensive grammar checking of prose files +;; (text-mode, markdown-mode, gfm-mode, org-mode). -;; Note: I do use proselint quite a bit in emails and org-mode files. However, some -;; org-files can be large and running proselint on them will slow Emacs to a crawl. -;; Therefore, hitting "C-; ?" also runs cj/flycheck-prose-on-demand if in an org buffer. +;; Note: Grammar checking is on-demand only to avoid performance issues. +;; Hitting "C-; ?" runs cj/flycheck-prose-on-demand if in an org buffer. -;; ;; The cj/flycheck-prose-on-demand function: ;; - Turns on flycheck for the local buffer -;; - ensures proselint is added -;; - triggers an immediate check -;; -;; Since this is called within cj/flycheck-list-errors, flycheck's error list will still -;; display and the focus transferred to that buffer. +;; - Enables LanguageTool checker +;; - Triggers an immediate check +;; - Displays errors in the *Flycheck errors* buffer -;; OS Dependencies: -;; proselint (in the Arch AUR) +;; Installation: +;; On Arch Linux: +;; sudo pacman -S languagetool +;; +;; The wrapper script at scripts/languagetool-flycheck formats LanguageTool's +;; JSON output into flycheck-compatible format. It requires Python 3. ;;; Code: @@ -62,20 +62,20 @@ ;; use the load-path of the currently running Emacs instance (setq flycheck-emacs-lisp-load-path 'inherit) - ;; Define the prose checker (installed separately via OS). - (flycheck-define-checker proselint - "A linter for prose." - :command ("proselint" source-inplace) + ;; Define LanguageTool checker for comprehensive grammar checking + (flycheck-define-checker languagetool + "A grammar checker using LanguageTool. +Uses a wrapper script to format output for flycheck." + :command ("~/.emacs.d/scripts/languagetool-flycheck" + source-inplace) :error-patterns ((warning line-start (file-name) ":" line ":" column ": " - (id (one-or-more (not (any " ")))) (message) line-end)) :modes (text-mode markdown-mode gfm-mode org-mode)) - (add-to-list 'flycheck-checkers 'proselint) + (add-to-list 'flycheck-checkers 'languagetool) (defun cj/flycheck-list-errors () "Display flycheck's error list and switch to its buffer. - Runs flycheck-prose-on-demand if in an org-buffer." (interactive) (when (derived-mode-p 'org-mode) @@ -85,12 +85,14 @@ Runs flycheck-prose-on-demand if in an org-buffer." (switch-to-buffer-other-window "*Flycheck errors*")) (defun cj/flycheck-prose-on-demand () - "Enable Flycheck+Proselint in this buffer, run it, and show errors." + "Enable Flycheck with LanguageTool in this buffer, run it, and show errors." (interactive) ;; turn on Flycheck locally (flycheck-mode 1) - ;; ensure proselint is valid for org/text - (flycheck-add-mode 'proselint major-mode) + ;; ensure LanguageTool is valid for current mode + (flycheck-add-mode 'languagetool major-mode) + ;; select LanguageTool as the checker + (setq-local flycheck-checker 'languagetool) ;; trigger immediate check (flycheck-buffer))) diff --git a/modules/modeline-config.el b/modules/modeline-config.el index 0a247732..b1403539 100644 --- a/modules/modeline-config.el +++ b/modules/modeline-config.el @@ -3,73 +3,170 @@ ;;; Commentary: -;; Doom modeline configuration with performance optimizations. - -;; Settings prioritize speed while keeping essential information including: -;; - relative file paths from project root -;; - column number and percentage position -;; - buffer modification indicators -;; - and major mode with icon. - -;; Disabled features for performance: -;; - minor modes display -;; - word count -;; - encoding info -;; - LSP information - -;; Performance tuning includes: -;; - 0.75 second refresh rate -;; - 1MB process output chunks -;; - nerd-icons (faster than all-the-icons) -;; - simplified checker format -;; - limited VCS info length +;; Simple, minimal modeline using only built-in Emacs functionality. +;; No external packages = no buffer issues, no native-comp errors. + +;; Features: +;; - Buffer status (modified, read-only) +;; - Buffer name +;; - Major mode +;; - Version control status +;; - Line and column position +;; - Buffer percentage ;;; Code: -;; ------------------------------- Doom Modeline ------------------------------- - -(use-package doom-modeline - :hook (after-init . doom-modeline-mode) - :custom - ;; Performance optimizations - (doom-modeline-buffer-file-name-style 'relative-from-project) ;; Faster than 'file-name - (doom-modeline-icon t) - (doom-modeline-major-mode-icon t) - (doom-modeline-major-mode-color-icon t) - (doom-modeline-buffer-state-icon t) - (doom-modeline-buffer-modification-icon t) - (doom-modeline-unicode-fallback nil) - (doom-modeline-minor-modes nil) ;; Hide minor modes as requested - (doom-modeline-enable-word-count nil) ;; Faster without word count - (doom-modeline-continuous-word-count-modes nil) - (doom-modeline-buffer-encoding nil) ;; Hide encoding for speed - (doom-modeline-indent-info nil) ;; Hide indent info for speed - (doom-modeline-checker-simple-format t) ;; Simpler checker format for speed - (doom-modeline-number-limit 99) ;; Lower number limit for better performance - (doom-modeline-vcs-max-length 12) ;; Limit VCS info length for speed - (doom-modeline-persp-name nil) ;; Disable perspective name for speed - (doom-modeline-display-default-persp-name nil) - (doom-modeline-persp-icon nil) - (doom-modeline-lsp nil) ;; Disable LSP info for speed - - ;; UI Preferences - (doom-modeline-height 25) - (doom-modeline-bar-width 3) - (doom-modeline-window-width-limit 0.25) - (doom-modeline-project-detection 'projectile) ;; Use projectile if available, nil is faster - - ;; Use nerd-icons instead of all-the-icons - (doom-modeline-icon-preference 'nerd-icons) - - ;; Enable elements you specifically requested - (doom-modeline-column-number t) ;; Show column number - (doom-modeline-percent-position t) ;; Show percentage position - (doom-modeline-buffer-name t) ;; Show buffer name - (doom-modeline-buffer-file-name t) ;; Show file name - :config - (setq read-process-output-max (* 1024 1024)) ;; 1MB process read size for better performance - (setq doom-modeline-refresh-rate 0.75)) ;; Update rate in seconds +;; Use buffer status colors from user-constants +(require 'user-constants) + +;; -------------------------- Modeline Configuration -------------------------- + +;; Use Emacs 30's built-in right-alignment +(setq mode-line-right-align-edge 'right-margin) + +;; String truncation length for narrow windows +(defcustom cj/modeline-string-truncate-length 12 + "String length after which truncation happens in narrow windows." + :type 'natnum + :group 'modeline) + +;; -------------------------- Helper Functions --------------------------------- + +(defun cj/modeline-window-narrow-p () + "Return non-nil if window is narrow (less than 100 chars wide)." + (< (window-total-width) 100)) + +(defun cj/modeline-string-truncate-p (str) + "Return non-nil if STR should be truncated." + (and (stringp str) + (not (string-empty-p str)) + (cj/modeline-window-narrow-p) + (> (length str) cj/modeline-string-truncate-length) + (not (one-window-p :no-minibuffer)))) + +(defun cj/modeline-string-cut-middle (str) + "Truncate STR in the middle if appropriate, else return STR. +Example: `my-very-long-name.el' → `my-ver...me.el'" + (if (cj/modeline-string-truncate-p str) + (let ((half (floor cj/modeline-string-truncate-length 2))) + (concat (substring str 0 half) "..." (substring str (- half)))) + str)) + +;; -------------------------- Modeline Segments -------------------------------- + +(defvar-local cj/modeline-buffer-name + '(:eval (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + (t 'normal))) + (color (alist-get state cj/buffer-status-colors)) + (name (buffer-name)) + (truncated-name (cj/modeline-string-cut-middle name))) + (propertize truncated-name + 'face `(:foreground ,color) + 'mouse-face 'mode-line-highlight + 'help-echo (concat + name "\n" + (or (buffer-file-name) + (format "No file. Directory: %s" default-directory))) + 'local-map (let ((map (make-sparse-keymap))) + (define-key map [mode-line mouse-1] 'previous-buffer) + (define-key map [mode-line mouse-3] 'next-buffer) + map)))) + "Buffer name colored by read-only/read-write status. +Green = writeable, Red = read-only, Gold = overwrite. +Truncates in narrow windows. Click to switch buffers.") + +(defvar-local cj/modeline-position + '(:eval (format "L:%d C:%d" (line-number-at-pos) (current-column))) + "Line and column position as L:line C:col.") + +(defvar cj/modeline-vc-faces + '((added . vc-locally-added-state) + (edited . vc-edited-state) + (removed . vc-removed-state) + (missing . vc-missing-state) + (conflict . vc-conflict-state) + (locked . vc-locked-state) + (up-to-date . vc-up-to-date-state)) + "VC state to face mapping.") + +(defvar-local cj/modeline-vc-branch + '(:eval (when (mode-line-window-selected-p) ; Only show in active window + (when-let* ((file (or buffer-file-name default-directory)) + (backend (vc-backend file))) + (when-let* ((branch (vc-working-revision file backend))) + ;; For Git, try to get symbolic branch name + (when (eq backend 'Git) + (require 'vc-git) + (when-let* ((symbolic (vc-git--symbolic-ref file))) + (setq branch symbolic))) + ;; Get VC state for face + (let* ((state (vc-state file backend)) + (face (alist-get state cj/modeline-vc-faces 'vc-up-to-date-state)) + (truncated-branch (cj/modeline-string-cut-middle branch))) + (concat + (propertize (char-to-string #xE0A0) 'face 'shadow) ; Git branch symbol + " " + (propertize truncated-branch + 'face face + 'mouse-face 'mode-line-highlight + 'help-echo (format "Branch: %s\nState: %s\nmouse-1: vc-diff\nmouse-3: vc-root-diff" branch state) + 'local-map (let ((map (make-sparse-keymap))) + (define-key map [mode-line mouse-1] 'vc-diff) + (define-key map [mode-line mouse-3] 'vc-root-diff) + map)))))))) + "Git branch with symbol and colored by VC state. +Shows only in active window. Truncates in narrow windows. +Click to show diffs with `vc-diff' or `vc-root-diff'.") + +(defvar-local cj/modeline-major-mode + '(:eval (let ((mode-str (format-mode-line mode-name)) ; Convert to string + (mode-sym major-mode)) + (propertize mode-str + 'mouse-face 'mode-line-highlight + 'help-echo (if-let* ((parent (get mode-sym 'derived-mode-parent))) + (format "Major mode: %s\nDerived from: %s\nmouse-1: describe-mode" mode-sym parent) + (format "Major mode: %s\nmouse-1: describe-mode" mode-sym)) + 'local-map (let ((map (make-sparse-keymap))) + (define-key map [mode-line mouse-1] 'describe-mode) + map)))) + "Major mode name only (no minor modes). +Click to show help with `describe-mode'.") + +(defvar-local cj/modeline-misc-info + '(:eval (when (mode-line-window-selected-p) + mode-line-misc-info)) + "Misc info (chime notifications, etc). +Shows only in active window.") + +;; -------------------------- Modeline Assembly -------------------------------- + +(setq-default mode-line-format + '("%e" ; Error message if out of memory + ;; LEFT SIDE + " " + cj/modeline-major-mode + " " + cj/modeline-buffer-name + " " + cj/modeline-position + ;; RIGHT SIDE (using Emacs 30 built-in right-align) + ;; Order: leftmost to rightmost as they appear in the list + mode-line-format-right-align + cj/modeline-vc-branch + " " + cj/modeline-misc-info + " ")) +;; Mark all segments as risky-local-variable (required for :eval forms) +(dolist (construct '(cj/modeline-buffer-name + cj/modeline-position + cj/modeline-vc-branch + cj/modeline-vc-faces + cj/modeline-major-mode + cj/modeline-misc-info)) + (put construct 'risky-local-variable t)) (provide 'modeline-config) ;;; modeline-config.el ends here diff --git a/modules/music-config.el b/modules/music-config.el index 902fbd9c..2e9b3252 100644 --- a/modules/music-config.el +++ b/modules/music-config.el @@ -44,14 +44,16 @@ (defun cj/music--valid-file-p (file) "Return non-nil if FILE has an accepted music extension (case-insensitive)." - (when-let ((ext (file-name-extension file))) - (member (downcase ext) cj/music-file-extensions))) + (when (and file (stringp file)) + (when-let ((ext (file-name-extension file))) + (member (downcase ext) cj/music-file-extensions)))) (defun cj/music--valid-directory-p (dir) "Return non-nil if DIR is a non-hidden directory." - (and (file-directory-p dir) - (not (string-prefix-p "." (file-name-nondirectory - (directory-file-name dir)))))) + (when (and dir (stringp dir) (not (string-empty-p dir))) + (and (file-directory-p dir) + (not (string-prefix-p "." (file-name-nondirectory + (directory-file-name dir))))))) (defun cj/music--collect-entries-recursive (root) "Return sorted relative paths of all subdirs and music files under ROOT. @@ -105,7 +107,7 @@ Directories are suffixed with /; files are plain. Hidden dirs/files skipped." (let ((line (string-trim (match-string 0)))) (unless (string-empty-p line) (push (if (or (file-name-absolute-p line) - (string-match-p "\`\(https?\|mms\)://" line)) + (string-match-p "\\`\\(https?\\|mms\\)://" line)) line (expand-file-name line dir)) tracks)))) @@ -189,6 +191,60 @@ Directories (trailing /) are added recursively; files added singly." ;;; Commands: playlist management (load/save/clear/reload/edit) +(defun cj/music--append-track-to-m3u-file (track-path m3u-file) + "Append TRACK-PATH to M3U-FILE. Signals error on failure. +Pure function for testing - no user interaction. +TRACK-PATH should be an absolute path. +M3U-FILE should be an existing, writable M3U file path." + (unless (file-exists-p m3u-file) + (error "M3U file does not exist: %s" m3u-file)) + (unless (file-writable-p m3u-file) + (error "M3U file is not writable: %s" m3u-file)) + + ;; Determine if we need a leading newline + (let ((needs-prefix-newline nil) + (file-size (file-attribute-size (file-attributes m3u-file)))) + (when (> file-size 0) + ;; Read the last character of the file to check if it ends with newline + (with-temp-buffer + (insert-file-contents m3u-file nil (max 0 (1- file-size)) file-size) + (setq needs-prefix-newline (not (= (char-after (point-min)) ?\n))))) + + ;; Append the track with proper newline handling + (with-temp-buffer + (when needs-prefix-newline + (insert "\n")) + (insert track-path "\n") + (write-region (point-min) (point-max) m3u-file t 0))) + t) + + +(defun cj/music-append-track-to-playlist () + "Append track at point to a selected M3U playlist file. +Prompts for M3U file selection with completion. Allows cancellation." + (interactive) + (unless (derived-mode-p 'emms-playlist-mode) + (user-error "This command must be run in the EMMS playlist buffer")) + (let ((track (emms-playlist-track-at (point)))) + (unless track + (user-error "No track at point")) + (let* ((track-path (emms-track-name track)) + (m3u-files (cj/music--get-m3u-files))) + (when (null m3u-files) + (user-error "No M3U files found in %s" cj/music-m3u-root)) + (let* ((choices (append (mapcar #'car m3u-files) '("(Cancel)"))) + (choice (completing-read "Append track to playlist: " choices nil t))) + (if (string= choice "(Cancel)") + (message "Cancelled") + (let ((m3u-file (cdr (assoc choice m3u-files)))) + (condition-case err + (progn + (cj/music--append-track-to-m3u-file track-path m3u-file) + (message "Added '%s' to %s" + (file-name-nondirectory track-path) + choice)) + (error (message "Failed to append track: %s" (error-message-string err)))))))))) + (defun cj/music-playlist-load () "Load an M3U playlist from cj/music-m3u-root. @@ -441,6 +497,7 @@ Dirs added recursively." ("q" . emms-playlist-mode-bury-buffer) ("a" . cj/music-fuzzy-select-and-add) ;; Manipulation + ("A" . cj/music-append-track-to-playlist) ("C" . cj/music-playlist-clear) ("L" . cj/music-playlist-load) ("E" . cj/music-playlist-edit) diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el index e2740cb4..61e542f6 100644 --- a/modules/org-agenda-config.el +++ b/modules/org-agenda-config.el @@ -262,20 +262,26 @@ This allows a line to show in an agenda without being scheduled or a deadline." :after alert ; Removed org-agenda - chime.el requires it internally :ensure nil ;; using local version :load-path "~/code/chime.el" + :init + ;; Debug mode (keep set to nil, but available for troubleshooting) + (setq chime-debug nil) :bind ("C-c A" . chime-check) :config - ;; Notification times: 5 minutes before and at event time (0 minutes) - ;; This gives two notifications per event without any after-event notifications - (setq chime-alert-time '(5 0)) + ;; Polling interval: check every 30 seconds + (setq chime-check-interval 30) - ;; Modeline display: show upcoming events within 2 hours - (setq chime-enable-modeline t) + ;; Alert intervals: 5 minutes before and at event time + ;; All notifications use medium urgency + (setq chime-alert-intervals '((5 . medium) (0 . medium))) + + ;; Day-wide events: notify at 9 AM for birthdays/all-day events + (setq chime-day-wide-time "09:00") + + ;; Modeline display: show upcoming events within 3 hours (setq chime-modeline-lookahead-minutes (* 3 60)) - (setq chime-modeline-format " ⏰ %s") - ;; Tooltip settings: show up to 20 upcoming events (regardless of how far in future) - ;; chime-tooltip-lookahead-hours defaults to 8760 (1 year) - effectively unlimited + ;; Tooltip settings: show up to 10 upcoming events within 6 days (setq chime-modeline-tooltip-max-events 10) (setq chime-tooltip-lookahead-hours (* 6 24)) @@ -287,26 +293,13 @@ This allows a line to show in an agenda without being scheduled or a deadline." (setq chime-time-left-format-long " in %hh %mm ") ; 1 hour+: " in 1h 37m" (setq chime-time-left-format-at-event "now") - ;; Title truncation: limit long event titles to 15 characters - ;; This affects only the title, not the icon or countdown - (setq chime-max-title-length 25) ; "Very Long Me... ( in 10m)" - - ;; Chime sound: disabled - (setq chime-play-sound nil) + ;; Title truncation: limit long event titles to 25 characters + (setq chime-max-title-length 25) - ;; Notification settings + ;; Notification title (setq chime-notification-title "Reminder") - (setq chime-alert-severity 'medium) - - ;; Don't filter by TODO keywords - notify for all events with timestamps - (setq chime-keyword-whitelist nil) - (setq chime-keyword-blacklist nil) - - ;; Only notify for non-done items (default behavior) - (setq chime-predicate-blacklist - '(chime-done-keywords-predicate)) - ;; Enable chime-mode - validation happens on first check after startup-delay + ;; Enable chime-mode (chime-mode 1)) ;; which-key labels diff --git a/modules/org-config.el b/modules/org-config.el index 753b1092..75d4c7db 100644 --- a/modules/org-config.el +++ b/modules/org-config.el @@ -6,102 +6,6 @@ ;;; Code: - -;; ---------------------------------- Org Mode --------------------------------- - -(use-package org - :defer t - :ensure nil ;; use the built-in package - :pin manual ;; never upgrade from the version built-into Emacs - :init - (defvar-keymap cj/org-table-map - :doc "org table operations.") - (keymap-set cj/custom-keymap "T" cj/org-table-map) - :bind - ("C-c c" . org-capture) - ("C-c a" . org-agenda) - (:map org-mode-map - ("C-c I" . org-table-field-info) ;; was C-c ? - ("C-\\" . org-match-sparse-tree) - ("C-c t" . org-set-tags-command) - ("C-c l" . org-store-link) - ("C-c C-l" . org-insert-link) - ("s-<up>" . org-priority-up) - ("s-<down>" . org-priority-down) - ("C-c N" . org-narrow-to-subtree) - ("C-c >" . cj/org-narrow-forward) - ("C-c <" . cj/org-narrow-backwards) - ("<f5>" . org-reveal) - ("C-c <ESC>" . widen)) - (:map cj/org-table-map - ("r i" . org-table-insert-row) - ("r d" . org-table-kill-row) - ("c i" . org-table-insert-column) - ("c d" . org-table-delete-column)) - - ;; backward and forward day are ',' and '.' - ;; shift & meta moves by week or year - ;; C-. jumps to today - ;; original keybindings blocked by windmove keys - ;; these are consistent with plain-old calendar mode - (:map org-read-date-minibuffer-local-map - ("," . (lambda () (interactive) - (org-eval-in-calendar '(calendar-backward-day 1)))) - ("." . (lambda () (interactive) - (org-eval-in-calendar '(calendar-forward-day 1)))) - ("<" . (lambda () (interactive) - (org-eval-in-calendar '(calendar-backward-month 1)))) - (">" . (lambda () (interactive) - (org-eval-in-calendar '(calendar-forward-month 1)))) - ("M-," . (lambda () (interactive) - (org-eval-in-calendar '(calendar-backward-year 1)))) - ("M-." . (lambda () (interactive) - (org-eval-in-calendar '(calendar-forward-year 1))))) - - :init - ;; windmove's keybindings conflict with org-agenda-todo-nextset/previousset keybindings - ;; solution: map the super key so that - ;; - super up/down increases and decreases the priority - ;; - super left/right changes the todo state - (setq org-replace-disputed-keys t) - (setq org-disputed-keys - '(([(shift left)] . [(super left)]) - ([(shift right)] . [(super right)]) - ([(shift up)] . [(super up)]) - ([(shift down)] . [(super down)]) - ([(control shift right)] . [(meta shift +)]) - ([(control shift left)] . [(meta shift -)]))) - - (defun cj/org-narrow-forward () - "Narrow to the next subtree at the same level." - (interactive) - (widen) - (org-forward-heading-same-level 1) - (org-narrow-to-subtree)) - - (defun cj/org-narrow-backwards () - "Narrow to the previous subtree at the same level." - (interactive) - (widen) - (org-backward-heading-same-level 1) - (org-narrow-to-subtree)) - - :hook - (org-mode . turn-on-visual-line-mode) - (org-mode . (lambda () (setq-local tab-width 8))) - - :config - ;; Load org-protocol for org-protocol:// URL handling - (require 'org-protocol nil t) - - ;; Set archive location (must be done after org loads) - (setq org-archive-location - (concat org-dir "/archives/archive.org::datetree/")) - - (cj/org-general-settings) - (cj/org-appearance-settings) - (cj/org-todo-settings)) - ;; ---------------------------- Org General Settings --------------------------- (defun cj/org-general-settings () @@ -164,11 +68,11 @@ (set-face-attribute 'org-link nil :underline t) (setq org-ellipsis " ▾") ;; change ellipses to down arrow - (setq org-hide-emphasis-markers t) ;; remove emphasis markers to keep the screen clean + (setq org-hide-emphasis-markers t) ;; hide emphasis markers (org-appear shows them when editing) (setq org-hide-leading-stars t) ;; hide leading stars, just show one per line (setq org-pretty-entities t) ;; render special symbols (setq org-pretty-entities-include-sub-superscripts nil) ;; ...except superscripts and subscripts - (setq org-fontify-emphasized-text nil) ;; ...and don't render bold and italic markup + (setq org-fontify-emphasized-text t) ;; render bold and italic markup (setq org-fontify-whole-heading-line t) ;; fontify the whole line for headings (for face-backgrounds) (add-hook 'org-mode-hook 'prettify-symbols-mode)) @@ -213,6 +117,101 @@ ;; inherit parents properties (sadly not schedules or deadlines) (setq org-use-property-inheritance t)) +;; ---------------------------------- Org Mode --------------------------------- + +(use-package org + :defer t + :ensure nil ;; use the built-in package + :pin manual ;; never upgrade from the version built-into Emacs + :init + (defvar-keymap cj/org-table-map + :doc "org table operations.") + (keymap-set cj/custom-keymap "T" cj/org-table-map) + :bind + ("C-c c" . org-capture) + ("C-c a" . org-agenda) + (:map org-mode-map + ("C-c I" . org-table-field-info) ;; was C-c ? + ("C-\\" . org-match-sparse-tree) + ("C-c t" . org-set-tags-command) + ("C-c l" . org-store-link) + ("C-c C-l" . org-insert-link) + ("s-<up>" . org-priority-up) + ("s-<down>" . org-priority-down) + ("C-c N" . org-narrow-to-subtree) + ("C-c >" . cj/org-narrow-forward) + ("C-c <" . cj/org-narrow-backwards) + ("<f5>" . org-reveal) + ("C-c <ESC>" . widen)) + (:map cj/org-table-map + ("r i" . org-table-insert-row) + ("r d" . org-table-kill-row) + ("c i" . org-table-insert-column) + ("c d" . org-table-delete-column)) + + ;; backward and forward day are ',' and '.' + ;; shift & meta moves by week or year + ;; C-. jumps to today + ;; original keybindings blocked by windmove keys + ;; these are consistent with plain-old calendar mode + (:map org-read-date-minibuffer-local-map + ("," . (lambda () (interactive) + (org-eval-in-calendar '(calendar-backward-day 1)))) + ("." . (lambda () (interactive) + (org-eval-in-calendar '(calendar-forward-day 1)))) + ("<" . (lambda () (interactive) + (org-eval-in-calendar '(calendar-backward-month 1)))) + (">" . (lambda () (interactive) + (org-eval-in-calendar '(calendar-forward-month 1)))) + ("M-," . (lambda () (interactive) + (org-eval-in-calendar '(calendar-backward-year 1)))) + ("M-." . (lambda () (interactive) + (org-eval-in-calendar '(calendar-forward-year 1))))) + + :init + ;; windmove's keybindings conflict with org-agenda-todo-nextset/previousset keybindings + ;; solution: map the super key so that + ;; - super up/down increases and decreases the priority + ;; - super left/right changes the todo state + (setq org-replace-disputed-keys t) + (setq org-disputed-keys + '(([(shift left)] . [(super left)]) + ([(shift right)] . [(super right)]) + ([(shift up)] . [(super up)]) + ([(shift down)] . [(super down)]) + ([(control shift right)] . [(meta shift +)]) + ([(control shift left)] . [(meta shift -)]))) + + (defun cj/org-narrow-forward () + "Narrow to the next subtree at the same level." + (interactive) + (widen) + (org-forward-heading-same-level 1) + (org-narrow-to-subtree)) + + (defun cj/org-narrow-backwards () + "Narrow to the previous subtree at the same level." + (interactive) + (widen) + (org-backward-heading-same-level 1) + (org-narrow-to-subtree)) + + :hook + (org-mode . turn-on-visual-line-mode) + (org-mode . (lambda () (setq-local tab-width 8))) + + :config + ;; Load org-protocol for org-protocol:// URL handling + (require 'org-protocol nil t) + + ;; Set archive location (must be done after org loads) + (setq org-archive-location + (concat org-dir "/archives/archive.org::datetree/")) + + (cj/org-general-settings) + (cj/org-appearance-settings) + (cj/org-todo-settings)) + ;; ------------------------------- Org Superstar ------------------------------- (use-package org-superstar @@ -222,6 +221,15 @@ (org-superstar-configure-like-org-bullets) (setq org-superstar-leading-bullet ?\s)) +;; -------------------------------- Org-Appear --------------------------------- + +(use-package org-appear + :hook (org-mode . org-appear-mode) + :custom + (org-appear-autoemphasis t) ;; Show * / _ when cursor is on them + (org-appear-autolinks t) ;; Also works for links + (org-appear-autosubmarkers t)) ;; And sub/superscripts + ;; ------------------------------- Org-Checklist ------------------------------- ;; needed for org-habits to reset checklists once task is complete diff --git a/modules/org-contacts-config.el b/modules/org-contacts-config.el index adb99db4..924b164c 100644 --- a/modules/org-contacts-config.el +++ b/modules/org-contacts-config.el @@ -37,6 +37,42 @@ ;; ----------------------- Org-Contacts Capture Template ----------------------- +(defun cj/org-contacts-finalize-birthday-timestamp () + "Add yearly repeating timestamp after properties drawer if BIRTHDAY is set." + (when (string= (plist-get org-capture-plist :key) "C") + (save-excursion + (goto-char (point-min)) + (let ((birthday (org-entry-get (point) "BIRTHDAY"))) + (when (and birthday (not (string-blank-p birthday))) + ;; Parse birthday - returns (year month day) or nil + (let ((parsed + (cond + ((string-match "^\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday) + (list (string-to-number (match-string 1 birthday)) + (string-to-number (match-string 2 birthday)) + (string-to-number (match-string 3 birthday)))) + ((string-match "^\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday) + (list (nth 5 (decode-time)) + (string-to-number (match-string 1 birthday)) + (string-to-number (match-string 2 birthday)))) + (t nil)))) + (when parsed + (let* ((year (nth 0 parsed)) + (month (nth 1 parsed)) + (day (nth 2 parsed)) + (time (encode-time 0 0 0 day month year)) + (dow (format-time-string "%a" time)) + (timestamp (format "<%04d-%02d-%02d %s +1y>" year month day dow)) + (heading-end (save-excursion (outline-next-heading) (point)))) + ;; Find :END: and insert timestamp + (when (re-search-forward "^[ \t]*:END:[ \t]*$" heading-end t) + (let ((end-pos (point))) + (goto-char end-pos) + (unless (re-search-forward "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^>]*\\+1y>" heading-end t) + (goto-char end-pos) + (end-of-line) + (insert "\n" timestamp)))))))))))) + (with-eval-after-load 'org-capture (add-to-list 'org-capture-templates '("C" "Contact" entry (file+headline contacts-file "Contacts") @@ -45,13 +81,15 @@ :EMAIL: %(cj/org-contacts-template-email) :PHONE: %^{Phone(s) - separate multiple with commas} :ADDRESS: %^{Address} -:BIRTHDAY: %^{Birthday (YYYY-MM-DD)} +:BIRTHDAY: %^{Birthday (YYYY-MM-DD or MM-DD)} +:NICKNAME: %^{Nickname} :COMPANY: %^{Company} :TITLE: %^{Title/Position} :WEBSITE: %^{URL} +:NOTE: %^{Notes} :END: -%^{Notes} -Added: %U"))) +Added: %U" + :prepare-finalize cj/org-contacts-finalize-birthday-timestamp))) ;; TASK: What purpose did this serve? ;; duplicate?!? diff --git a/modules/org-gcal-config.el b/modules/org-gcal-config.el index 0c309a0e..97e8446a 100644 --- a/modules/org-gcal-config.el +++ b/modules/org-gcal-config.el @@ -13,9 +13,9 @@ ;; - Events are managed by Org (changes in org file push back to Google Calendar) ;; This is controlled by org-gcal-managed-newly-fetched-mode and ;; org-gcal-managed-update-existing-mode set to "org" -;; - 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 +;; - Automatic sync timer (configurable via cj/org-gcal-sync-interval-minutes) +;; Default: 30 minutes, set to nil to disable +;; See: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#sync-automatically-at-regular-times ;; - Validates existing oath2-auto.plist file or creates it to avoid the issue mentioned here: ;; https://github.com/kidd/org-gcal.el?tab=readme-ov-file#note ;; @@ -27,7 +27,10 @@ ;; 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) +;; - Manual sync: C-; g s (or M-x org-gcal-sync) +;; - Toggle auto-sync on/off: C-; g t +;; - Restart auto-sync (e.g., after changing interval): C-; g r +;; - Clear sync lock (if sync gets stuck): C-; g c ;; ;; Note: ;; This configuration creates oauth2-auto.plist on first run to prevent sync errors. @@ -43,6 +46,17 @@ (defvar org-gcal--sync-lock)) (declare-function org-gcal-reload-client-id-secret "org-gcal") +;; User configurable sync interval +(defvar cj/org-gcal-sync-interval-minutes 30 + "Interval in minutes for automatic Google Calendar sync. +Set to nil to disable automatic syncing. +Changes take effect after calling `cj/org-gcal-restart-auto-sync'.") + +;; Internal timer object +(defvar cj/org-gcal-sync-timer nil + "Timer object for automatic org-gcal sync. +Use `cj/org-gcal-start-auto-sync' and `cj/org-gcal-stop-auto-sync' to control.") + (defun cj/org-gcal-clear-sync-lock () "Clear the org-gcal sync lock. Useful when a sync fails and leaves the lock in place, preventing future syncs." @@ -66,11 +80,61 @@ enabling bidirectional sync so changes push back to Google Calendar." (save-buffer)) (message "Converted %d event(s) to Org-managed" count))) +(defun cj/org-gcal-start-auto-sync () + "Start automatic Google Calendar sync timer. +Uses the interval specified in `cj/org-gcal-sync-interval-minutes'. +Does nothing if interval is nil or timer is already running." + (interactive) + (when (and cj/org-gcal-sync-interval-minutes + (not (and cj/org-gcal-sync-timer + (memq cj/org-gcal-sync-timer timer-list)))) + (let ((interval-seconds (* cj/org-gcal-sync-interval-minutes 60))) + (setq cj/org-gcal-sync-timer + (run-with-timer + 120 ;; Initial delay: 2 minutes after startup + interval-seconds + (lambda () + (condition-case err + (org-gcal-sync) + (error (message "org-gcal: Auto-sync failed: %s" err)))))) + (message "org-gcal: Auto-sync started (every %d minutes)" + cj/org-gcal-sync-interval-minutes)))) + +(defun cj/org-gcal-stop-auto-sync () + "Stop automatic Google Calendar sync timer." + (interactive) + (when (and cj/org-gcal-sync-timer + (memq cj/org-gcal-sync-timer timer-list)) + (cancel-timer cj/org-gcal-sync-timer) + (setq cj/org-gcal-sync-timer nil) + (message "org-gcal: Auto-sync stopped"))) + +(defun cj/org-gcal-toggle-auto-sync () + "Toggle automatic Google Calendar sync timer on/off." + (interactive) + (if (and cj/org-gcal-sync-timer + (memq cj/org-gcal-sync-timer timer-list)) + (cj/org-gcal-stop-auto-sync) + (cj/org-gcal-start-auto-sync))) + +(defun cj/org-gcal-restart-auto-sync () + "Restart automatic Google Calendar sync timer. +Useful after changing `cj/org-gcal-sync-interval-minutes'." + (interactive) + (cj/org-gcal-stop-auto-sync) + (cj/org-gcal-start-auto-sync)) + +;; Deferred library required by org-gcal +(use-package deferred + :ensure t) + +;; OAuth2 authentication library required by org-gcal +(use-package oauth2-auto + :ensure t) + (use-package org-gcal :vc (:url "https://github.com/cjennings/org-gcal" :rev :newest) :defer t ;; unless idle timer is set below - :bind (("C-; g" . org-gcal-sync) - ("C-; G" . cj/org-gcal-clear-sync-lock)) :init ;; Retrieve credentials from authinfo.gpg BEFORE package loads @@ -96,14 +160,13 @@ enabling bidirectional sync so changes push back to Google Calendar." (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 - ;; Enable bidirectional sync - treat events as Org-managed so changes push back - (setq org-gcal-managed-newly-fetched-mode "org") ;; New events from GCal are Org-managed - (setq org-gcal-managed-update-existing-mode "org") ;; Existing events become Org-managed + ;; Google Calendar is authoritative - avoids sync conflicts + (setq org-gcal-managed-newly-fetched-mode "gcal") ;; New events from GCal stay GCal-managed + (setq org-gcal-managed-update-existing-mode "gcal") ;; GCal wins on conflicts :config - ;; Enable plstore passphrase caching after org-gcal loads - (require 'plstore) - (setq plstore-cache-passphrase-for-symmetric-encryption t) + ;; Plstore caching is now configured globally in auth-config.el + ;; to ensure it loads before org-gcal needs it ;; set org-gcal timezone based on system timezone (setq org-gcal-local-timezone (cj/detect-system-timezone)) @@ -125,19 +188,26 @@ enabling bidirectional sync so changes push back to Google Calendar." ;; Advise org-gcal--sync-unlock which is called when sync completes (advice-add 'org-gcal--sync-unlock :after #'cj/org-gcal-save-files-after-sync)) -;; Set up automatic initial sync on boot with error handling -;;(run-with-idle-timer -;; 2 nil -;; (lambda () -;; (condition-case err -;; (org-gcal-sync) -;; (error (message "org-gcal: Initial sync failed: %s" err))))) +;; Start automatic sync timer based on user configuration +;; Set cj/org-gcal-sync-interval-minutes to nil to disable +(cj/org-gcal-start-auto-sync) + +;; Google Calendar keymap and keybindings +(defvar-keymap cj/gcal-map + :doc "Keymap for Google Calendar operations" + "s" #'org-gcal-sync + "t" #'cj/org-gcal-toggle-auto-sync + "r" #'cj/org-gcal-restart-auto-sync + "c" #'cj/org-gcal-clear-sync-lock) +(keymap-set cj/custom-keymap "g" cj/gcal-map) -;; which-key labels (with-eval-after-load 'which-key (which-key-add-key-based-replacements - "C-; g" "gcal sync" - "C-; G" "clear sync lock")) + "C-; g" "gcal menu" + "C-; g s" "sync" + "C-; g t" "toggle auto-sync" + "C-; g r" "restart auto-sync" + "C-; g c" "clear sync lock")) (provide 'org-gcal-config) ;;; org-gcal-config.el ends here diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el index a6b42ce7..a33dd633 100644 --- a/modules/org-roam-config.el +++ b/modules/org-roam-config.el @@ -297,6 +297,36 @@ title." ;; Message to user (message "'%s' added as an org-roam node." title))) +;; TASK: Need to decide keybindings before implementation and testing +;; (use-package consult-org-roam +;; :ensure t +;; :after org-roam +;; :init +;; (require 'consult-org-roam) +;; ;; Activate the minor mode +;; (consult-org-roam-mode 1) +;; :custom +;; ;; Use `ripgrep' for searching with `consult-org-roam-search' +;; (consult-org-roam-grep-func #'consult-ripgrep) +;; ;; Configure a custom narrow key for `consult-buffer' +;; (consult-org-roam-buffer-narrow-key ?r) +;; ;; Display org-roam buffers right after non-org-roam buffers +;; ;; in consult-buffer (and not down at the bottom) +;; (consult-org-roam-buffer-after-buffers t) +;; :config +;; ;; Eventually suppress previewing for certain functions +;; (consult-customize +;; consult-org-roam-forward-links +;; :preview-key "M-.") +;; :bind +;; ;; Define some convenient keybindings as an addition +;; ("C-c n e" . consult-org-roam-file-find) +;; ("C-c n b" . consult-org-roam-backlinks) +;; ("C-c n B" . consult-org-roam-backlinks-recursive) +;; ("C-c n l" . consult-org-roam-forward-links) +;; ("C-c n r" . consult-org-roam-search)) + + ;; which-key labels (with-eval-after-load 'which-key (which-key-add-key-based-replacements diff --git a/modules/prog-general.el b/modules/prog-general.el index 3ece928a..0ae6aa82 100644 --- a/modules/prog-general.el +++ b/modules/prog-general.el @@ -93,8 +93,7 @@ ;; --------------------------------- Treesitter -------------------------------- ;; incremental language syntax parser - -(use-package tree-sitter) +;; Using Emacs 29+ built-in treesit with treesit-auto for grammar management ;; installs tree-sitter grammars if they're absent (use-package treesit-auto diff --git a/modules/prog-go.el b/modules/prog-go.el index 465cbf14..0d271617 100644 --- a/modules/prog-go.el +++ b/modules/prog-go.el @@ -31,9 +31,6 @@ Install with: go install golang.org/x/tools/gopls@latest") "Path to Delve debugger. Install with: go install github.com/go-delve/delve/cmd/dlv@latest") -(defvar go-ts-mode-map) -(defvar go-mod-ts-mode-map) - ;; Forward declarations for LSP (declare-function lsp-deferred "lsp-mode") (defvar lsp-go-gopls-server-path) @@ -88,6 +85,9 @@ Install with: go install github.com/go-delve/delve/cmd/dlv@latest") (defun cj/go-mode-keybindings () "Set up keybindings for Go programming. Overrides default prog-mode keybindings with Go-specific commands." + ;; C-; f: Format with gofmt/goimports + (local-set-key (kbd "C-; f") #'gofmt) + ;; S-f5: Run staticcheck (static analysis) (local-set-key (kbd "S-<f5>") #'cj/go-staticcheck) @@ -100,8 +100,6 @@ Overrides default prog-mode keybindings with Go-specific commands." (use-package go-mode :hook ((go-ts-mode . cj/go-setup) (go-ts-mode . cj/go-mode-keybindings)) - :bind (:map go-ts-mode-map - ("C-; f" . gofmt)) ;; Override global formatter with gofmt/goimports :mode (("\\.go\\'" . go-ts-mode) ;; .go files use go-ts-mode ("go\\.mod\\'" . go-mod-ts-mode)) ;; go.mod uses go-mod-ts-mode :config diff --git a/modules/system-utils.el b/modules/system-utils.el index 2b39d862..e9686777 100644 --- a/modules/system-utils.el +++ b/modules/system-utils.el @@ -186,13 +186,22 @@ Logs output and exit code to buffer *external-open.log*." ;;; -------------------------- Scratch Buffer Happiness ------------------------- (defvar scratch-emacs-version-and-system - (concat ";; Emacs " emacs-version + (concat "# Emacs " emacs-version " on " system-configuration ".\n")) (defvar scratch-greet - (concat ";; Emacs ♥ you, " user-login-name ". Happy Hacking!\n\n")) + (concat "# Emacs ♥ you, " user-login-name ". Happy Hacking!\n\n")) (setopt initial-scratch-message (concat scratch-emacs-version-and-system scratch-greet)) +;; Set scratch buffer to org-mode +(setopt initial-major-mode 'org-mode) + +;; Move cursor to end of scratch buffer on startup +(add-hook 'emacs-startup-hook + (lambda () + (when (string= (buffer-name) "*scratch*") + (goto-char (point-max))))) + ;;; --------------------------------- Dictionary -------------------------------- (use-package quick-sdcv diff --git a/modules/transcription-config.el b/modules/transcription-config.el new file mode 100644 index 00000000..fd2f4aaa --- /dev/null +++ b/modules/transcription-config.el @@ -0,0 +1,390 @@ +;;; transcription-config.el --- Audio transcription workflow -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Created: 2025-11-04 + +;;; Commentary: +;; +;; Audio transcription workflow with multiple backend options. +;; +;; USAGE: +;; In dired: Press `T` on an audio file to transcribe +;; Anywhere: M-x cj/transcribe-audio +;; View active: M-x cj/transcriptions-buffer +;; Switch backend: C-; T b (or M-x cj/transcription-switch-backend) +;; +;; OUTPUT FILES: +;; audio.m4a → audio.txt (transcript) +;; → audio.log (process logs, conditionally kept) +;; +;; BACKENDS: +;; - 'openai-api: Fast cloud transcription +;; API key retrieved from authinfo.gpg (machine api.openai.com) +;; - 'assemblyai: Cloud transcription with speaker diarization +;; API key retrieved from authinfo.gpg (machine api.assemblyai.com) +;; - 'local-whisper: Local transcription (requires whisper installed) +;; +;; NOTIFICATIONS: +;; - "Transcription started on <file>" +;; - "Transcription complete. Transcript in <file.txt>" +;; - "Transcription errored. Logs in <file.log>" +;; +;; MODELINE: +;; Shows active transcription count: ⏺2 +;; Click to view *Transcriptions* buffer +;; +;;; Code: + +(require 'dired) +(require 'notifications) +(require 'auth-source) + +;; ----------------------------- Configuration --------------------------------- + +(defvar cj/transcribe-backend 'assemblyai + "Transcription backend to use. +- `openai-api': Fast cloud transcription via OpenAI API +- `assemblyai': Cloud transcription with speaker diarization via AssemblyAI +- `local-whisper': Local transcription using installed Whisper") + +(defvar cj/transcription-keep-log-when-done nil + "Whether to keep log files after successful transcription. +If nil, log files are deleted after successful completion. +If t, log files are always kept. +Log files are always kept on error regardless of this setting.") + +(defvar cj/transcriptions-list '() + "List of active transcriptions. +Each entry: (process audio-file start-time status) +Status: running, complete, error") + +;; ----------------------------- Pure Functions -------------------------------- + +(defun cj/--audio-file-p (file) + "Return non-nil if FILE is an audio file based on extension." + (when (and file (stringp file)) + (when-let ((ext (file-name-extension file))) + (member (downcase ext) cj/audio-file-extensions)))) + +(defun cj/--transcription-output-files (audio-file) + "Return cons cell of (TXT-FILE . LOG-FILE) for AUDIO-FILE." + (let ((base (file-name-sans-extension audio-file))) + (cons (concat base ".txt") + (concat base ".log")))) + +(defun cj/--transcription-duration (start-time) + "Return duration string (MM:SS) since START-TIME." + (let* ((elapsed (float-time (time-subtract (current-time) start-time))) + (minutes (floor (/ elapsed 60))) + (seconds (floor (mod elapsed 60)))) + (format "%02d:%02d" minutes seconds))) + +(defun cj/--should-keep-log (success-p) + "Return non-nil if log file should be kept. +SUCCESS-P indicates whether transcription succeeded." + (or (not success-p) ; Always keep on error + cj/transcription-keep-log-when-done)) + +(defun cj/--transcription-script-path () + "Return absolute path to transcription script based on backend." + (let ((script-name (pcase cj/transcribe-backend + ('openai-api "oai-transcribe") + ('assemblyai "assemblyai-transcribe") + ('local-whisper "local-whisper")))) + (expand-file-name (concat "scripts/" script-name) user-emacs-directory))) + +(defun cj/--get-openai-api-key () + "Retrieve OpenAI API key from authinfo.gpg. +Expects entry in authinfo.gpg: + machine api.openai.com login api password sk-... +Returns the API key string, or nil if not found." + (when-let* ((auth-info (car (auth-source-search + :host "api.openai.com" + :require '(:secret)))) + (secret (plist-get auth-info :secret))) + (if (functionp secret) + (funcall secret) + secret))) + +(defun cj/--get-assemblyai-api-key () + "Retrieve AssemblyAI API key from authinfo.gpg. +Expects entry in authinfo.gpg: + machine api.assemblyai.com login api password <key> +Returns the API key string, or nil if not found." + (when-let* ((auth-info (car (auth-source-search + :host "api.assemblyai.com" + :require '(:secret)))) + (secret (plist-get auth-info :secret))) + (if (functionp secret) + (funcall secret) + secret))) + +;; ---------------------------- Process Management ----------------------------- + +(defun cj/--notify (title message &optional urgency) + "Send desktop notification and echo area message. +TITLE and MESSAGE are strings. URGENCY is normal or critical." + (message "%s: %s" title message) + (when (and (fboundp 'notifications-notify) + (getenv "DISPLAY")) + (notifications-notify + :title title + :body message + :urgency (or urgency 'normal)))) + +(defun cj/--start-transcription-process (audio-file) + "Start async transcription process for AUDIO-FILE. +Returns the process object." + (unless (file-exists-p audio-file) + (user-error "Audio file does not exist: %s" audio-file)) + + (unless (cj/--audio-file-p audio-file) + (user-error "Not an audio file: %s" audio-file)) + + (let* ((script (cj/--transcription-script-path)) + (outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs)) + (log-file (cdr outputs)) + (buffer-name (format " *transcribe-%s*" (file-name-nondirectory audio-file))) + (process-name (format "transcribe-%s" (file-name-nondirectory audio-file)))) + + (unless (file-executable-p script) + (user-error "Transcription script not found or not executable: %s" script)) + + ;; Create log file + (with-temp-file log-file + (insert (format "Transcription started: %s\n" (current-time-string)) + (format "Backend: %s\n" cj/transcribe-backend) + (format "Audio file: %s\n" audio-file) + (format "Script: %s\n\n" script))) + + ;; Start process with environment + (let* ((process-environment + ;; Add API key to environment based on backend + (pcase cj/transcribe-backend + ('openai-api + (if-let ((api-key (cj/--get-openai-api-key))) + (cons (format "OPENAI_API_KEY=%s" api-key) + process-environment) + (user-error "OpenAI API key not found in authinfo.gpg for host api.openai.com"))) + ('assemblyai + (if-let ((api-key (cj/--get-assemblyai-api-key))) + (cons (format "ASSEMBLYAI_API_KEY=%s" api-key) + process-environment) + (user-error "AssemblyAI API key not found in authinfo.gpg for host api.assemblyai.com"))) + (_ process-environment))) + (process (make-process + :name process-name + :buffer (get-buffer-create buffer-name) + :command (list script audio-file) + :sentinel (lambda (proc event) + (cj/--transcription-sentinel proc event audio-file txt-file log-file)) + :stderr log-file))) + + ;; Track transcription + (push (list process audio-file (current-time) 'running) cj/transcriptions-list) + (force-mode-line-update t) + + ;; Notify user + (cj/--notify "Transcription" + (format "Started on %s" (file-name-nondirectory audio-file))) + + process))) + +(defun cj/--transcription-sentinel (process event audio-file txt-file log-file) + "Sentinel for transcription PROCESS. +EVENT is the process event string. +AUDIO-FILE, TXT-FILE, and LOG-FILE are the associated files." + (let* ((success-p (and (string-match-p "finished" event) + (= 0 (process-exit-status process)))) + (process-buffer (process-buffer process)) + (entry (assq process cj/transcriptions-list))) + + ;; Write process output to txt file + (when (and success-p (buffer-live-p process-buffer)) + (with-current-buffer process-buffer + (write-region (point-min) (point-max) txt-file nil 'silent))) + + ;; Append process output to log file + (when (buffer-live-p process-buffer) + (with-temp-buffer + (insert-file-contents log-file) + (goto-char (point-max)) + (insert "\n" (format-time-string "[%Y-%m-%d %H:%M:%S] ") event "\n") + (insert-buffer-substring process-buffer) + (write-region (point-min) (point-max) log-file nil 'silent))) + + ;; Update transcription status + (when entry + (setf (nth 3 entry) (if success-p 'complete 'error))) + + ;; Cleanup log file if successful and configured to do so + (when (and success-p (not (cj/--should-keep-log t))) + (delete-file log-file)) + + ;; Kill process buffer + (when (buffer-live-p process-buffer) + (kill-buffer process-buffer)) + + ;; Notify user + (if success-p + (cj/--notify "Transcription" + (format "Complete. Transcript in %s" (file-name-nondirectory txt-file))) + (cj/--notify "Transcription" + (format "Errored. Logs in %s" (file-name-nondirectory log-file)) + 'critical)) + + ;; Clean up completed transcriptions after 10 minutes + (run-at-time 600 nil #'cj/--cleanup-completed-transcriptions) + + ;; Update modeline + (force-mode-line-update t))) + +(defun cj/--cleanup-completed-transcriptions () + "Remove completed/errored transcriptions from tracking list." + (setq cj/transcriptions-list + (seq-filter (lambda (entry) + (eq (nth 3 entry) 'running)) + cj/transcriptions-list)) + (force-mode-line-update t)) + +(defun cj/--count-active-transcriptions () + "Return count of running transcriptions." + (length (seq-filter (lambda (entry) + (eq (nth 3 entry) 'running)) + cj/transcriptions-list))) + +;; ----------------------------- Modeline Integration -------------------------- + +(defun cj/--transcription-modeline-string () + "Return modeline string for active transcriptions." + (let ((count (cj/--count-active-transcriptions))) + (when (> count 0) + (propertize (format " ⏺%d " count) + 'face 'warning + 'help-echo (format "%d active transcription%s (click to view)" + count (if (= count 1) "" "s")) + 'mouse-face 'mode-line-highlight + 'local-map (let ((map (make-sparse-keymap))) + (define-key map [mode-line mouse-1] + #'cj/transcriptions-buffer) + map))))) + +;; Add to mode-line-format (will be activated when module loads) +(add-to-list 'mode-line-misc-info + '(:eval (cj/--transcription-modeline-string)) + t) + +;; --------------------------- Interactive Commands ---------------------------- + +;;;###autoload +(defun cj/transcribe-audio (audio-file) + "Transcribe AUDIO-FILE asynchronously. +Creates AUDIO.txt with transcript and AUDIO.log with process logs. +Uses backend specified by `cj/transcribe-backend'." + (interactive (list (read-file-name "Audio file to transcribe: " + nil nil t nil + #'cj/--audio-file-p))) + (cj/--start-transcription-process (expand-file-name audio-file))) + +;;;###autoload +(defun cj/transcribe-audio-at-point () + "Transcribe audio file at point in dired." + (interactive) + (unless (derived-mode-p 'dired-mode) + (user-error "Not in dired-mode")) + (let ((file (dired-get-filename nil t))) + (unless file + (user-error "No file at point")) + (cj/transcribe-audio file))) + +;;;###autoload +(defun cj/transcriptions-buffer () + "Show buffer with active transcriptions." + (interactive) + (let ((buffer (get-buffer-create "*Transcriptions*"))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (propertize "Active Transcriptions\n" 'face 'bold) + (propertize (make-string 50 ?─) 'face 'shadow) + "\n\n") + (if (null cj/transcriptions-list) + (insert "No active transcriptions.\n") + (dolist (entry cj/transcriptions-list) + (let* ((process (nth 0 entry)) + (audio-file (nth 1 entry)) + (start-time (nth 2 entry)) + (status (nth 3 entry)) + (duration (cj/--transcription-duration start-time)) + (status-face (pcase status + ('running 'warning) + ('complete 'success) + ('error 'error)))) + (insert (propertize (format "%-10s" status) 'face status-face) + " " + (file-name-nondirectory audio-file) + (format " (%s)\n" duration)))))) + (goto-char (point-min)) + (special-mode)) + (display-buffer buffer))) + +;;;###autoload +(defun cj/transcription-kill (process) + "Kill transcription PROCESS." + (interactive + (list (let ((choices (mapcar (lambda (entry) + (cons (file-name-nondirectory (nth 1 entry)) + (nth 0 entry))) + cj/transcriptions-list))) + (unless choices + (user-error "No active transcriptions")) + (cdr (assoc (completing-read "Kill transcription: " choices nil t) + choices))))) + (when (process-live-p process) + (kill-process process) + (message "Killed transcription process"))) + +;;;###autoload +(defun cj/transcription-switch-backend () + "Switch transcription backend. +Prompts with completing-read to select from available backends." + (interactive) + (let* ((backends '(("assemblyai" . assemblyai) + ("openai-api" . openai-api) + ("local-whisper" . local-whisper))) + (current (symbol-name cj/transcribe-backend)) + (prompt (format "Transcription backend (current: %s): " current)) + (choice (completing-read prompt backends nil t)) + (new-backend (alist-get choice backends nil nil #'string=))) + (setq cj/transcribe-backend new-backend) + (message "Transcription backend: %s" choice))) + +;; ------------------------------- Dired Integration --------------------------- + +(with-eval-after-load 'dired + (define-key dired-mode-map (kbd "T") #'cj/transcribe-audio-at-point)) + +;; Dirvish inherits dired-mode-map, so T works automatically + +;; ------------------------------- Global Keybindings -------------------------- + +;; Transcription keymap +(defvar-keymap cj/transcribe-map + :doc "Keymap for transcription operations" + "a" #'cj/transcribe-audio + "b" #'cj/transcription-switch-backend + "v" #'cj/transcriptions-buffer + "k" #'cj/transcription-kill) +(keymap-set cj/custom-keymap "T" cj/transcribe-map) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; T" "transcription menu" + "C-; T a" "transcribe audio" + "C-; T b" "switch backend" + "C-; T v" "view transcriptions" + "C-; T k" "kill transcription")) + +(provide 'transcription-config) +;;; transcription-config.el ends here diff --git a/modules/ui-config.el b/modules/ui-config.el index 91dbaf31..837d2169 100644 --- a/modules/ui-config.el +++ b/modules/ui-config.el @@ -36,11 +36,8 @@ "Opacity level for Emacs frames when `cj/enable-transparency' is non-nil. 100 = fully opaque, 0 = fully transparent.") -(defconst cj/cursor-colors - '((read-only . "#f06a3f") ; red – buffer is read-only - (overwrite . "#c48702") ; gold – overwrite mode - (normal . "#64aa0f")) ; green – insert & read/write - "Alist mapping cursor states to their colors.") +;; Use buffer status colors from user-constants +(require 'user-constants) ;; ----------------------------- System UI Settings ---------------------------- @@ -104,7 +101,7 @@ When `cj/enable-transparency' is nil, reset alpha to fully opaque." (buffer-read-only 'read-only) (overwrite-mode 'overwrite) (t 'normal))) - (color (alist-get state cj/cursor-colors))) + (color (alist-get state cj/buffer-status-colors))) (unless (and (string= color cj/-cursor-last-color) (string= (buffer-name) cj/-cursor-last-buffer)) (set-cursor-color color) diff --git a/modules/user-constants.el b/modules/user-constants.el index bcb34bcc..2a6d0ca2 100644 --- a/modules/user-constants.el +++ b/modules/user-constants.el @@ -38,6 +38,23 @@ Example: (setq cj/debug-modules '(org-agenda mail)) (defvar user-mail-address "c@cjennings.net" "The user's email address.") +;; ---------------------------- Buffer Status Colors --------------------------- + +(defconst cj/buffer-status-colors + '((read-only . "#f06a3f") ; red – buffer is read-only + (overwrite . "#c48702") ; gold – overwrite mode + (normal . "#64aa0f")) ; green – insert & read/write + "Alist mapping buffer states to their colors. +Used by cursor color, modeline, and other UI elements.") + +;; --------------------------- Media File Extensions --------------------------- + +(defvar cj/audio-file-extensions + '("m4a" "mp3" "wav" "flac" "ogg" "opus" "aac" + "aiff" "aif" "wma" "ape" "alac" "weba") + "File extensions recognized as audio files. +Used by transcription module and other audio-related functionality.") + ;; ------------------------ Directory And File Constants ----------------------- ;; DIRECTORIES diff --git a/modules/vc-config.el b/modules/vc-config.el index a936e890..141f6e17 100644 --- a/modules/vc-config.el +++ b/modules/vc-config.el @@ -115,6 +115,30 @@ (forge-create-issue) (user-error "Not in a forge repository"))) +(defun cj/goto-git-gutter-diff-hunks () + "Jump to git-gutter diff hunks using consult. +Searches for lines starting with + or - (diff markers) and allows +interactive selection to jump to any changed line in the buffer." + (interactive) + (require 'git-gutter) + (consult-line "^[+\\-]")) + +;; -------------------------------- Difftastic --------------------------------- +;; Structural diffs for better git change visualization +;; Requires: difft binary (installed via pacman -S difftastic) + +(use-package difftastic + :defer t + :commands (difftastic-magit-diff difftastic-magit-show) + :bind (:map magit-blame-read-only-mode-map + ("D" . difftastic-magit-show) + ("S" . difftastic-magit-show)) + :config + (eval-after-load 'magit-diff + '(transient-append-suffix 'magit-diff '(-1 -1) + [("D" "Difftastic diff (dwim)" difftastic-magit-diff) + ("S" "Difftastic show" difftastic-magit-show)]))) + ;; --------------------------------- VC Keymap --------------------------------- ;; Ordering & sorting prefix and keymap diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el index 73f782f6..45bab267 100644 --- a/modules/video-audio-recording.el +++ b/modules/video-audio-recording.el @@ -4,7 +4,7 @@ ;;; 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. +;; Audio recordings use M4A/AAC format for best compatibility. ;; ;; Note: video-recordings-dir and audio-recordings-dir are defined ;; (and directory created) in user-constants.el @@ -69,6 +69,160 @@ Returns device name or nil if not found." (when (string-match "\\([^\t\n]+\\.monitor\\)" output) (match-string 1 output)))) +(defun cj/recording--parse-pactl-output (output) + "Internal parser for pactl sources output. Takes OUTPUT string. +Returns list of (device-name driver state) tuples. +Extracted for testing without shell command execution." + (let ((sources nil)) + (dolist (line (split-string output "\n" t)) + (when (string-match "^[0-9]+\t\\([^\t]+\\)\t\\([^\t]+\\)\t\\([^\t]+\\)\t\\([^\t]+\\)" line) + (let ((device (match-string 1 line)) + (driver (match-string 2 line)) + (state (match-string 4 line))) + (push (list device driver state) sources)))) + (nreverse sources))) + +(defun cj/recording-parse-sources () + "Parse pactl sources output into structured list. +Returns list of (device-name driver state) tuples." + (cj/recording--parse-pactl-output + (shell-command-to-string "pactl list sources short 2>/dev/null"))) + +(defun cj/recording-friendly-state (state) + "Convert technical state name to user-friendly label. +STATE is the raw state from pactl (SUSPENDED, RUNNING, IDLE, etc.)." + (pcase state + ("SUSPENDED" "Ready") + ("RUNNING" "Active") + ("IDLE" "Ready") + (_ state))) ; fallback to original if unknown + +(defun cj/recording-list-devices () + "Show all available audio sources in a readable format. +Opens a buffer showing devices with their states." + (interactive) + (let ((sources (cj/recording-parse-sources))) + (with-current-buffer (get-buffer-create "*Recording Devices*") + (erase-buffer) + (insert "Available Audio Sources\n") + (insert "========================\n\n") + (insert "Note: 'Ready' devices are available and will activate when recording starts.\n\n") + (insert "Current Configuration:\n") + (insert (format " Microphone: %s\n" (or cj/recording-mic-device "Not set"))) + (insert (format " System Audio: %s\n\n" (or cj/recording-system-device "Not set"))) + (insert "Available Devices:\n\n") + (if sources + (dolist (source sources) + (let ((device (nth 0 source)) + (driver (nth 1 source)) + (state (nth 2 source)) + (friendly-state (cj/recording-friendly-state (nth 2 source)))) + (insert (format "%-10s [%s]\n" friendly-state driver)) + (insert (format " %s\n\n" device)))) + (insert " No audio sources found. Is PulseAudio/PipeWire running?\n")) + (goto-char (point-min)) + (special-mode)) + (switch-to-buffer-other-window "*Recording Devices*"))) + +(defun cj/recording-select-device (prompt device-type) + "Interactively select an audio device. +PROMPT is shown to user. DEVICE-TYPE is 'mic or 'monitor for filtering. +Returns selected device name or nil." + (let* ((sources (cj/recording-parse-sources)) + (filtered (if (eq device-type 'monitor) + (seq-filter (lambda (s) (string-match-p "\\.monitor$" (car s))) sources) + (seq-filter (lambda (s) (not (string-match-p "\\.monitor$" (car s)))) sources))) + (choices (mapcar (lambda (s) + (let ((device (nth 0 s)) + (driver (nth 1 s)) + (state (nth 2 s)) + (friendly-state (cj/recording-friendly-state (nth 2 s)))) + (cons (format "%-10s %s" friendly-state device) device))) + filtered))) + (if choices + (cdr (assoc (completing-read prompt choices nil t) choices)) + (user-error "No %s devices found" (if (eq device-type 'monitor) "monitor" "input"))))) + +(defun cj/recording-select-devices () + "Interactively select microphone and system audio devices. +Sets cj/recording-mic-device and cj/recording-system-device." + (interactive) + (setq cj/recording-mic-device + (cj/recording-select-device "Select microphone device: " 'mic)) + (setq cj/recording-system-device + (cj/recording-select-device "Select system audio monitor: " 'monitor)) + (message "Devices set - Mic: %s, System: %s" + cj/recording-mic-device + cj/recording-system-device)) + +(defun cj/recording-group-devices-by-hardware () + "Group audio sources by hardware device. +Returns alist of (device-name . (mic-source . monitor-source))." + (let ((sources (cj/recording-parse-sources)) + (devices (make-hash-table :test 'equal)) + (result nil)) + ;; Group sources by base device name + (dolist (source sources) + (let* ((device (nth 0 source)) + (driver (nth 1 source)) + ;; Extract hardware ID (the unique part that identifies the physical device) + (base-name (cond + ;; USB devices: extract usb-XXXXX-XX part + ((string-match "\\.\\(usb-[^.]+\\-[0-9]+\\)\\." device) + (match-string 1 device)) + ;; Built-in (pci) devices: extract pci-XXXXX part + ((string-match "\\.\\(pci-[^.]+\\)\\." device) + (match-string 1 device)) + ;; Bluetooth devices: extract and normalize MAC address + ;; (input uses colons, output uses underscores - normalize to colons) + ((string-match "bluez_\\(?:input\\|output\\)\\.\\([^.]+\\)" device) + (replace-regexp-in-string "_" ":" (match-string 1 device))) + (t device))) + (is-monitor (string-match-p "\\.monitor$" device)) + (device-entry (gethash base-name devices))) + (unless device-entry + (setf device-entry (cons nil nil)) + (puthash base-name device-entry devices)) + ;; Store mic or monitor in the pair + (if is-monitor + (setcdr device-entry device) + (setcar device-entry device)))) + + ;; Convert hash table to alist with friendly names + (maphash (lambda (base-name pair) + (when (and (car pair) (cdr pair)) ; Only include if we have both mic and monitor + (let ((friendly-name + (cond + ((string-match-p "usb.*[Jj]abra" base-name) "Jabra SPEAK 510 USB") + ((string-match-p "^usb-" base-name) "USB Audio Device") + ((string-match-p "^pci-" base-name) "Built-in Laptop Audio") + ((string-match-p "^[0-9A-Fa-f:]+$" base-name) "Bluetooth Headset") + (t base-name)))) + (push (cons friendly-name pair) result)))) + devices) + (nreverse result))) + +(defun cj/recording-quick-setup-for-calls () + "Quick setup for recording call/meetings. +Detects available audio devices and lets you pick one device to use for +both microphone (your voice) and monitor (remote person + sound effects). +Perfect for recording video calls, phone calls, or presentations." + (interactive) + (let* ((grouped-devices (cj/recording-group-devices-by-hardware)) + (choices (mapcar #'car grouped-devices))) + (if (null choices) + (user-error "No complete audio devices found (need both mic and monitor)") + (let* ((choice (completing-read "Which device are you using for the call? " choices nil t)) + (device-pair (cdr (assoc choice grouped-devices))) + (mic (car device-pair)) + (monitor (cdr device-pair))) + (setq cj/recording-mic-device mic) + (setq cj/recording-system-device monitor) + (message "Call recording ready! Using: %s\n Mic: %s\n Monitor: %s" + choice + (file-name-nondirectory mic) + (file-name-nondirectory monitor)))))) + (defun cj/recording-get-devices () "Get or auto-detect audio devices. Returns (mic-device . system-device) or nil on error." @@ -78,9 +232,14 @@ Returns (mic-device . system-device) or nil on error." (unless cj/recording-system-device (setq cj/recording-system-device (cj/recording-detect-system-device))) - ;; Validate devices + ;; If auto-detection failed, prompt user to select (unless (and cj/recording-mic-device cj/recording-system-device) - (user-error "Could not detect audio devices. Set cj/recording-mic-device and cj/recording-system-device manually")) + (when (y-or-n-p "Could not auto-detect audio devices. Select manually? ") + (cj/recording-select-devices))) + + ;; Final validation + (unless (and cj/recording-mic-device cj/recording-system-device) + (user-error "Audio devices not configured. Run M-x cj/recording-select-devices")) (cons cj/recording-mic-device cj/recording-system-device)) @@ -111,7 +270,7 @@ Otherwise use the default location in `audio-recordings-dir'." (cj/ffmpeg-record-audio location))) (defun cj/ffmpeg-record-video (directory) - "Start an ffmpeg video recording. Save output to DIRECTORY." + "Start an ffmpeg video recording. Save output to DIRECTORY." (cj/recording-check-ffmpeg) (unless cj/video-recording-ffmpeg-process (let* ((devices (cj/recording-get-devices)) @@ -144,7 +303,7 @@ Otherwise use the default location in `audio-recordings-dir'." filename cj/recording-mic-boost cj/recording-system-volume)))) (defun cj/ffmpeg-record-audio (directory) - "Start an ffmpeg audio recording. Save output to DIRECTORY." + "Start an ffmpeg audio recording. Save output to DIRECTORY." (cj/recording-check-ffmpeg) (unless cj/audio-recording-ffmpeg-process (let* ((devices (cj/recording-get-devices)) @@ -152,16 +311,16 @@ Otherwise use the default location in `audio-recordings-dir'." (system-device (cdr devices)) (location (expand-file-name directory)) (name (format-time-string "%Y-%m-%d-%H-%M-%S")) - (filename (expand-file-name (concat name ".opus") location)) + (filename (expand-file-name (concat name ".m4a") location)) (ffmpeg-command (format (concat "ffmpeg " "-f pulse -i %s " "-ac 1 " "-f pulse -i %s " - "-ac 2 " - "-filter_complex \"[0:a]volume=%.1f[mic];[1:a]volume=%.1f[sys];[mic][sys]amerge=inputs=2\" " - "-c:a libopus " - "-b:a 96k " + "-ac 1 " + "-filter_complex \"[0:a]volume=%.1f[mic];[1:a]volume=%.1f[sys];[mic][sys]amerge=inputs=2[out];[out]pan=mono|c0=0.5*c0+0.5*c1\" " + "-c:a aac " + "-b:a 64k " "%s") mic-device system-device @@ -222,6 +381,9 @@ Otherwise use the default location in `audio-recordings-dir'." (define-key map (kbd "A") #'cj/audio-recording-stop) (define-key map (kbd "a") #'cj/audio-recording-start) (define-key map (kbd "l") #'cj/recording-adjust-volumes) + (define-key map (kbd "d") #'cj/recording-list-devices) + (define-key map (kbd "s") #'cj/recording-select-devices) + (define-key map (kbd "c") #'cj/recording-quick-setup-for-calls) map) "Keymap for video/audio recording operations.") @@ -234,7 +396,10 @@ Otherwise use the default location in `audio-recordings-dir'." "C-; r V" "stop video" "C-; r a" "start audio" "C-; r A" "stop audio" - "C-; r l" "adjust levels")) + "C-; r l" "adjust levels" + "C-; r d" "list devices" + "C-; r s" "select devices" + "C-; r c" "quick setup for calls")) (provide 'video-audio-recording) ;;; video-audio-recording.el ends here. diff --git a/modules/weather-config.el b/modules/weather-config.el index 31fb1b70..3a30aa17 100644 --- a/modules/weather-config.el +++ b/modules/weather-config.el @@ -10,8 +10,15 @@ ;; ----------------------------------- Wttrin ---------------------------------- +;; Load wttrin from local development directory +(add-to-list 'load-path "/home/cjennings/code/wttrin") + +;; Set debug flag BEFORE loading wttrin (checked at load time) +(setq wttrin-debug nil) + (use-package wttrin - :vc (:url "https://github.com/cjennings/emacs-wttrin" :rev :newest) + ;; Uncomment the next line to use vc-install instead of local directory: + ;; :vc (:url "https://github.com/cjennings/emacs-wttrin" :rev :newest) :defer t :preface ;; dependency for wttrin @@ -21,6 +28,22 @@ ("M-W" . wttrin) :custom (wttrin-unit-system "u") + (wttrin-mode-line-favorite-location "New Orleans, LA") + (wttrin-mode-line-refresh-interval 900) ; 15 minutes + :init + ;; Explicitly autoload the mode function (needed for local dev directory) + (autoload 'wttrin-mode-line-mode "wttrin" "Toggle weather display in mode-line." t) + ;; Enable mode-line widget AFTER Emacs finishes initializing + ;; (url-retrieve async needs full init to work without buffer errors) + (if (daemonp) + ;; Daemon mode: wait for first client to connect + (add-hook 'server-after-make-frame-hook + (lambda () (wttrin-mode-line-mode 1)) + t) ; append to end of hook + ;; Normal Emacs: wait for startup to complete + (add-hook 'after-init-hook + (lambda () (wttrin-mode-line-mode 1)) + t)) ; append to end of hook :config (setq wttrin-default-locations '( "New Orleans, LA" diff --git a/scripts/assemblyai-transcribe b/scripts/assemblyai-transcribe new file mode 100755 index 00000000..22cbf538 --- /dev/null +++ b/scripts/assemblyai-transcribe @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# assemblyai-transcribe - Transcribe audio files using AssemblyAI API with speaker diarization +# Usage: assemblyai-transcribe <audio-file> [language] +# +# Requires: ASSEMBLYAI_API_KEY environment variable +# Language: en, es, fr, etc. (default: en) +# Features: Speaker diarization (up to 50 speakers) + +set -euo pipefail + +# Parse arguments +AUDIO="${1:-}" +LANG="${2:-en}" + +# Validate arguments +if [[ -z "$AUDIO" ]]; then + echo "Usage: assemblyai-transcribe <audio-file> [language]" >&2 + echo "Example: assemblyai-transcribe meeting.m4a en" >&2 + exit 1 +fi + +if [[ ! -f "$AUDIO" ]]; then + echo "Error: Audio file not found: $AUDIO" >&2 + exit 1 +fi + +# Check API key is set +if [[ -z "${ASSEMBLYAI_API_KEY:-}" ]]; then + echo "Error: ASSEMBLYAI_API_KEY environment variable not set" >&2 + exit 1 +fi + +# Check curl is available +if ! command -v curl &> /dev/null; then + echo "Error: curl command not found" >&2 + exit 1 +fi + +# Check jq is available (for JSON parsing) +if ! command -v jq &> /dev/null; then + echo "Error: jq command not found (required for JSON parsing)" >&2 + echo "Install with: sudo pacman -S jq" >&2 + exit 1 +fi + +API_BASE="https://api.assemblyai.com/v2" + +# Step 1: Upload audio file +echo "Uploading audio file..." >&2 +UPLOAD_RESPONSE=$(curl -s -X POST "${API_BASE}/upload" \ + -H "Authorization: ${ASSEMBLYAI_API_KEY}" \ + --data-binary "@${AUDIO}") + +UPLOAD_URL=$(echo "$UPLOAD_RESPONSE" | jq -r '.upload_url') + +if [[ -z "$UPLOAD_URL" ]] || [[ "$UPLOAD_URL" == "null" ]]; then + echo "Error: Failed to upload audio file" >&2 + echo "$UPLOAD_RESPONSE" >&2 + exit 1 +fi + +echo "Upload complete. Submitting transcription..." >&2 + +# Step 2: Submit transcription request with speaker labels +TRANSCRIPT_REQUEST=$(cat <<EOF +{ + "audio_url": "${UPLOAD_URL}", + "language_code": "${LANG}", + "speech_model": "universal", + "speaker_labels": true +} +EOF +) + +TRANSCRIPT_RESPONSE=$(curl -s -X POST "${API_BASE}/transcript" \ + -H "Authorization: ${ASSEMBLYAI_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$TRANSCRIPT_REQUEST") + +TRANSCRIPT_ID=$(echo "$TRANSCRIPT_RESPONSE" | jq -r '.id') + +if [[ -z "$TRANSCRIPT_ID" ]] || [[ "$TRANSCRIPT_ID" == "null" ]]; then + echo "Error: Failed to submit transcription" >&2 + echo "$TRANSCRIPT_RESPONSE" >&2 + exit 1 +fi + +echo "Transcription job submitted (ID: ${TRANSCRIPT_ID})" >&2 +echo "Waiting for completion..." >&2 + +# Step 3: Poll for completion +STATUS="queued" +POLL_INTERVAL=3 +MAX_WAIT=1800 # 30 minutes +ELAPSED=0 + +while [[ "$STATUS" == "queued" ]] || [[ "$STATUS" == "processing" ]]; do + if [[ $ELAPSED -ge $MAX_WAIT ]]; then + echo "Error: Transcription timed out after ${MAX_WAIT} seconds" >&2 + exit 1 + fi + + sleep $POLL_INTERVAL + ELAPSED=$((ELAPSED + POLL_INTERVAL)) + + RESULT=$(curl -s -X GET "${API_BASE}/transcript/${TRANSCRIPT_ID}" \ + -H "Authorization: ${ASSEMBLYAI_API_KEY}") + + STATUS=$(echo "$RESULT" | jq -r '.status') + + if [[ "$STATUS" == "processing" ]]; then + echo "Processing... (${ELAPSED}s elapsed)" >&2 + fi +done + +# Check if transcription failed +if [[ "$STATUS" != "completed" ]]; then + ERROR_MSG=$(echo "$RESULT" | jq -r '.error // "Unknown error"') + echo "Error: Transcription failed with status: ${STATUS}" >&2 + echo "Error message: ${ERROR_MSG}" >&2 + exit 1 +fi + +echo "Transcription complete! (${ELAPSED}s total)" >&2 + +# Step 4: Format output with speaker labels +# Extract utterances and format as "Speaker A: text" +echo "$RESULT" | jq -r ' + if .utterances then + .utterances[] | "Speaker \(.speaker): \(.text)" + else + .text + end +' diff --git a/scripts/delete-elisp-compiled-files.sh b/scripts/delete-elisp-compiled-files.sh deleted file mode 100755 index 5976c92b..00000000 --- a/scripts/delete-elisp-compiled-files.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -location=$HOME/.emacs.d/ - -echo "Deleting emacs lisp compiled files (.eln and .elc) from $location..." -find $location -type f \( -name "*.eln" -o -name "*.elc" \) -exec rm -f {} + -echo "Done." diff --git a/scripts/install-whisper.sh b/scripts/install-whisper.sh new file mode 100755 index 00000000..e2ea4ac9 --- /dev/null +++ b/scripts/install-whisper.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Install OpenAI Whisper for transcription on Arch Linux +# Usage: install-whisper.sh [--yes] # --yes for non-interactive mode + +set -euo pipefail + +# Non-interactive mode +ASSUME_YES=false +if [[ "${1:-}" == "--yes" ]] || [[ "${1:-}" == "-y" ]]; then + ASSUME_YES=true +fi + +echo "=== Whisper Installation for Arch Linux ===" +echo + +# Check if running on Arch +if [[ ! -f /etc/arch-release ]]; then + echo "Warning: This script is designed for Arch Linux" + if [[ "$ASSUME_YES" == false ]]; then + read -p "Continue anyway? [y/N] " -n 1 -r + echo + [[ ! $REPLY =~ ^[Yy]$ ]] && exit 1 + else + echo "Continuing anyway (--yes mode)" + fi +fi + +# 1. Install system dependencies +echo "Step 1/3: Installing system dependencies (ffmpeg)..." +if ! command -v ffmpeg &> /dev/null; then + sudo pacman -S --needed ffmpeg + echo "✓ ffmpeg installed" +else + echo "✓ ffmpeg already installed" +fi + +# 2. Check for AUR package first (optional but cleaner) +echo +echo "Step 2/3: Checking for AUR package..." +AUR_INSTALLED=false + +if command -v yay &> /dev/null; then + echo "Found yay. Checking AUR for python-openai-whisper..." + if yay -Ss python-openai-whisper | grep -q 'python-openai-whisper'; then + INSTALL_AUR=false + if [[ "$ASSUME_YES" == true ]]; then + echo "Installing from AUR (--yes mode)" + INSTALL_AUR=true + else + read -p "Install from AUR via yay? [Y/n] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then + INSTALL_AUR=true + fi + fi + + if [[ "$INSTALL_AUR" == true ]]; then + yay -S --needed --noconfirm python-openai-whisper + echo "✓ Installed from AUR" + AUR_INSTALLED=true + fi + else + echo "Package python-openai-whisper not found in AUR" + fi +else + echo "yay not found. Skipping AUR installation." + echo "(Install yay if you prefer AUR packages)" +fi + +# 3. Install via pip if not from AUR +if [[ "$AUR_INSTALLED" == false ]]; then + echo + echo "Step 3/3: Installing openai-whisper via pip..." + pip install --user -U openai-whisper + echo "✓ openai-whisper installed via pip" + echo + echo "Note: Ensure ~/.local/bin is in your PATH" + echo "Add to ~/.bashrc or ~/.zshrc: export PATH=\"\$HOME/.local/bin:\$PATH\"" +fi + +# Verify installation +echo +echo "=== Verifying Installation ===" +if command -v whisper &> /dev/null; then + echo "✓ whisper command found at: $(which whisper)" + whisper --help | head -n 3 + echo + echo "=== Installation Complete! ===" + echo + echo "Models available: tiny, base, small, medium, large" + echo "Recommended: small (good balance of speed/accuracy)" + echo "Model will download automatically on first use." + echo + echo "Test with: whisper your-audio.m4a --model small --language en" +else + echo "✗ Installation failed - whisper command not found" + echo + echo "Troubleshooting:" + echo "1. Ensure ~/.local/bin is in your PATH" + echo "2. Run: source ~/.bashrc (or ~/.zshrc)" + echo "3. Try: python -m whisper --help" + exit 1 +fi diff --git a/scripts/languagetool-flycheck b/scripts/languagetool-flycheck new file mode 100755 index 00000000..ecbc900f --- /dev/null +++ b/scripts/languagetool-flycheck @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +Wrapper for LanguageTool to produce flycheck-compatible output. +Output format: filename:line:column: message +""" + +import json +import sys +import subprocess + +def main(): + if len(sys.argv) < 2: + print("Usage: languagetool-flycheck FILE", file=sys.stderr) + sys.exit(1) + + filename = sys.argv[1] + + # Run languagetool with JSON output + try: + result = subprocess.run( + ['languagetool', '-l', 'en-US', '--json', filename], + capture_output=True, + text=True, + timeout=30 + ) + except subprocess.TimeoutExpired: + print(f"{filename}:1:1: LanguageTool timeout", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"{filename}:1:1: LanguageTool error: {e}", file=sys.stderr) + sys.exit(1) + + # Parse JSON output + try: + # Find the JSON in the output (skip warning lines) + json_output = None + for line in result.stdout.split('\n'): + if line.startswith('{'): + json_output = line + break + + if not json_output: + sys.exit(0) # No errors found + + data = json.loads(json_output) + + # Read file to calculate line numbers from character offsets + with open(filename, 'r', encoding='utf-8') as f: + content = f.read() + + # Convert matches to flycheck format + for match in data.get('matches', []): + offset = match['offset'] + length = match['length'] + message = match['message'] + rule_id = match['rule']['id'] + + # Calculate line and column from offset + line = content[:offset].count('\n') + 1 + line_start = content.rfind('\n', 0, offset) + 1 + column = offset - line_start + 1 + + # Get first suggestion if available + suggestions = match.get('replacements', []) + if suggestions: + suggestion = suggestions[0]['value'] + message = f"{rule_id}: {message} Suggestion: {suggestion}" + else: + message = f"{rule_id}: {message}" + + # Output in flycheck format + print(f"{filename}:{line}:{column}: {message}") + + except json.JSONDecodeError as e: + print(f"{filename}:1:1: Failed to parse LanguageTool JSON: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"{filename}:1:1: Error processing LanguageTool output: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/scripts/local-whisper b/scripts/local-whisper new file mode 100755 index 00000000..b08651c9 --- /dev/null +++ b/scripts/local-whisper @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# local-whisper - Transcribe audio files using locally installed Whisper +# Usage: local-whisper <audio-file> [model] [language] +# +# Models: tiny, base, small, medium, large (default: small) +# Language: en, es, fr, etc. (default: en) + +set -euo pipefail + +# Parse arguments +AUDIO="${1:-}" +MODEL="${2:-small}" +LANG="${3:-en}" + +# Validate arguments +if [[ -z "$AUDIO" ]]; then + echo "Usage: local-whisper <audio-file> [model] [language]" >&2 + echo "Example: local-whisper meeting.m4a small en" >&2 + exit 1 +fi + +if [[ ! -f "$AUDIO" ]]; then + echo "Error: Audio file not found: $AUDIO" >&2 + exit 1 +fi + +# Check whisper is installed +if ! command -v whisper &> /dev/null; then + echo "Error: whisper command not found" >&2 + echo "Install with: ~/.emacs.d/scripts/install-whisper.sh" >&2 + exit 1 +fi + +# Get absolute path to audio file +AUDIO_ABS="$(realpath "$AUDIO")" +AUDIO_DIR="$(dirname "$AUDIO_ABS")" +AUDIO_BASE="$(basename "$AUDIO_ABS")" +AUDIO_NAME="${AUDIO_BASE%.*}" + +# Run whisper +# Note: whisper creates ${AUDIO_NAME}.txt automatically in the output directory +whisper "$AUDIO_ABS" \ + --model "$MODEL" \ + --language "$LANG" \ + --task transcribe \ + --output_format txt \ + --output_dir "$AUDIO_DIR" \ + --verbose False 2>&1 + +# Output file that whisper creates +OUTPUT_FILE="$AUDIO_DIR/$AUDIO_NAME.txt" + +# Return transcript to stdout +if [[ -f "$OUTPUT_FILE" ]]; then + cat "$OUTPUT_FILE" + exit 0 +else + echo "Error: Whisper did not create expected output file: $OUTPUT_FILE" >&2 + exit 1 +fi diff --git a/scripts/oai-transcribe b/scripts/oai-transcribe new file mode 100755 index 00000000..f64a8122 --- /dev/null +++ b/scripts/oai-transcribe @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# oai-transcribe - Transcribe audio files using OpenAI Whisper API +# Usage: oai-transcribe <audio-file> [language] +# +# Requires: OPENAI_API_KEY environment variable +# Language: en, es, fr, etc. (default: en) + +set -euo pipefail + +# Parse arguments +AUDIO="${1:-}" +LANG="${2:-en}" + +# Validate arguments +if [[ -z "$AUDIO" ]]; then + echo "Usage: oai-transcribe <audio-file> [language]" >&2 + echo "Example: oai-transcribe meeting.m4a en" >&2 + exit 1 +fi + +if [[ ! -f "$AUDIO" ]]; then + echo "Error: Audio file not found: $AUDIO" >&2 + exit 1 +fi + +# Check API key is set +if [[ -z "${OPENAI_API_KEY:-}" ]]; then + echo "Error: OPENAI_API_KEY environment variable not set" >&2 + echo "Set with: export OPENAI_API_KEY='sk-...'" >&2 + exit 1 +fi + +# Check curl is available +if ! command -v curl &> /dev/null; then + echo "Error: curl command not found" >&2 + exit 1 +fi + +# Call OpenAI API +curl -s -X POST "https://api.openai.com/v1/audio/transcriptions" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -F "model=whisper-1" \ + -F "response_format=text" \ + -F "language=${LANG}" \ + -F "file=@${AUDIO}" diff --git a/scripts/profile-dotemacs.sh b/scripts/profile-dotemacs.sh deleted file mode 100755 index b31d078b..00000000 --- a/scripts/profile-dotemacs.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash - -# profile-dotemacs.sh -# Craig Jennings <c@cjennings.net> -# a convenience script to load an emacs-lisp file which will -# startup emacs (with or without an early-init) and provide -# benchmark information on the Emacs config. - -EMACS_HOME="$HOME/.emacs.d/" -EARLY_INIT_FILE="$EMACS_HOME/early-init.el" -PROFILE_FILE="$EMACS_HOME/custom/profile-dotemacs.el" - -if [ -f "$EARLY_INIT_FILE" ] -then - emacs -Q --load $PROFILE_FILE --eval "(progn (load-file \"~/.emacs.d/early-init.el\") (profile-dotemacs))" -else - echo "No early init found. Proceeding to benchmark init.el." - emacs -Q --load $PROFILE_FILE --eval "(profile-dotemacs)" -fi - diff --git a/scripts/reset-to-first-launch.sh b/scripts/reset-to-first-launch.sh deleted file mode 100755 index 7bb15ba5..00000000 --- a/scripts/reset-to-first-launch.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/sh -# script for Emacs config testing -# - clears out all but necessary init/config files -# - removes native ad bytecode files. - -rm -rf ~/.cache/org-persist/ -rm -rf ~/.emacs.d/.cache/ -rm -rf ~/.emacs.d/.elfeed-db/ -rm -rf ~/.emacs.d/auto-save-list/ -rm -rf ~/.emacs.d/backups/ -rm -rf ~/.emacs.d/crossword/ -rm -rf ~/.emacs.d/dirvish/ -rm -rf ~/.emacs.d/eln-cache/ -rm -rf ~/.emacs.d/elpa/ -rm -rf ~/.emacs.d/emms/ -rm -rf ~/.emacs.d/emojis/ -rm -rf ~/.emacs.d/erc/ -rm -rf ~/.emacs.d/eshell/ -rm -rf ~/.emacs.d/multisession -rm -rf ~/.emacs.d/nov-places/ -rm -rf ~/.emacs.d/persist/ -rm -rf ~/.emacs.d/quelpa/ -rm -rf ~/.emacs.d/request/ -rm -rf ~/.emacs.d/tramp-auto-save/ -rm -rf ~/.emacs.d/transient/ -rm -rf ~/.emacs.d/tree-sitter/ -rm -rf ~/.emacs.d/url/ -rm -rf ~/.emacs.d/var -rm ~/.emacs.d/.elfeed-db -rm ~/.emacs.d/.emacs-history -rm ~/.emacs.d/.lsp-session* -rm ~/.emacs.d/.org-generic-id-locations -rm ~/.emacs.d/.org-id-locations -rm ~/.emacs.d/.pdf-view-restore -rm ~/.emacs.d/.scratch -rm ~/.emacs.d/forge-database.sqlite -rm ~/.emacs.d/history -rm ~/.emacs.d/nov-places -rm ~/.emacs.d/org-roam.db -rm ~/.emacs.d/pomm -rm ~/.emacs.d/projectile-bookmarks.eld -rm ~/.emacs.d/projects -rm ~/.emacs.d/recentf -rm ~/.emacs.d/tramp-connection-history -rm ~/sync/org/emacs-theme.persist - -find ~/.emacs.d -name "*.eln" -type f -delete -find ~/.emacs.d -name "*.elc" -type f -delete diff --git a/scripts/uninstall-whisper.sh b/scripts/uninstall-whisper.sh new file mode 100755 index 00000000..e46c6ebc --- /dev/null +++ b/scripts/uninstall-whisper.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Uninstall OpenAI Whisper + +set -euo pipefail + +echo "=== Whisper Uninstallation ===" +echo + +REMOVED=false + +# Check if installed via AUR +if command -v yay &> /dev/null; then + if yay -Qi python-openai-whisper &> /dev/null 2>&1; then + echo "Detected AUR installation (python-openai-whisper)" + read -p "Remove via yay? [Y/n] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then + yay -R python-openai-whisper + echo "✓ Removed via AUR" + REMOVED=true + fi + fi +fi + +# Check if installed via pip +if pip list 2>/dev/null | grep -q openai-whisper; then + echo "Detected pip installation (openai-whisper)" + read -p "Remove via pip? [Y/n] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then + pip uninstall -y openai-whisper + echo "✓ Removed via pip" + REMOVED=true + fi +fi + +if [[ "$REMOVED" == false ]]; then + echo "No whisper installation found (checked AUR and pip)" +fi + +# Ask about ffmpeg +echo +read -p "Remove ffmpeg? (may be used by other apps) [y/N] " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + sudo pacman -R ffmpeg + echo "✓ Removed ffmpeg" +fi + +# Ask about model cache +CACHE_DIR="$HOME/.cache/whisper" +if [[ -d "$CACHE_DIR" ]]; then + echo + echo "Whisper models are cached in: $CACHE_DIR" + du -sh "$CACHE_DIR" 2>/dev/null || echo "Size: unknown" + read -p "Delete cached models? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + rm -rf "$CACHE_DIR" + echo "✓ Deleted model cache" + fi +fi + +echo +echo "=== Uninstallation Complete ===" diff --git a/tests/fixtures/grammar-correct.txt b/tests/fixtures/grammar-correct.txt new file mode 100644 index 00000000..bea335e8 --- /dev/null +++ b/tests/fixtures/grammar-correct.txt @@ -0,0 +1,5 @@ +This is a well-written sentence with no grammar errors. + +The quick brown fox jumps over the lazy dog. + +Everything here follows standard English grammar rules. diff --git a/tests/fixtures/grammar-errors-basic.txt b/tests/fixtures/grammar-errors-basic.txt new file mode 100644 index 00000000..c2f72c12 --- /dev/null +++ b/tests/fixtures/grammar-errors-basic.txt @@ -0,0 +1,7 @@ +This are a test of basic grammar errors. + +I could of done better with this sentence. + +Their going to the store to buy there groceries. + +The dog wagged it's tail happily. diff --git a/tests/fixtures/grammar-errors-punctuation.txt b/tests/fixtures/grammar-errors-punctuation.txt new file mode 100644 index 00000000..37de646a --- /dev/null +++ b/tests/fixtures/grammar-errors-punctuation.txt @@ -0,0 +1,5 @@ +This sentence is missing punctuation at the end + +Multiple spaces between words should be detected. + +A sentence with,incorrect comma,placement and usage. diff --git a/tests/fixtures/pactl-output-empty.txt b/tests/fixtures/pactl-output-empty.txt new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/tests/fixtures/pactl-output-empty.txt diff --git a/tests/fixtures/pactl-output-inputs-only.txt b/tests/fixtures/pactl-output-inputs-only.txt new file mode 100644 index 00000000..1840b37c --- /dev/null +++ b/tests/fixtures/pactl-output-inputs-only.txt @@ -0,0 +1,3 @@ +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED +79 bluez_input.00:1B:66:C0:91:6D PipeWire float32le 1ch 48000Hz SUSPENDED +100 alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback PipeWire s16le 1ch 16000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-malformed.txt b/tests/fixtures/pactl-output-malformed.txt new file mode 100644 index 00000000..a37b8dd6 --- /dev/null +++ b/tests/fixtures/pactl-output-malformed.txt @@ -0,0 +1,4 @@ +This is not valid pactl output +Some random text +50 incomplete-line-missing-fields +Another bad line with only two tabs diff --git a/tests/fixtures/pactl-output-monitors-only.txt b/tests/fixtures/pactl-output-monitors-only.txt new file mode 100644 index 00000000..be29ebe8 --- /dev/null +++ b/tests/fixtures/pactl-output-monitors-only.txt @@ -0,0 +1,3 @@ +49 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED +81 bluez_output.00_1B_66_C0_91_6D.1.monitor PipeWire s24le 2ch 48000Hz RUNNING +99 alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor PipeWire s16le 2ch 48000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-normal.txt b/tests/fixtures/pactl-output-normal.txt new file mode 100644 index 00000000..6d8d955b --- /dev/null +++ b/tests/fixtures/pactl-output-normal.txt @@ -0,0 +1,6 @@ +49 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED +79 bluez_input.00:1B:66:C0:91:6D PipeWire float32le 1ch 48000Hz SUSPENDED +81 bluez_output.00_1B_66_C0_91_6D.1.monitor PipeWire s24le 2ch 48000Hz SUSPENDED +99 alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor PipeWire s16le 2ch 48000Hz SUSPENDED +100 alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback PipeWire s16le 1ch 16000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-single.txt b/tests/fixtures/pactl-output-single.txt new file mode 100644 index 00000000..d1d1c254 --- /dev/null +++ b/tests/fixtures/pactl-output-single.txt @@ -0,0 +1 @@ +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED diff --git a/tests/test-custom-buffer-file-copy-to-bottom-of-buffer.el b/tests/test-custom-buffer-file-copy-to-bottom-of-buffer.el new file mode 100644 index 00000000..0c41761e --- /dev/null +++ b/tests/test-custom-buffer-file-copy-to-bottom-of-buffer.el @@ -0,0 +1,187 @@ +;;; test-custom-buffer-file-copy-to-bottom-of-buffer.el --- Tests for cj/copy-to-bottom-of-buffer -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/copy-to-bottom-of-buffer function from custom-buffer-file.el +;; +;; This function copies all text from point to the end of the current buffer +;; to the kill ring without modifying the buffer. + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-copy-to-bottom-setup () + "Set up test environment." + (setq kill-ring nil)) + +(defun test-copy-to-bottom-teardown () + "Clean up test environment." + (setq kill-ring nil)) + +;;; Normal Cases + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-normal-point-in-middle-copies-to-end () + "Should copy from point to end when point in middle." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (forward-line 1) ; Point at start of "Line 2" + (let ((original-content (buffer-string))) + (cj/copy-to-bottom-of-buffer) + ;; Buffer should be unchanged + (should (equal (buffer-string) original-content)) + ;; Kill ring should contain from point to end + (should (equal (car kill-ring) "Line 2\nLine 3")))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-normal-single-line-copies-partial () + "Should copy partial line content from middle of line." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello World") + (goto-char (point-min)) + (forward-char 6) ; Point after "Hello " + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Hello World")) + (should (equal (car kill-ring) "World"))) + (test-copy-to-bottom-teardown))) + +;;; Boundary Cases + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-point-at-beginning-copies-all () + "Should copy entire buffer when point at beginning." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Line 1\nLine 2\nLine 3")) + (should (equal (car kill-ring) "Line 1\nLine 2\nLine 3"))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-point-at-end-copies-empty () + "Should copy empty string when point at end." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-max)) + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Line 1\nLine 2\nLine 3")) + (should (equal (car kill-ring) ""))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-empty-buffer-copies-empty () + "Should copy empty string in empty buffer." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "")) + (should (equal (car kill-ring) ""))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-point-second-to-last-char-copies-one () + "Should copy last character when point at second-to-last." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello") + (goto-char (1- (point-max))) ; Before 'o' + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Hello")) + (should (equal (car kill-ring) "o"))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-unicode-content-copies-correctly () + "Should handle unicode content correctly." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello 👋\nمرحبا\nWorld") + (goto-char (point-min)) + (forward-line 1) + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Hello 👋\nمرحبا\nWorld")) + (should (equal (car kill-ring) "مرحبا\nWorld"))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-narrowed-buffer-respects-narrowing () + "Should respect narrowing and only copy within narrowed region." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3\nLine 4") + (goto-char (point-min)) + (forward-line 1) + (let ((start (point))) + (forward-line 2) + (narrow-to-region start (point)) + (goto-char (point-min)) + (forward-line 1) ; Point at "Line 3" + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Line 2\nLine 3\n")) + (should (equal (car kill-ring) "Line 3\n")))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-whitespace-only-copies-whitespace () + "Should copy whitespace-only content." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert " \n\t\t\n ") + (goto-char (point-min)) + (forward-char 4) ; After first newline + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) " \n\t\t\n ")) + (should (equal (car kill-ring) "\t\t\n "))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-single-character-copies-char () + "Should copy single character buffer." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "x") + (goto-char (point-min)) + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "x")) + (should (equal (car kill-ring) "x"))) + (test-copy-to-bottom-teardown))) + +;;; Error Cases + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-error-read-only-buffer-succeeds () + "Should work in read-only buffer since it doesn't modify content." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Read-only content") + (read-only-mode 1) + (goto-char (point-min)) + (cj/copy-to-bottom-of-buffer) + (should (equal (car kill-ring) "Read-only content"))) + (test-copy-to-bottom-teardown))) + +(provide 'test-custom-buffer-file-copy-to-bottom-of-buffer) +;;; test-custom-buffer-file-copy-to-bottom-of-buffer.el ends here diff --git a/tests/test-custom-buffer-file-copy-to-top-of-buffer.el b/tests/test-custom-buffer-file-copy-to-top-of-buffer.el new file mode 100644 index 00000000..0f09f26d --- /dev/null +++ b/tests/test-custom-buffer-file-copy-to-top-of-buffer.el @@ -0,0 +1,186 @@ +;;; test-custom-buffer-file-copy-to-top-of-buffer.el --- Tests for cj/copy-to-top-of-buffer -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/copy-to-top-of-buffer function from custom-buffer-file.el +;; +;; This function copies all text from the beginning of the buffer to point +;; to the kill ring without modifying the buffer. + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-copy-to-top-setup () + "Set up test environment." + (setq kill-ring nil)) + +(defun test-copy-to-top-teardown () + "Clean up test environment." + (setq kill-ring nil)) + +;;; Normal Cases + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-normal-point-in-middle-copies-from-beginning () + "Should copy from beginning to point when point in middle." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (forward-line 2) ; Point at start of "Line 3" + (let ((original-content (buffer-string))) + (cj/copy-to-top-of-buffer) + ;; Buffer should be unchanged + (should (equal (buffer-string) original-content)) + ;; Kill ring should contain from beginning to point + (should (equal (car kill-ring) "Line 1\nLine 2\n")))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-normal-single-line-copies-partial () + "Should copy partial line content from beginning to middle of line." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello World") + (goto-char (point-min)) + (forward-char 5) ; Point after "Hello" + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Hello World")) + (should (equal (car kill-ring) "Hello"))) + (test-copy-to-top-teardown))) + +;;; Boundary Cases + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-point-at-end-copies-all () + "Should copy entire buffer when point at end." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-max)) + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Line 1\nLine 2\nLine 3")) + (should (equal (car kill-ring) "Line 1\nLine 2\nLine 3"))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-point-at-beginning-copies-empty () + "Should copy empty string when point at beginning." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Line 1\nLine 2\nLine 3")) + (should (equal (car kill-ring) ""))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-empty-buffer-copies-empty () + "Should copy empty string in empty buffer." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "")) + (should (equal (car kill-ring) ""))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-point-at-second-char-copies-one () + "Should copy first character when point at second character." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello") + (goto-char (1+ (point-min))) ; After 'H' + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Hello")) + (should (equal (car kill-ring) "H"))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-unicode-content-copies-correctly () + "Should handle unicode content correctly." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello 👋\nمرحبا\nWorld") + (goto-char (point-min)) + (forward-line 2) ; Point at start of "World" + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Hello 👋\nمرحبا\nWorld")) + (should (equal (car kill-ring) "Hello 👋\nمرحبا\n"))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-narrowed-buffer-respects-narrowing () + "Should respect narrowing and only copy within narrowed region." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3\nLine 4") + (goto-char (point-min)) + (forward-line 1) + (let ((start (point))) + (forward-line 2) + (narrow-to-region start (point)) + (goto-char (point-max)) ; Point at end of narrowed region + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Line 2\nLine 3\n")) + (should (equal (car kill-ring) "Line 2\nLine 3\n")))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-whitespace-only-copies-whitespace () + "Should copy whitespace-only content." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert " \n\t\t\n ") + (goto-char (point-min)) + (forward-char 7) ; After second newline + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) " \n\t\t\n ")) + (should (equal (car kill-ring) " \n\t\t\n"))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-single-character-copies-char () + "Should copy single character buffer." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "x") + (goto-char (point-max)) + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "x")) + (should (equal (car kill-ring) "x"))) + (test-copy-to-top-teardown))) + +;;; Error Cases + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-error-read-only-buffer-succeeds () + "Should work in read-only buffer since it doesn't modify content." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Read-only content") + (goto-char (point-max)) + (read-only-mode 1) + (cj/copy-to-top-of-buffer) + (should (equal (car kill-ring) "Read-only content"))) + (test-copy-to-top-teardown))) + +(provide 'test-custom-buffer-file-copy-to-top-of-buffer) +;;; test-custom-buffer-file-copy-to-top-of-buffer.el ends here diff --git a/tests/test-flycheck-languagetool-setup.el b/tests/test-flycheck-languagetool-setup.el new file mode 100644 index 00000000..a719e822 --- /dev/null +++ b/tests/test-flycheck-languagetool-setup.el @@ -0,0 +1,71 @@ +;;; test-flycheck-languagetool-setup.el --- Unit tests for LanguageTool setup -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests verifying LanguageTool installation and wrapper script setup. +;; Focus: Testing OUR code (wrapper script, file setup), not flycheck internals. +;; +;; We trust that flycheck works correctly (it's an external framework). +;; These tests verify: +;; - LanguageTool is installed and accessible +;; - Our wrapper script exists, is executable, and has correct structure +;; - Python 3 dependency is available +;; +;; Categories: Normal (installation checks), Boundary (script structure), Error (missing dependencies) + +;;; Code: + +(require 'ert) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-flycheck-languagetool-setup-normal-wrapper-exists () + "Test that languagetool-flycheck wrapper script exists." + (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (should (file-exists-p wrapper-path)))) + +(ert-deftest test-flycheck-languagetool-setup-normal-wrapper-executable () + "Test that languagetool-flycheck wrapper script is executable." + (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (should (file-executable-p wrapper-path)))) + +(ert-deftest test-flycheck-languagetool-setup-normal-languagetool-installed () + "Test that languagetool command is available in PATH." + (should (executable-find "languagetool"))) + +(ert-deftest test-flycheck-languagetool-setup-normal-python3-available () + "Test that python3 is available for wrapper script." + (should (executable-find "python3"))) + + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-flycheck-languagetool-setup-boundary-wrapper-script-format () + "Test that wrapper script has correct shebang and structure." + (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (with-temp-buffer + (insert-file-contents wrapper-path) + (goto-char (point-min)) + ;; Check shebang + (should (looking-at "#!/usr/bin/env python3")) + ;; Check it contains required imports + (should (search-forward "import json" nil t)) + (should (search-forward "import subprocess" nil t))))) + +;; ----------------------------- Error Cases ----------------------------------- + +(ert-deftest test-flycheck-languagetool-setup-error-missing-file-argument () + "Test that wrapper script requires file argument. +When called without arguments, wrapper should exit with error." + (let* ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")) + (exit-code nil)) + (with-temp-buffer + (setq exit-code (call-process wrapper nil t nil)) + ;; Should exit with non-zero status when no file provided + (should-not (= 0 exit-code)) + ;; Should print usage message to stderr (captured in buffer) + (goto-char (point-min)) + (should (or (search-forward "Usage:" nil t) + (search-forward "FILE" nil t)))))) + +(provide 'test-flycheck-languagetool-setup) +;;; test-flycheck-languagetool-setup.el ends here diff --git a/tests/test-integration-grammar-checking.el b/tests/test-integration-grammar-checking.el new file mode 100644 index 00000000..8948c17a --- /dev/null +++ b/tests/test-integration-grammar-checking.el @@ -0,0 +1,190 @@ +;;; test-integration-grammar-checking.el --- Integration tests for grammar checking -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests for the LanguageTool wrapper script with real grammar checking. +;; Tests the integration: test fixture → wrapper script → LanguageTool → formatted output +;; +;; Components integrated: +;; - scripts/languagetool-flycheck (our wrapper script) +;; - languagetool command (external grammar checker) +;; - Test fixtures with known grammar errors +;; - Output formatting (JSON → flycheck format) +;; +;; Focus: Testing OUR integration code (wrapper), not flycheck framework. +;; We trust that flycheck works; we test that our wrapper produces correct output. +;; +;; Categories: Normal workflow, Boundary cases, Error handling + +;;; Code: + +(require 'ert) + +;; ----------------------------- Test Helpers ---------------------------------- + +(defun test-integration-grammar--fixture-path (filename) + "Return absolute path to test fixture FILENAME." + (expand-file-name (concat "tests/fixtures/" filename) + user-emacs-directory)) + +(defun test-integration-grammar--wrapper-output (file-path) + "Run languagetool-flycheck wrapper directly on FILE-PATH. +Returns output as string." + (let ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (with-temp-buffer + (call-process wrapper nil t nil file-path) + (buffer-string)))) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-integration-grammar-checking-normal-wrapper-detects-errors () + "Test that wrapper script detects grammar errors in fixture. + +Components integrated: +- scripts/languagetool-flycheck (wrapper script) +- languagetool command (external checker) +- Test fixture with known errors" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture))) + ;; Should detect "This are" error + (should (string-match-p "PLURAL_VERB_AFTER_THIS\\|This are" output)) + ;; Should detect "could of" error + (should (string-match-p "COULD_OF\\|could of" output)) + ;; Output should be in flycheck format (filename:line:column:) + (should (string-match-p "grammar-errors-basic\\.txt:[0-9]+:[0-9]+:" output)))) + +(ert-deftest test-integration-grammar-checking-normal-wrapper-format () + "Test that wrapper outputs flycheck-compatible format. + +Components integrated: +- scripts/languagetool-flycheck (output formatting) +- languagetool command (JSON parsing)" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture)) + (lines (split-string output "\n" t))) + (dolist (line lines) + ;; Each line should match: filename:line:column: message + (should (string-match "^[^:]+:[0-9]+:[0-9]+: " line))))) + +(ert-deftest test-integration-grammar-checking-normal-correct-text-no-errors () + "Test that grammatically correct text produces no errors. + +Components integrated: +- scripts/languagetool-flycheck (wrapper script) +- languagetool command (validation) +- Test fixture with correct grammar" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-correct.txt")) + (output (test-integration-grammar--wrapper-output fixture))) + ;; Correct grammar should produce no output (or only whitespace) + (should (or (string-empty-p (string-trim output)) + (= 0 (length (string-trim output))))))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-integration-grammar-checking-boundary-empty-file () + "Test that empty file produces no errors. + +Components integrated: +- scripts/languagetool-flycheck (empty input handling) +- languagetool command" + (let ((temp-file (make-temp-file "grammar-test-" nil ".txt"))) + (unwind-protect + (let ((output (test-integration-grammar--wrapper-output temp-file))) + (should (or (string-empty-p (string-trim output)) + (= 0 (length (string-trim output)))))) + (delete-file temp-file)))) + +(ert-deftest test-integration-grammar-checking-boundary-single-word () + "Test that single word file produces no errors. + +Components integrated: +- scripts/languagetool-flycheck (minimal input) +- languagetool command" + (let ((temp-file (make-temp-file "grammar-test-" nil ".txt"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "Hello")) + (let ((output (test-integration-grammar--wrapper-output temp-file))) + ;; Single word might produce no errors or might flag as incomplete sentence + ;; Just verify it doesn't crash + (should (stringp output)))) + (delete-file temp-file)))) + +(ert-deftest test-integration-grammar-checking-boundary-multiple-paragraphs () + "Test that file with multiple paragraphs is checked completely. + +Components integrated: +- scripts/languagetool-flycheck (multi-paragraph handling) +- languagetool command (full file processing)" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture)) + (lines (split-string output "\n" t))) + ;; Should detect errors in multiple lines + ;; Check that we have multiple error reports with different line numbers + (let ((line-numbers '())) + (dolist (line lines) + (when (string-match ":[0-9]+:" line) + (let ((line-num (string-to-number + (nth 1 (split-string line ":"))))) + (push line-num line-numbers)))) + ;; Should have errors from multiple lines + (should (> (length (delete-dups line-numbers)) 1))))) + +;; ----------------------------- Error Cases ----------------------------------- + +(ert-deftest test-integration-grammar-checking-error-nonexistent-file () + "Test that wrapper handles nonexistent file with error. + +Components integrated: +- scripts/languagetool-flycheck (error handling) +- File system (missing file) +- Python exception handling" + (let* ((nonexistent "/tmp/this-file-does-not-exist-12345.txt") + (wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")) + (exit-code nil) + (output nil)) + (with-temp-buffer + (setq exit-code (call-process wrapper nil t nil nonexistent)) + (setq output (buffer-string))) + ;; LanguageTool/Python should handle the error + ;; Check that we get output (error message or error in flycheck format) + (should (stringp output)) + ;; Output should contain some indication of the error (filename or error marker) + (should (or (string-match-p nonexistent output) + (string-match-p "error" output) + (string-match-p "Error" output) + ;; Or it might report no errors for a nonexistent file + (string-empty-p (string-trim output)))))) + +(ert-deftest test-integration-grammar-checking-error-no-file-argument () + "Test that wrapper requires file argument. + +Components integrated: +- scripts/languagetool-flycheck (argument validation)" + (let* ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")) + (exit-code nil)) + (with-temp-buffer + (setq exit-code (call-process wrapper nil t nil)) + ;; Should exit with non-zero status when no file provided + (should-not (= 0 exit-code))))) + +;; ----------------------------- Integration with Real Files ------------------- + +(ert-deftest test-integration-grammar-checking-integration-comprehensive-errors () + "Test that wrapper catches multiple types of grammar errors in one file. + +Components integrated: +- scripts/languagetool-flycheck (our wrapper) +- languagetool command (comprehensive checking) +- Test fixture with various error types" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture)) + (lines (split-string output "\n" t))) + ;; Should detect multiple errors (at least 3-4 in the fixture) + (should (>= (length lines) 3)) + ;; All lines should be properly formatted + (dolist (line lines) + (should (string-match "^[^:]+:[0-9]+:[0-9]+: " line))))) + +(provide 'test-integration-grammar-checking) +;;; test-integration-grammar-checking.el ends here diff --git a/tests/test-integration-recording-device-workflow.el b/tests/test-integration-recording-device-workflow.el new file mode 100644 index 00000000..ba92d700 --- /dev/null +++ b/tests/test-integration-recording-device-workflow.el @@ -0,0 +1,232 @@ +;;; test-integration-recording-device-workflow.el --- Integration tests for recording device workflow -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests covering the complete device detection and grouping workflow. +;; +;; This tests the full pipeline from raw pactl output through parsing, grouping, +;; and friendly name assignment. The workflow enables users to select audio devices +;; for recording calls/meetings. +;; +;; Components integrated: +;; - cj/recording--parse-pactl-output (parse raw pactl output into structured data) +;; - cj/recording-parse-sources (shell command wrapper) +;; - cj/recording-group-devices-by-hardware (group inputs/monitors by device) +;; - cj/recording-friendly-state (convert technical state names) +;; - Bluetooth MAC address normalization (colons → underscores) +;; - Device name pattern matching (USB, PCI, Bluetooth) +;; - Friendly name assignment (user-facing device names) +;; +;; Critical integration points: +;; - Parse output must produce data that group-devices can process +;; - Bluetooth MAC normalization must work across parse→group boundary +;; - Incomplete devices (only mic OR only monitor) must be filtered +;; - Friendly names must correctly identify device types + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases - Complete Workflow + +(ert-deftest test-integration-recording-device-workflow-parse-to-group-all-devices () + "Test complete workflow from pactl output to grouped devices. + +When pactl output contains all three device types (built-in, USB, Bluetooth), +the workflow should parse, group, and assign friendly names to all devices. + +Components integrated: +- cj/recording--parse-pactl-output (parsing) +- cj/recording-group-devices-by-hardware (grouping + MAC normalization) +- Device pattern matching (USB/PCI/Bluetooth detection) +- Friendly name assignment + +Validates: +- All three device types are detected +- Bluetooth MAC addresses normalized (colons → underscores) +- Each device has both mic and monitor +- Friendly names correctly assigned +- Complete data flow: raw output → parsed list → grouped pairs" + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + ;; Test parse step + (let ((parsed (cj/recording-parse-sources))) + (should (= 6 (length parsed))) + + ;; Test group step (receives parsed data) + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 3 (length grouped))) + + ;; Validate built-in device + (let ((built-in (assoc "Built-in Laptop Audio" grouped))) + (should built-in) + (should (string-prefix-p "alsa_input.pci" (cadr built-in))) + (should (string-prefix-p "alsa_output.pci" (cddr built-in)))) + + ;; Validate USB device + (let ((usb (assoc "Jabra SPEAK 510 USB" grouped))) + (should usb) + (should (string-match-p "Jabra" (cadr usb))) + (should (string-match-p "Jabra" (cddr usb)))) + + ;; Validate Bluetooth device (CRITICAL: MAC normalization) + (let ((bluetooth (assoc "Bluetooth Headset" grouped))) + (should bluetooth) + ;; Input has colons + (should (string-match-p "00:1B:66:C0:91:6D" (cadr bluetooth))) + ;; Output has underscores + (should (string-match-p "00_1B_66_C0_91_6D" (cddr bluetooth))) + ;; But they're grouped together! + (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth))) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth))))))))) + +(ert-deftest test-integration-recording-device-workflow-friendly-states-in-list () + "Test that friendly state names appear in device list output. + +When listing devices, technical state names (SUSPENDED, RUNNING) should be +converted to friendly names (Ready, Active) for better UX. + +Components integrated: +- cj/recording-parse-sources (parsing with state) +- cj/recording-friendly-state (state name conversion) + +Validates: +- SUSPENDED → Ready +- RUNNING → Active +- State conversion works across the parse workflow" + (let ((output (concat + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Verify states are parsed correctly + (should (equal "SUSPENDED" (nth 2 (nth 0 parsed)))) + (should (equal "RUNNING" (nth 2 (nth 1 parsed)))) + + ;; Verify friendly conversion works + (should (equal "Ready" (cj/recording-friendly-state (nth 2 (nth 0 parsed))))) + (should (equal "Active" (cj/recording-friendly-state (nth 2 (nth 1 parsed))))))))) + +;;; Boundary Cases - Incomplete Devices + +(ert-deftest test-integration-recording-device-workflow-incomplete-devices-filtered () + "Test that devices with only mic OR only monitor are filtered out. + +For call recording, we need BOTH mic and monitor from the same device. +Incomplete devices should not appear in the grouped output. + +Components integrated: +- cj/recording-parse-sources (parsing all devices) +- cj/recording-group-devices-by-hardware (filtering incomplete pairs) + +Validates: +- Device with only mic is filtered +- Device with only monitor is filtered +- Only complete devices (both mic and monitor) are returned +- Filtering happens at group stage, not parse stage" + (let ((output (concat + ;; Complete device + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + ;; Incomplete: USB mic with no monitor + "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n" + ;; Incomplete: Bluetooth monitor with no mic + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + ;; Parse sees all 4 devices + (let ((parsed (cj/recording-parse-sources))) + (should (= 4 (length parsed))) + + ;; Group returns only 1 complete device + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 1 (length grouped))) + (should (equal "Built-in Laptop Audio" (caar grouped)))))))) + +;;; Edge Cases - Bluetooth MAC Normalization + +(ert-deftest test-integration-recording-device-workflow-bluetooth-mac-variations () + "Test Bluetooth MAC normalization with different formats. + +Bluetooth devices use colons in input names but underscores in output names. +The grouping must normalize these to match devices correctly. + +Components integrated: +- cj/recording-parse-sources (preserves original MAC format) +- cj/recording-group-devices-by-hardware (normalizes MAC for matching) +- Base name extraction (regex patterns) +- MAC address transformation (underscores → colons) + +Validates: +- Input with colons (bluez_input.AA:BB:CC:DD:EE:FF) parsed correctly +- Output with underscores (bluez_output.AA_BB_CC_DD_EE_FF) parsed correctly +- Normalization happens during grouping +- Devices paired despite format difference +- Original device names preserved (not mutated)" + (let ((output (concat + "79\tbluez_input.11:22:33:44:55:66\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.11_22_33_44_55_66.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Original formats preserved in parse + (should (string-match-p "11:22:33" (caar parsed))) + (should (string-match-p "11_22_33" (caadr parsed))) + + ;; But grouping matches them + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 1 (length grouped))) + (should (equal "Bluetooth Headset" (caar grouped))) + ;; Original names preserved + (should (equal "bluez_input.11:22:33:44:55:66" (cadar grouped))) + (should (equal "bluez_output.11_22_33_44_55_66.1.monitor" (cddar grouped)))))))) + +;;; Error Cases - Malformed Data + +(ert-deftest test-integration-recording-device-workflow-malformed-output-handled () + "Test that malformed pactl output is handled gracefully. + +When pactl output is malformed or unparseable, the workflow should not crash. +It should return empty results at appropriate stages. + +Components integrated: +- cj/recording--parse-pactl-output (malformed line handling) +- cj/recording-group-devices-by-hardware (empty input handling) + +Validates: +- Malformed lines are silently skipped during parse +- Empty parse results don't crash grouping +- Workflow degrades gracefully +- No exceptions thrown" + (let ((output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Malformed output produces empty parse + (should (null parsed)) + + ;; Empty parse produces empty grouping (no crash) + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (null grouped))))))) + +(provide 'test-integration-recording-device-workflow) +;;; test-integration-recording-device-workflow.el ends here diff --git a/tests/test-integration-transcription.el b/tests/test-integration-transcription.el new file mode 100644 index 00000000..96b617bc --- /dev/null +++ b/tests/test-integration-transcription.el @@ -0,0 +1,145 @@ +;;; test-integration-transcription.el --- Integration tests for transcription -*- lexical-binding: t; -*- + +;;; Commentary: +;; End-to-end integration tests for transcription workflow +;; Tests complete workflow with temporary files and mocked processes +;; Categories: Normal workflow, Error handling, Cleanup + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Test Helpers ---------------------------------- + +(defun test-transcription--make-mock-audio-file () + "Create a temporary mock audio file for testing. +Returns the absolute path to the file." + (let ((file (make-temp-file "test-audio-" nil ".m4a"))) + (with-temp-file file + (insert "Mock audio data")) + file)) + +(defun test-transcription--cleanup-output-files (audio-file) + "Delete transcript and log files associated with AUDIO-FILE." + (let* ((outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs)) + (log-file (cdr outputs))) + (when (file-exists-p txt-file) + (delete-file txt-file)) + (when (file-exists-p log-file) + (delete-file log-file)))) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-integration-transcription-output-files-created () + "Test that .txt and .log files are created for audio file." + (let* ((audio-file (test-transcription--make-mock-audio-file)) + (outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs)) + (log-file (cdr outputs))) + (unwind-protect + (progn + ;; Verify output file paths are correct + (should (string-suffix-p ".txt" txt-file)) + (should (string-suffix-p ".log" log-file)) + (should (string= (file-name-sans-extension txt-file) + (file-name-sans-extension audio-file))) + (should (string= (file-name-sans-extension log-file) + (file-name-sans-extension audio-file)))) + ;; Cleanup + (delete-file audio-file) + (test-transcription--cleanup-output-files audio-file)))) + +(ert-deftest test-integration-transcription-validates-file-exists () + "Test that transcription fails for non-existent file." + (should-error + (cj/--start-transcription-process "/nonexistent/audio.m4a") + :type 'user-error)) + +(ert-deftest test-integration-transcription-validates-audio-extension () + "Test that transcription fails for non-audio file." + (let ((non-audio (make-temp-file "test-" nil ".txt"))) + (unwind-protect + (should-error + (cj/--start-transcription-process non-audio) + :type 'user-error) + (delete-file non-audio)))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-integration-transcription-audio-file-detection () + "Test various audio file extensions are accepted." + (dolist (ext '("m4a" "mp3" "wav" "flac" "ogg" "opus")) + (let ((audio-file (make-temp-file "test-audio-" nil (concat "." ext)))) + (unwind-protect + (progn + (should (cj/--audio-file-p audio-file)) + ;; Would start transcription if script existed + ) + (delete-file audio-file))))) + +(ert-deftest test-integration-transcription-filename-with-spaces () + "Test transcription with audio file containing spaces." + (let ((audio-file (make-temp-file "test audio file" nil ".m4a"))) + (unwind-protect + (let* ((outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs)) + (log-file (cdr outputs))) + (should (file-name-absolute-p txt-file)) + (should (file-name-absolute-p log-file))) + (delete-file audio-file)))) + +(ert-deftest test-integration-transcription-filename-with-special-chars () + "Test transcription with special characters in filename." + (let ((audio-file (make-temp-file "test_(final)" nil ".m4a"))) + (unwind-protect + (let* ((outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs))) + ;; make-temp-file adds random suffix, so just check it ends with .txt + ;; and contains the special chars + (should (string-suffix-p ".txt" txt-file)) + (should (string-match-p "test_(final)" txt-file))) + (delete-file audio-file)))) + +;; ----------------------------- Cleanup Tests --------------------------------- + +(ert-deftest test-integration-transcription-cleanup-completed () + "Test that completed transcriptions are removed from tracking." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil complete) + (proc3 "file3.m4a" nil error)))) + (cj/--cleanup-completed-transcriptions) + (should (= 1 (length cj/transcriptions-list))) + (should (eq 'running (nth 3 (car cj/transcriptions-list)))))) + +(ert-deftest test-integration-transcription-cleanup-all-complete () + "Test cleanup when all transcriptions are complete." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil complete) + (proc2 "file2.m4a" nil error)))) + (cj/--cleanup-completed-transcriptions) + (should (null cj/transcriptions-list)))) + +(ert-deftest test-integration-transcription-cleanup-preserves-running () + "Test that running transcriptions are not cleaned up." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil running)))) + (cj/--cleanup-completed-transcriptions) + (should (= 2 (length cj/transcriptions-list))))) + +;; ----------------------------- Backend Tests --------------------------------- + +(ert-deftest test-integration-transcription-script-path-exists () + "Test that transcription scripts exist in expected location." + (dolist (backend '(local-whisper openai-api)) + (let ((cj/transcribe-backend backend)) + (let ((script (cj/--transcription-script-path))) + (should (file-name-absolute-p script)) + ;; Note: Script may not exist in test environment, just check path format + (should (string-match-p "scripts/" script)))))) + +(provide 'test-integration-transcription) +;;; test-integration-transcription.el ends here diff --git a/tests/test-music-config--append-track-to-m3u-file.el b/tests/test-music-config--append-track-to-m3u-file.el new file mode 100644 index 00000000..2bf3e87d --- /dev/null +++ b/tests/test-music-config--append-track-to-m3u-file.el @@ -0,0 +1,187 @@ +;;; test-music-config--append-track-to-m3u-file.el --- Tests for appending tracks to M3U files -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--append-track-to-m3u-file function. +;; Tests the pure, deterministic helper that appends track paths to M3U files. +;; +;; Test organization: +;; - Normal Cases: Standard append operations +;; - Boundary Cases: Edge conditions (unicode, long paths, special chars) +;; - Error Cases: File errors (missing, read-only, directory instead of file) +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--append-track-to-m3u-file-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--append-track-to-m3u-file-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-empty-file-appends-track () + "Append to brand new empty M3U file." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/artist/song.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-existing-with-newline-appends-track () + "Append to file with existing content ending with newline." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "/home/user/music/first.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path "/home/user/music/second.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-existing-without-newline-appends-track () + "Append to file without trailing newline adds leading newline." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "/home/user/music/first.mp3") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path "/home/user/music/second.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content "\n" track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-multiple-appends-all-succeed () + "Multiple appends to same file all succeed (allows duplicates)." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track1 "/home/user/music/track1.mp3") + (track2 "/home/user/music/track2.mp3") + (track1-duplicate "/home/user/music/track1.mp3")) + (cj/music--append-track-to-m3u-file track1 m3u-file) + (cj/music--append-track-to-m3u-file track2 m3u-file) + (cj/music--append-track-to-m3u-file track1-duplicate m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (let ((content (buffer-string))) + (should (string= content + (concat track1 "\n" track2 "\n" track1-duplicate "\n")))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-very-long-path-appends-successfully () + "Append very long track path without truncation." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + ;; Create a path that's ~500 chars long + (track-path (concat "/home/user/music/" + (make-string 450 ?a) + "/song.mp3"))) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))) + (should (= (length (buffer-string)) (1+ (length track-path)))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-path-with-unicode-appends-successfully () + "Append path with unicode characters preserves UTF-8 encoding." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/中文/artist-名前/song🎵.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-path-with-spaces-appends-successfully () + "Append path with spaces and special characters." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/Artist Name/Album (2024)/01 - Song's Title [Remix].mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-m3u-with-comments-appends-after () + "Append to M3U file containing comments and metadata." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "#EXTM3U\n#EXTINF:-1,Radio Station\nhttp://stream.url/radio\n") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path "/home/user/music/local-track.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-error-nonexistent-file-signals-error () + "Signal error when M3U file doesn't exist." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file "/nonexistent/path/to/playlist.m3u") + (track-path "/home/user/music/song.mp3")) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-file) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-error-readonly-file-signals-error () + "Signal error when M3U file is read-only." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/song.mp3")) + ;; Make file read-only + (set-file-modes m3u-file #o444) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-file) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-error-directory-not-file-signals-error () + "Signal error when path points to directory instead of file." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-dir (cj/create-test-subdirectory "test-playlist-dir")) + (track-path "/home/user/music/song.mp3")) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-dir) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(provide 'test-music-config--append-track-to-m3u-file) +;;; test-music-config--append-track-to-m3u-file.el ends here diff --git a/tests/test-music-config--collect-entries-recursive.el b/tests/test-music-config--collect-entries-recursive.el new file mode 100644 index 00000000..d71ceab6 --- /dev/null +++ b/tests/test-music-config--collect-entries-recursive.el @@ -0,0 +1,245 @@ +;;; test-music-config--collect-entries-recursive.el --- Tests for recursive music collection -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--collect-entries-recursive function. +;; Tests the recursive helper that collects music files and directories. +;; +;; Test organization: +;; - Normal Cases: Single level, nested directories, mixed files +;; - Boundary Cases: Hidden files/dirs, non-music files, empty dirs, sorting +;; - Error Cases: Empty root, nonexistent root +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--collect-entries-recursive-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--collect-entries-recursive-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--collect-entries-recursive-normal-single-level-files-and-dirs () + "Collect music files and subdirectories at single level." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create files at root + (cj/create-directory-or-file-ensuring-parents "music/song1.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/song2.flac" "") + ;; Create subdirectories + (cj/create-directory-or-file-ensuring-parents "music/artist1/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist2/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "artist1/" result)) + (should (member "artist2/" result)) + (should (member "song1.mp3" result)) + (should (member "song2.flac" result)) + (should (= (length result) 4)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-normal-nested-directories () + "Collect nested directories multiple levels deep." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create nested structure + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/disc1/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "artist/" result)) + (should (member "artist/album/" result)) + (should (member "artist/album/disc1/" result)) + (should (= (length result) 3)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-normal-mixed-files-at-multiple-levels () + "Collect music files at root, subdirs, and nested subdirs." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Root level file + (cj/create-directory-or-file-ensuring-parents "music/root-track.mp3" "") + ;; Subdir with file + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/track1.mp3" "") + ;; Nested subdir with file + (cj/create-directory-or-file-ensuring-parents "music/artist/album/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/track2.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "root-track.mp3" result)) + (should (member "artist/" result)) + (should (member "artist/track1.mp3" result)) + (should (member "artist/album/" result)) + (should (member "artist/album/track2.mp3" result)) + (should (= (length result) 5)))) + (test-music-config--collect-entries-recursive-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--collect-entries-recursive-boundary-hidden-directories-skipped () + "Hidden directories and their contents are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Visible file + (cj/create-directory-or-file-ensuring-parents "music/visible.mp3" "") + ;; Hidden directory with music file + (cj/create-directory-or-file-ensuring-parents "music/.hidden/" "") + (cj/create-directory-or-file-ensuring-parents "music/.hidden/secret.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "visible.mp3" result)) + (should-not (member ".hidden/" result)) + (should-not (member ".hidden/secret.mp3" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-hidden-files-skipped () + "Hidden files at root are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Visible file + (cj/create-directory-or-file-ensuring-parents "music/visible.mp3" "") + ;; Hidden file (note: directory-files regex "^[^.].*" should skip it) + (cj/create-directory-or-file-ensuring-parents "music/.hidden-track.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "visible.mp3" result)) + (should-not (member ".hidden-track.mp3" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-non-music-files-excluded () + "Non-music files are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Music file + (cj/create-directory-or-file-ensuring-parents "music/song.mp3" "") + ;; Non-music files + (cj/create-directory-or-file-ensuring-parents "music/readme.txt" "") + (cj/create-directory-or-file-ensuring-parents "music/cover.jpg" "") + (cj/create-directory-or-file-ensuring-parents "music/info.pdf" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "song.mp3" result)) + (should-not (member "readme.txt" result)) + (should-not (member "cover.jpg" result)) + (should-not (member "info.pdf" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-empty-directories-included () + "Empty subdirectories are still listed with trailing slash." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Empty subdirectories + (cj/create-directory-or-file-ensuring-parents "music/empty-artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/another-empty/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "empty-artist/" result)) + (should (member "another-empty/" result)) + (should (= (length result) 2)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-sorted-output () + "Output is sorted alphabetically (case-insensitive)." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create files in non-alphabetical order + (cj/create-directory-or-file-ensuring-parents "music/zebra.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/Alpha.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/beta.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + ;; Should be sorted alphabetically (case-insensitive) + (should (equal result '("Alpha.mp3" "beta.mp3" "zebra.mp3"))))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-directories-have-trailing-slash () + "Directories have trailing slash, files don't." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/song.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + ;; Directory has trailing slash + (should (cl-some (lambda (entry) (string-suffix-p "/" entry)) result)) + ;; File doesn't have trailing slash + (should (cl-some (lambda (entry) (not (string-suffix-p "/" entry))) result)) + ;; Specifically check + (should (member "artist/" result)) + (should (member "song.mp3" result)) + (should-not (member "song.mp3/" result)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-all-music-extensions () + "All configured music extensions are collected." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create file for each extension: aac, flac, m4a, mp3, ogg, opus, wav + (cj/create-directory-or-file-ensuring-parents "music/track.aac" "") + (cj/create-directory-or-file-ensuring-parents "music/track.flac" "") + (cj/create-directory-or-file-ensuring-parents "music/track.m4a" "") + (cj/create-directory-or-file-ensuring-parents "music/track.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/track.ogg" "") + (cj/create-directory-or-file-ensuring-parents "music/track.opus" "") + (cj/create-directory-or-file-ensuring-parents "music/track.wav" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (= (length result) 7)) + (should (member "track.aac" result)) + (should (member "track.flac" result)) + (should (member "track.m4a" result)) + (should (member "track.mp3" result)) + (should (member "track.ogg" result)) + (should (member "track.opus" result)) + (should (member "track.wav" result)))) + (test-music-config--collect-entries-recursive-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--collect-entries-recursive-error-empty-root-returns-empty () + "Empty root directory returns empty list." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "empty-music"))) + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (null result)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-error-nonexistent-root-returns-empty () + "Nonexistent directory returns empty list." + (let ((result (cj/music--collect-entries-recursive "/nonexistent/path/to/music"))) + (should (null result)))) + +(provide 'test-music-config--collect-entries-recursive) +;;; test-music-config--collect-entries-recursive.el ends here diff --git a/tests/test-music-config--completion-table.el b/tests/test-music-config--completion-table.el new file mode 100644 index 00000000..5be0479d --- /dev/null +++ b/tests/test-music-config--completion-table.el @@ -0,0 +1,134 @@ +;;; test-music-config--completion-table.el --- Tests for completion table generation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--completion-table function. +;; Tests the completion table generator that creates custom completion tables. +;; +;; Test organization: +;; - Normal Cases: Metadata, completions, case-insensitive matching +;; - Boundary Cases: Empty candidates, partial matching, exact matches +;; - Error Cases: Nil candidates +;; +;;; Code: + +(require 'ert) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Normal Cases + +(ert-deftest test-music-config--completion-table-normal-metadata-action-returns-metadata () + "Completion table returns metadata when action is 'metadata." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "" nil 'metadata))) + (should (eq (car result) 'metadata)) + ;; Check metadata contains expected properties + (should (equal (alist-get 'display-sort-function (cdr result)) 'identity)) + (should (equal (alist-get 'cycle-sort-function (cdr result)) 'identity)) + (should (eq (alist-get 'completion-ignore-case (cdr result)) t)))) + +(ert-deftest test-music-config--completion-table-normal-t-action-returns-all-completions () + "Completion table returns all matching completions when action is t." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "" nil t))) + ;; Empty string should match all candidates + (should (equal (sort result #'string<) '("Classical" "Jazz" "Rock"))))) + +(ert-deftest test-music-config--completion-table-normal-nil-action-tries-completion () + "Completion table tries completion when action is nil." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Roc" nil nil))) + ;; Should return completion attempt for "Roc" -> "Rock" + (should (stringp result)) + (should (string-prefix-p "Roc" result)))) + +(ert-deftest test-music-config--completion-table-normal-case-insensitive-metadata () + "Completion table metadata indicates case-insensitive completion." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (metadata (funcall table "" nil 'metadata))) + ;; Metadata should indicate case-insensitive + (should (eq (alist-get 'completion-ignore-case (cdr metadata)) t)))) + +;;; Boundary Cases + +(ert-deftest test-music-config--completion-table-boundary-empty-candidates () + "Completion table with empty candidate list returns no completions." + (let* ((candidates '()) + (table (cj/music--completion-table candidates)) + (result (funcall table "anything" nil t))) + (should (null result)))) + +(ert-deftest test-music-config--completion-table-boundary-single-candidate () + "Completion table with single candidate returns it on match." + (let* ((candidates '("OnlyOne")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Only" nil t))) + (should (equal result '("OnlyOne"))))) + +(ert-deftest test-music-config--completion-table-boundary-partial-matching () + "Completion table matches multiple candidates with common prefix." + (let* ((candidates '("playlist1" "playlist2" "jazz")) + (table (cj/music--completion-table candidates)) + (result (funcall table "play" nil t))) + (should (= (length result) 2)) + (should (member "playlist1" result)) + (should (member "playlist2" result)) + (should-not (member "jazz" result)))) + +(ert-deftest test-music-config--completion-table-boundary-no-matches () + "Completion table returns empty when no candidates match." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Metal" nil t))) + (should (null result)))) + +(ert-deftest test-music-config--completion-table-boundary-exact-match () + "Completion table returns t for exact match with nil action." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Jazz" nil nil))) + ;; Exact match with nil action returns t + (should (eq result t)))) + +(ert-deftest test-music-config--completion-table-boundary-mixed-case-candidates () + "Completion table with mixed-case duplicate candidates." + (let* ((candidates '("Rock" "ROCK" "rock")) + (table (cj/music--completion-table candidates)) + (result (funcall table "R" nil t))) + ;; All start with "R", but exact case matters for complete-with-action + ;; Only exact case match "R" prefix + (should (member "Rock" result)) + (should (member "ROCK" result)) + ;; "rock" doesn't match "R" prefix (lowercase) + (should-not (member "rock" result)))) + +(ert-deftest test-music-config--completion-table-boundary-unicode-candidates () + "Completion table handles unicode characters in candidates." + (let* ((candidates '("中文" "日本語" "한국어")) + (table (cj/music--completion-table candidates)) + (result (funcall table "中" nil t))) + (should (member "中文" result)))) + +;;; Error Cases + +(ert-deftest test-music-config--completion-table-error-nil-candidates-handles-gracefully () + "Completion table with nil candidates handles gracefully." + (let* ((candidates nil) + (table (cj/music--completion-table candidates)) + (result (funcall table "anything" nil t))) + ;; Should not crash, returns empty + (should (null result)))) + +(provide 'test-music-config--completion-table) +;;; test-music-config--completion-table.el ends here diff --git a/tests/test-music-config--get-m3u-basenames.el b/tests/test-music-config--get-m3u-basenames.el new file mode 100644 index 00000000..91c8af70 --- /dev/null +++ b/tests/test-music-config--get-m3u-basenames.el @@ -0,0 +1,121 @@ +;;; test-music-config--get-m3u-basenames.el --- Tests for M3U basename extraction -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--get-m3u-basenames function. +;; Tests the helper that extracts M3U basenames (without .m3u extension). +;; +;; Test organization: +;; - Normal Cases: Multiple files, single file +;; - Boundary Cases: Empty directory, extension removal +;; - Error Cases: Nonexistent directory +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--get-m3u-basenames-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--get-m3u-basenames-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--get-m3u-basenames-normal-multiple-files-returns-basenames () + "Extract basenames from multiple M3U files without .m3u extension." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "rock.m3u")) + (file2 (cj/create-temp-test-file-with-content "" "jazz.m3u")) + (file3 (cj/create-temp-test-file-with-content "" "classical.m3u"))) + (rename-file file1 (expand-file-name "rock.m3u" test-dir)) + (rename-file file2 (expand-file-name "jazz.m3u" test-dir)) + (rename-file file3 (expand-file-name "classical.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (= (length result) 3)) + ;; Sort for consistent comparison + (let ((sorted-result (sort result #'string<))) + (should (equal sorted-result '("classical" "jazz" "rock"))))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-normal-single-file-returns-basename () + "Extract basename from single M3U file without .m3u extension." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "favorites.m3u"))) + (rename-file file1 (expand-file-name "favorites.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (= (length result) 1)) + (should (equal (car result) "favorites"))))) + (test-music-config--get-m3u-basenames-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--get-m3u-basenames-boundary-empty-directory-returns-empty () + "Extract basenames from empty directory returns empty list." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "empty-playlists"))) + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (null result))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-boundary-extension-removed () + "Basenames have .m3u extension removed." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "test.m3u"))) + (rename-file file1 (expand-file-name "playlist.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (equal result '("playlist"))) + ;; Verify no .m3u extension present + (should-not (string-match-p "\\.m3u" (car result)))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-boundary-spaces-in-filename-preserved () + "Basenames with spaces preserve the spaces." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "test.m3u"))) + (rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (equal result '("My Favorite Songs")))))) + (test-music-config--get-m3u-basenames-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--get-m3u-basenames-error-nonexistent-directory-signals-error () + "Nonexistent directory signals error." + (let ((cj/music-m3u-root "/nonexistent/directory/path")) + (should-error (cj/music--get-m3u-basenames) + :type 'file-error))) + +(provide 'test-music-config--get-m3u-basenames) +;;; test-music-config--get-m3u-basenames.el ends here diff --git a/tests/test-music-config--get-m3u-files.el b/tests/test-music-config--get-m3u-files.el new file mode 100644 index 00000000..2d31d554 --- /dev/null +++ b/tests/test-music-config--get-m3u-files.el @@ -0,0 +1,150 @@ +;;; test-music-config--get-m3u-files.el --- Tests for M3U file discovery -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--get-m3u-files function. +;; Tests the helper that discovers M3U files in the music directory. +;; +;; Test organization: +;; - Normal Cases: Multiple M3U files, single file +;; - Boundary Cases: Empty directory, non-M3U files, various filenames +;; - Error Cases: Nonexistent directory +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--get-m3u-files-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--get-m3u-files-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--get-m3u-files-normal-multiple-files-returns-list () + "Discover multiple M3U files returns list of (basename . fullpath) conses." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "playlist1.m3u")) + (file2 (cj/create-temp-test-file-with-content "" "playlist2.m3u")) + (file3 (cj/create-temp-test-file-with-content "" "playlist3.m3u"))) + ;; Move files to test-dir + (rename-file file1 (expand-file-name "playlist1.m3u" test-dir)) + (rename-file file2 (expand-file-name "playlist2.m3u" test-dir)) + (rename-file file3 (expand-file-name "playlist3.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 3)) + ;; Check structure: list of (basename . fullpath) conses + ;; Sort for consistent comparison (directory-files order is filesystem-dependent) + (let ((basenames (sort (mapcar #'car result) #'string<)) + (fullpaths (sort (mapcar #'cdr result) #'string<))) + (should (equal basenames '("playlist1.m3u" "playlist2.m3u" "playlist3.m3u"))) + (should (equal fullpaths + (list (expand-file-name "playlist1.m3u" test-dir) + (expand-file-name "playlist2.m3u" test-dir) + (expand-file-name "playlist3.m3u" test-dir)))))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-normal-single-file-returns-list () + "Discover single M3U file returns single-item list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "myplaylist.m3u"))) + (rename-file file1 (expand-file-name "myplaylist.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "myplaylist.m3u")) + (should (equal (cdar result) (expand-file-name "myplaylist.m3u" test-dir)))))) + (test-music-config--get-m3u-files-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--get-m3u-files-boundary-empty-directory-returns-empty () + "Discover M3U files in empty directory returns empty list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "empty-playlists"))) + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (null result))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-non-m3u-files-ignored () + "Directory with non-M3U files returns empty list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "mixed-files")) + (txt-file (cj/create-temp-test-file-with-content "" "readme.txt")) + (mp3-file (cj/create-temp-test-file-with-content "" "song.mp3")) + (json-file (cj/create-temp-test-file-with-content "" "data.json"))) + (rename-file txt-file (expand-file-name "readme.txt" test-dir)) + (rename-file mp3-file (expand-file-name "song.mp3" test-dir)) + (rename-file json-file (expand-file-name "data.json" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (null result))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-m3u-with-spaces-included () + "M3U files with spaces in name are discovered." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "my-playlist.m3u"))) + (rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "My Favorite Songs.m3u"))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-mixed-m3u-and-other-files () + "Directory with both M3U and non-M3U files returns only M3U files." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "mixed")) + (m3u-file (cj/create-temp-test-file-with-content "" "playlist.m3u")) + (txt-file (cj/create-temp-test-file-with-content "" "readme.txt")) + (mp3-file (cj/create-temp-test-file-with-content "" "song.mp3"))) + (rename-file m3u-file (expand-file-name "playlist.m3u" test-dir)) + (rename-file txt-file (expand-file-name "readme.txt" test-dir)) + (rename-file mp3-file (expand-file-name "song.mp3" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "playlist.m3u"))))) + (test-music-config--get-m3u-files-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--get-m3u-files-error-nonexistent-directory-signals-error () + "Nonexistent directory signals error." + (let ((cj/music-m3u-root "/nonexistent/directory/path")) + (should-error (cj/music--get-m3u-files) + :type 'file-error))) + +(provide 'test-music-config--get-m3u-files) +;;; test-music-config--get-m3u-files.el ends here diff --git a/tests/test-music-config--m3u-file-tracks.el b/tests/test-music-config--m3u-file-tracks.el new file mode 100644 index 00000000..badc9817 --- /dev/null +++ b/tests/test-music-config--m3u-file-tracks.el @@ -0,0 +1,193 @@ +;;; test-music-config--m3u-file-tracks.el --- Tests for M3U file parsing -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--m3u-file-tracks function. +;; Tests the M3U parser that extracts track paths from playlist files. +;; +;; Test organization: +;; - Normal Cases: Absolute paths, relative paths, URLs (http/https/mms) +;; - Boundary Cases: Empty lines, whitespace, comments, order preservation +;; - Error Cases: Nonexistent files, nil input +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--m3u-file-tracks-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--m3u-file-tracks-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--m3u-file-tracks-normal-absolute-paths-returns-list () + "Parse M3U with absolute paths returns list in order." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n/home/user/music/track2.mp3\n/home/user/music/track3.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3" + "/home/user/music/track3.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-relative-paths-expanded () + "Parse M3U with relative paths expands them relative to M3U directory." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "artist/track1.mp3\nartist/track2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (m3u-dir (file-name-directory m3u-file)) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks (list (expand-file-name "artist/track1.mp3" m3u-dir) + (expand-file-name "artist/track2.mp3" m3u-dir))))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-http-urls-preserved () + "Parse M3U with http:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "http://example.com/stream1.mp3\nhttp://example.com/stream2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("http://example.com/stream1.mp3" + "http://example.com/stream2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-https-urls-preserved () + "Parse M3U with https:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "https://secure.example.com/stream.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("https://secure.example.com/stream.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-mms-urls-preserved () + "Parse M3U with mms:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "mms://radio.example.com/stream\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("mms://radio.example.com/stream")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-mixed-paths-and-urls () + "Parse M3U with mix of absolute, relative, and URLs handles all correctly." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/local.mp3\nartist/relative.mp3\nhttp://example.com/stream.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (m3u-dir (file-name-directory m3u-file)) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks (list "/home/user/music/local.mp3" + (expand-file-name "artist/relative.mp3" m3u-dir) + "http://example.com/stream.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--m3u-file-tracks-boundary-empty-lines-ignored () + "Parse M3U with empty lines ignores them and returns tracks." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n\n/home/user/music/track2.mp3\n\n\n/home/user/music/track3.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3" + "/home/user/music/track3.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-whitespace-only-lines-ignored () + "Parse M3U with whitespace-only lines ignores them." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n \n\t\t\n/home/user/music/track2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-comments-ignored () + "Parse M3U with comment lines ignores them, returns only tracks." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "#EXTM3U\n#EXTINF:-1,Track Title\n/home/user/music/track.mp3\n#Another comment\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-leading-trailing-whitespace-trimmed () + "Parse M3U with whitespace around paths trims it." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content " /home/user/music/track1.mp3 \n\t/home/user/music/track2.mp3\t\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-empty-file-returns-nil () + "Parse empty M3U file returns nil." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (null tracks))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-only-comments-returns-empty () + "Parse M3U with only comments returns empty list." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "#EXTM3U\n#EXTINF:-1,Title\n#Another comment\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (null tracks))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-preserves-order () + "Parse M3U preserves track order (tests nreverse)." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/track1.mp3\n/track2.mp3\n/track3.mp3\n/track4.mp3\n/track5.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/track1.mp3" "/track2.mp3" "/track3.mp3" "/track4.mp3" "/track5.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--m3u-file-tracks-error-nonexistent-file-returns-nil () + "Parse nonexistent file returns nil." + (should (null (cj/music--m3u-file-tracks "/nonexistent/path/playlist.m3u")))) + +(ert-deftest test-music-config--m3u-file-tracks-error-nil-input-returns-nil () + "Parse nil input returns nil gracefully." + (should (null (cj/music--m3u-file-tracks nil)))) + +(provide 'test-music-config--m3u-file-tracks) +;;; test-music-config--m3u-file-tracks.el ends here diff --git a/tests/test-music-config--safe-filename.el b/tests/test-music-config--safe-filename.el new file mode 100644 index 00000000..8105ee15 --- /dev/null +++ b/tests/test-music-config--safe-filename.el @@ -0,0 +1,97 @@ +;;; test-music-config--safe-filename.el --- Tests for filename sanitization -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--safe-filename function. +;; Tests the pure helper that sanitizes filenames by replacing invalid chars. +;; +;; Test organization: +;; - Normal Cases: Valid filenames unchanged, spaces replaced +;; - Boundary Cases: Special chars, unicode, slashes, consecutive invalid chars +;; - Error Cases: Nil input +;; +;;; Code: + +(require 'ert) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Normal Cases + +(ert-deftest test-music-config--safe-filename-normal-alphanumeric-unchanged () + "Validate alphanumeric filename remains unchanged." + (should (string= (cj/music--safe-filename "MyPlaylist123") + "MyPlaylist123"))) + +(ert-deftest test-music-config--safe-filename-normal-with-hyphens-unchanged () + "Validate filename with hyphens remains unchanged." + (should (string= (cj/music--safe-filename "my-playlist-name") + "my-playlist-name"))) + +(ert-deftest test-music-config--safe-filename-normal-with-underscores-unchanged () + "Validate filename with underscores remains unchanged." + (should (string= (cj/music--safe-filename "my_playlist_name") + "my_playlist_name"))) + +(ert-deftest test-music-config--safe-filename-normal-spaces-replaced () + "Validate spaces are replaced with underscores." + (should (string= (cj/music--safe-filename "My Favorite Songs") + "My_Favorite_Songs"))) + +;;; Boundary Cases + +(ert-deftest test-music-config--safe-filename-boundary-special-chars-replaced () + "Validate special characters are replaced with underscores." + (should (string= (cj/music--safe-filename "playlist@#$%^&*()") + "playlist_________"))) + +(ert-deftest test-music-config--safe-filename-boundary-unicode-replaced () + "Validate unicode characters are replaced with underscores." + (should (string= (cj/music--safe-filename "中文歌曲") + "____"))) + +(ert-deftest test-music-config--safe-filename-boundary-mixed-valid-invalid () + "Validate mixed valid and invalid characters." + (should (string= (cj/music--safe-filename "Rock & Roll") + "Rock___Roll"))) + +(ert-deftest test-music-config--safe-filename-boundary-dots-replaced () + "Validate dots are replaced with underscores." + (should (string= (cj/music--safe-filename "my.playlist.name") + "my_playlist_name"))) + +(ert-deftest test-music-config--safe-filename-boundary-slashes-replaced () + "Validate slashes are replaced with underscores." + (should (string= (cj/music--safe-filename "folder/file") + "folder_file"))) + +(ert-deftest test-music-config--safe-filename-boundary-consecutive-invalid-chars () + "Validate consecutive invalid characters each become underscores." + (should (string= (cj/music--safe-filename "test!!!name") + "test___name"))) + +(ert-deftest test-music-config--safe-filename-boundary-empty-string-unchanged () + "Validate empty string remains unchanged." + (should (string= (cj/music--safe-filename "") + ""))) + +(ert-deftest test-music-config--safe-filename-boundary-only-invalid-chars () + "Validate string with only invalid characters becomes all underscores." + (should (string= (cj/music--safe-filename "!@#$%") + "_____"))) + +;;; Error Cases + +(ert-deftest test-music-config--safe-filename-error-nil-input-signals-error () + "Validate nil input signals error." + (should-error (cj/music--safe-filename nil) + :type 'wrong-type-argument)) + +(provide 'test-music-config--safe-filename) +;;; test-music-config--safe-filename.el ends here diff --git a/tests/test-music-config--valid-directory-p.el b/tests/test-music-config--valid-directory-p.el new file mode 100644 index 00000000..21c2b240 --- /dev/null +++ b/tests/test-music-config--valid-directory-p.el @@ -0,0 +1,139 @@ +;;; test-music-config--valid-directory-p.el --- Tests for directory validation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--valid-directory-p function. +;; Tests the pure helper that validates non-hidden directories. +;; +;; Test organization: +;; - Normal Cases: Valid visible directories +;; - Boundary Cases: Trailing slashes, dots in names, hidden directories +;; - Error Cases: Files (not dirs), nonexistent paths, nil input +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--valid-directory-p-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--valid-directory-p-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--valid-directory-p-normal-visible-directory-returns-true () + "Validate visible directory returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-normal-nested-directory-returns-true () + "Validate nested visible directory returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir/subdir/nested"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--valid-directory-p-boundary-trailing-slash-returns-true () + "Validate directory with trailing slash returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p (file-name-as-directory test-dir)))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-no-trailing-slash-returns-true () + "Validate directory without trailing slash returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p (directory-file-name test-dir)))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-dot-in-middle-returns-true () + "Validate directory with dot in middle of name returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "my.music.dir"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-hidden-directory-returns-nil () + "Validate hidden directory (starting with dot) returns nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory ".hidden"))) + (should-not (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-current-dir-dot-returns-nil () + "Validate current directory '.' returns nil (hidden)." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + ;; Change to test dir and check "." + (let ((default-directory test-dir)) + (should-not (cj/music--valid-directory-p ".")))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-parent-dir-dotdot-returns-nil () + "Validate parent directory '..' returns nil (hidden)." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir/subdir"))) + ;; Change to subdir and check ".." + (let ((default-directory test-dir)) + (should-not (cj/music--valid-directory-p "..")))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-hidden-subdir-basename-check () + "Validate hidden subdirectory returns nil based on basename." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((hidden-dir (cj/create-test-subdirectory "visible/.hidden"))) + (should-not (cj/music--valid-directory-p hidden-dir))) + (test-music-config--valid-directory-p-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--valid-directory-p-error-regular-file-returns-nil () + "Validate regular file (not directory) returns nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-file (cj/create-temp-test-file "testfile-"))) + (should-not (cj/music--valid-directory-p test-file))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-error-nonexistent-path-returns-nil () + "Validate nonexistent path returns nil." + (should-not (cj/music--valid-directory-p "/nonexistent/path/to/directory"))) + +(ert-deftest test-music-config--valid-directory-p-error-nil-input-returns-nil () + "Validate nil input returns nil gracefully." + (should-not (cj/music--valid-directory-p nil))) + +(ert-deftest test-music-config--valid-directory-p-error-empty-string-returns-nil () + "Validate empty string returns nil." + (should-not (cj/music--valid-directory-p ""))) + +(provide 'test-music-config--valid-directory-p) +;;; test-music-config--valid-directory-p.el ends here diff --git a/tests/test-music-config--valid-file-p.el b/tests/test-music-config--valid-file-p.el new file mode 100644 index 00000000..8099c50c --- /dev/null +++ b/tests/test-music-config--valid-file-p.el @@ -0,0 +1,99 @@ +;;; test-music-config--valid-file-p.el --- Tests for music file validation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--valid-file-p function. +;; Tests the pure, deterministic helper that validates music file extensions. +;; +;; Test organization: +;; - Normal Cases: Valid music extensions (case-insensitive) +;; - Boundary Cases: Edge conditions (no extension, dots in path, empty strings) +;; - Error Cases: Invalid extensions, nil input +;; +;;; Code: + +(require 'ert) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Normal Cases + +(ert-deftest test-music-config--valid-file-p-normal-mp3-extension-returns-true () + "Validate mp3 file extension returns non-nil." + (should (cj/music--valid-file-p "/path/to/song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-normal-flac-extension-returns-true () + "Validate flac file extension returns non-nil." + (should (cj/music--valid-file-p "/path/to/song.flac"))) + +(ert-deftest test-music-config--valid-file-p-normal-all-extensions-return-true () + "Validate all configured music extensions return non-nil." + ;; Test each extension from cj/music-file-extensions + (dolist (ext '("aac" "flac" "m4a" "mp3" "ogg" "opus" "wav")) + (should (cj/music--valid-file-p (format "/path/to/song.%s" ext))))) + +(ert-deftest test-music-config--valid-file-p-normal-uppercase-extension-returns-true () + "Validate uppercase extension returns non-nil (case-insensitive)." + (should (cj/music--valid-file-p "/path/to/song.MP3"))) + +(ert-deftest test-music-config--valid-file-p-normal-mixed-case-extension-returns-true () + "Validate mixed-case extension returns non-nil (case-insensitive)." + (should (cj/music--valid-file-p "/path/to/song.Mp3")) + (should (cj/music--valid-file-p "/path/to/song.FLaC"))) + +;;; Boundary Cases + +(ert-deftest test-music-config--valid-file-p-boundary-dots-in-path-returns-true () + "Validate file with dots in directory path uses only last extension." + (should (cj/music--valid-file-p "/path/with.dots/in.directory/song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-boundary-multiple-extensions-uses-last () + "Validate file with multiple extensions uses rightmost extension." + (should (cj/music--valid-file-p "/path/to/song.backup.mp3")) + (should (cj/music--valid-file-p "/path/to/song.old.flac"))) + +(ert-deftest test-music-config--valid-file-p-boundary-just-filename-with-extension-returns-true () + "Validate bare filename without path returns non-nil." + (should (cj/music--valid-file-p "song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-boundary-no-extension-returns-nil () + "Validate file without extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/song"))) + +(ert-deftest test-music-config--valid-file-p-boundary-dot-at-end-returns-nil () + "Validate file ending with dot (empty extension) returns nil." + (should-not (cj/music--valid-file-p "/path/to/song."))) + +(ert-deftest test-music-config--valid-file-p-boundary-empty-string-returns-nil () + "Validate empty string returns nil." + (should-not (cj/music--valid-file-p ""))) + +;;; Error Cases + +(ert-deftest test-music-config--valid-file-p-error-nil-input-returns-nil () + "Validate nil input returns nil gracefully." + (should-not (cj/music--valid-file-p nil))) + +(ert-deftest test-music-config--valid-file-p-error-non-music-extension-returns-nil () + "Validate non-music file extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/document.txt")) + (should-not (cj/music--valid-file-p "/path/to/readme.md"))) + +(ert-deftest test-music-config--valid-file-p-error-image-extension-returns-nil () + "Validate image file extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/cover.jpg")) + (should-not (cj/music--valid-file-p "/path/to/artwork.png"))) + +(ert-deftest test-music-config--valid-file-p-error-video-extension-returns-nil () + "Validate video file extension returns nil (mp4 not in list, only m4a)." + (should-not (cj/music--valid-file-p "/path/to/video.mp4")) + (should-not (cj/music--valid-file-p "/path/to/clip.mkv"))) + +(provide 'test-music-config--valid-file-p) +;;; test-music-config--valid-file-p.el ends here diff --git a/tests/test-org-contacts-capture-finalize.el b/tests/test-org-contacts-capture-finalize.el new file mode 100644 index 00000000..d379a912 --- /dev/null +++ b/tests/test-org-contacts-capture-finalize.el @@ -0,0 +1,217 @@ +;;; test-org-contacts-capture-finalize.el --- Tests for org-contacts capture template finalization -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;;; Commentary: + +;; Unit tests for the org-contacts capture template finalization function +;; that automatically inserts birthday timestamps. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'org) + +;; Define the function to test (copied from org-contacts-config.el) +(defun cj/org-contacts-finalize-birthday-timestamp () + "Add yearly repeating timestamp after properties drawer if BIRTHDAY is set. +This function is called during `org-capture' finalization to automatically +insert a plain timestamp for birthdays, enabling them to appear in org-agenda +without requiring org-contacts to be loaded in the async subprocess." + (when (string= (plist-get org-capture-plist :key) "C") + (save-excursion + (goto-char (point-min)) + ;; Find the properties drawer + (when (re-search-forward "^:PROPERTIES:" nil t) + (let ((drawer-start (point)) + (drawer-end (save-excursion + (when (re-search-forward "^:END:" nil t) + (point))))) + (when drawer-end + ;; Get BIRTHDAY property value + (goto-char drawer-start) + (when (re-search-forward "^:BIRTHDAY:[ \t]*\\(.+\\)$" drawer-end t) + (let ((birthday-value (string-trim (match-string 1)))) + ;; Only process non-empty birthdays + (when (and birthday-value + (not (string-blank-p birthday-value))) + ;; Parse birthday and create timestamp + (let* ((parsed (cond + ;; Format: YYYY-MM-DD + ((string-match "^\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday-value) + (list (string-to-number (match-string 1 birthday-value)) + (string-to-number (match-string 2 birthday-value)) + (string-to-number (match-string 3 birthday-value)))) + ;; Format: MM-DD + ((string-match "^\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday-value) + (list nil + (string-to-number (match-string 1 birthday-value)) + (string-to-number (match-string 2 birthday-value)))) + (t nil))) + (year (when parsed (or (nth 0 parsed) (nth 5 (decode-time))))) + (month (when parsed (nth 1 parsed))) + (day (when parsed (nth 2 parsed)))) + (when (and year month day) + ;; Create timestamp + (let* ((time (encode-time 0 0 0 day month year)) + (dow (format-time-string "%a" time)) + (date-str (format "%04d-%02d-%02d" year month day)) + (timestamp (format "<%s %s +1y>" date-str dow))) + ;; Insert after :END: if not already present + (goto-char drawer-end) + (let ((heading-end (save-excursion (outline-next-heading) (point)))) + (unless (re-search-forward "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^>]*\\+1y>" heading-end t) + (goto-char drawer-end) + (end-of-line) + (insert "\n" timestamp))))))))))))) + +;;; Tests for birthday timestamp finalization + +(ert-deftest test-contacts-capture-finalize-with-full-birthday () + "Test that finalize adds timestamp for YYYY-MM-DD birthday." + (with-temp-buffer + (org-mode) + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":END:\n") + (insert "Added: [2025-11-01 Fri 20:30]\n") + + ;; Simulate capture context + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; Should have birthday timestamp + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)) + ;; Timestamp should be after :END: + (should (string-match-p ":END:\n<1985-03-15" content)))))) + +(ert-deftest test-contacts-capture-finalize-with-partial-birthday () + "Test that finalize adds timestamp for MM-DD birthday with current year." + (let ((current-year (nth 5 (decode-time)))) + (with-temp-buffer + (org-mode) + (insert "* Bob Baker\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 07-04\n") + (insert ":END:\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; Should have birthday timestamp with current year + (should (string-match-p (format "<%d-07-04 [A-Za-z]\\{3\\} \\+1y>" current-year) content))))))) + +(ert-deftest test-contacts-capture-finalize-without-birthday () + "Test that finalize does nothing when no birthday property." + (with-temp-buffer + (org-mode) + (insert "* Carol Chen\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: carol@example.com\n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content)) + ;; Should have no timestamp + (should-not (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" (buffer-string)))))) + +(ert-deftest test-contacts-capture-finalize-with-empty-birthday () + "Test that finalize skips empty birthday values." + (with-temp-buffer + (org-mode) + (insert "* David Davis\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: \n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content)) + ;; Should have no timestamp + (should-not (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" (buffer-string)))))) + +(ert-deftest test-contacts-capture-finalize-prevents-duplicates () + "Test that finalize doesn't add duplicate timestamps." + (with-temp-buffer + (org-mode) + (insert "* Eve Evans\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (insert "<2000-01-01 Sat +1y>\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Should have exactly one timestamp + (should (= 1 (how-many "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (point-min) (point-max))))))) + +(ert-deftest test-contacts-capture-finalize-only-for-contact-template () + "Test that finalize only runs for 'C' template key." + (with-temp-buffer + (org-mode) + (insert "* Task with birthday property\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "t"))) ; Different template key + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content))))) + +(ert-deftest test-contacts-capture-finalize-preserves-existing-content () + "Test that finalize preserves all existing content." + (with-temp-buffer + (org-mode) + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":PHONE: 555-1234\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":NICKNAME: Ali\n") + (insert ":NOTE: Met at conference\n") + (insert ":END:\n") + (insert "Added: [2025-11-01 Fri 20:30]\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; All properties should still be present + (should (string-search ":EMAIL: alice@example.com" content)) + (should (string-search ":PHONE: 555-1234" content)) + (should (string-search ":BIRTHDAY: 1985-03-15" content)) + (should (string-search ":NICKNAME: Ali" content)) + (should (string-search ":NOTE: Met at conference" content)) + ;; Added timestamp should still be there + (should (string-search "Added: [2025-11-01 Fri 20:30]" content)) + ;; Birthday timestamp should be added + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)))))) + +(provide 'test-org-contacts-capture-finalize) +;;; test-org-contacts-capture-finalize.el ends here diff --git a/tests/test-transcription-audio-file.el b/tests/test-transcription-audio-file.el new file mode 100644 index 00000000..f40d9ca6 --- /dev/null +++ b/tests/test-transcription-audio-file.el @@ -0,0 +1,83 @@ +;;; test-transcription-audio-file.el --- Tests for audio file detection -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--audio-file-p function +;; Categories: Normal cases, Boundary cases, Error cases + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--audio-file-p-m4a () + "Test that .m4a files are recognized as audio." + (should (cj/--audio-file-p "meeting.m4a"))) + +(ert-deftest test-cj/--audio-file-p-mp3 () + "Test that .mp3 files are recognized as audio." + (should (cj/--audio-file-p "podcast.mp3"))) + +(ert-deftest test-cj/--audio-file-p-wav () + "Test that .wav files are recognized as audio." + (should (cj/--audio-file-p "recording.wav"))) + +(ert-deftest test-cj/--audio-file-p-flac () + "Test that .flac files are recognized as audio." + (should (cj/--audio-file-p "music.flac"))) + +(ert-deftest test-cj/--audio-file-p-with-path () + "Test audio file recognition with full path." + (should (cj/--audio-file-p "/home/user/recordings/meeting.m4a"))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--audio-file-p-uppercase-extension () + "Test that uppercase extensions are recognized." + (should (cj/--audio-file-p "MEETING.M4A"))) + +(ert-deftest test-cj/--audio-file-p-mixed-case () + "Test that mixed case extensions are recognized." + (should (cj/--audio-file-p "podcast.Mp3"))) + +(ert-deftest test-cj/--audio-file-p-no-extension () + "Test that files without extension are not recognized." + (should-not (cj/--audio-file-p "meeting"))) + +(ert-deftest test-cj/--audio-file-p-empty-string () + "Test that empty string is not recognized as audio." + (should-not (cj/--audio-file-p ""))) + +(ert-deftest test-cj/--audio-file-p-dotfile () + "Test that dotfiles without proper extension are not recognized." + (should-not (cj/--audio-file-p ".hidden"))) + +(ert-deftest test-cj/--audio-file-p-multiple-dots () + "Test file with multiple dots but audio extension." + (should (cj/--audio-file-p "meeting.2025-11-04.final.m4a"))) + +;; ------------------------------ Error Cases ---------------------------------- + +(ert-deftest test-cj/--audio-file-p-not-audio () + "Test that non-audio files are not recognized." + (should-not (cj/--audio-file-p "document.pdf"))) + +(ert-deftest test-cj/--audio-file-p-text-file () + "Test that text files are not recognized as audio." + (should-not (cj/--audio-file-p "notes.txt"))) + +(ert-deftest test-cj/--audio-file-p-org-file () + "Test that org files are not recognized as audio." + (should-not (cj/--audio-file-p "tasks.org"))) + +(ert-deftest test-cj/--audio-file-p-video-file () + "Test that video files are not recognized as audio." + (should-not (cj/--audio-file-p "video.mp4"))) + +(ert-deftest test-cj/--audio-file-p-nil () + "Test that nil input returns nil." + (should-not (cj/--audio-file-p nil))) + +(provide 'test-transcription-audio-file) +;;; test-transcription-audio-file.el ends here diff --git a/tests/test-transcription-config--transcription-script-path.el b/tests/test-transcription-config--transcription-script-path.el new file mode 100644 index 00000000..a56cb05c --- /dev/null +++ b/tests/test-transcription-config--transcription-script-path.el @@ -0,0 +1,106 @@ +;;; test-transcription-config--transcription-script-path.el --- Tests for cj/--transcription-script-path -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--transcription-script-path function from transcription-config.el +;; +;; This function returns the absolute path to the transcription script based on +;; the current value of cj/transcribe-backend. + +;;; Code: + +(require 'ert) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Stub notification function +(unless (fboundp 'notifications-notify) + (defun notifications-notify (&rest _args) + "Stub notification function for testing." + nil)) + +;; Now load the actual production module +(require 'transcription-config) + +;;; Setup and Teardown + +(defun test-transcription-script-path-setup () + "Set up test environment." + ;; Save original backend setting + (setq test-transcription-original-backend cj/transcribe-backend)) + +(defun test-transcription-script-path-teardown () + "Clean up test environment." + ;; Restore original backend setting + (setq cj/transcribe-backend test-transcription-original-backend)) + +;;; Normal Cases + +(ert-deftest test-transcription-config--transcription-script-path-normal-openai-api-returns-oai-transcribe () + "Should return oai-transcribe script path for openai-api backend." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'openai-api) + (let ((result (cj/--transcription-script-path))) + (should (stringp result)) + (should (string-suffix-p "scripts/oai-transcribe" result)) + (should (string-prefix-p (expand-file-name user-emacs-directory) result)))) + (test-transcription-script-path-teardown))) + +(ert-deftest test-transcription-config--transcription-script-path-normal-assemblyai-returns-assemblyai-transcribe () + "Should return assemblyai-transcribe script path for assemblyai backend." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'assemblyai) + (let ((result (cj/--transcription-script-path))) + (should (stringp result)) + (should (string-suffix-p "scripts/assemblyai-transcribe" result)) + (should (string-prefix-p (expand-file-name user-emacs-directory) result)))) + (test-transcription-script-path-teardown))) + +(ert-deftest test-transcription-config--transcription-script-path-normal-local-whisper-returns-local-whisper () + "Should return local-whisper script path for local-whisper backend." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'local-whisper) + (let ((result (cj/--transcription-script-path))) + (should (stringp result)) + (should (string-suffix-p "scripts/local-whisper" result)) + (should (string-prefix-p (expand-file-name user-emacs-directory) result)))) + (test-transcription-script-path-teardown))) + +(ert-deftest test-transcription-config--transcription-script-path-normal-returns-absolute-path () + "Should return absolute path starting with user-emacs-directory." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'openai-api) + (let ((result (cj/--transcription-script-path))) + (should (file-name-absolute-p result)) + (should (string-prefix-p "/" result)))) + (test-transcription-script-path-teardown))) + +;;; Boundary Cases + +(ert-deftest test-transcription-config--transcription-script-path-boundary-path-format-consistent () + "Should return paths in consistent format across backends." + (test-transcription-script-path-setup) + (unwind-protect + (let (paths) + (dolist (backend '(openai-api assemblyai local-whisper)) + (setq cj/transcribe-backend backend) + (push (cj/--transcription-script-path) paths)) + ;; All paths should have same structure: <emacs-dir>/scripts/<name> + (should (= (length paths) 3)) + (should (seq-every-p (lambda (p) (string-match-p "/scripts/[^/]+$" p)) paths))) + (test-transcription-script-path-teardown))) + +(provide 'test-transcription-config--transcription-script-path) +;;; test-transcription-config--transcription-script-path.el ends here diff --git a/tests/test-transcription-counter.el b/tests/test-transcription-counter.el new file mode 100644 index 00000000..fae353ba --- /dev/null +++ b/tests/test-transcription-counter.el @@ -0,0 +1,98 @@ +;;; test-transcription-counter.el --- Tests for active transcription counting -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--count-active-transcriptions and modeline integration +;; Categories: Normal cases, Boundary cases + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--count-active-transcriptions-empty () + "Test count when no transcriptions are active." + (let ((cj/transcriptions-list '())) + (should (= 0 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-one-running () + "Test count with one running transcription." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (should (= 1 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-multiple-running () + "Test count with multiple running transcriptions." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil running) + (proc3 "file3.m4a" nil running)))) + (should (= 3 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-mixed-status () + "Test count excludes completed/errored transcriptions." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil complete) + (proc3 "file3.m4a" nil running) + (proc4 "file4.m4a" nil error)))) + (should (= 2 (cj/--count-active-transcriptions))))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--count-active-transcriptions-only-complete () + "Test count when all transcriptions are complete." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil complete) + (proc2 "file2.m4a" nil complete)))) + (should (= 0 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-only-error () + "Test count when all transcriptions errored." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil error) + (proc2 "file2.m4a" nil error)))) + (should (= 0 (cj/--count-active-transcriptions))))) + +;; ----------------------------- Modeline Tests -------------------------------- + +(ert-deftest test-cj/--transcription-modeline-string-none-active () + "Test modeline string when no transcriptions active." + (let ((cj/transcriptions-list '())) + (should-not (cj/--transcription-modeline-string)))) + +(ert-deftest test-cj/--transcription-modeline-string-one-active () + "Test modeline string with one active transcription." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should result) + (should (string-match-p "⏺1" result))))) + +(ert-deftest test-cj/--transcription-modeline-string-multiple-active () + "Test modeline string with multiple active transcriptions." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil running) + (proc3 "file3.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should result) + (should (string-match-p "⏺3" result))))) + +(ert-deftest test-cj/--transcription-modeline-string-has-help-echo () + "Test that modeline string has help-echo property." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should (get-text-property 0 'help-echo result))))) + +(ert-deftest test-cj/--transcription-modeline-string-has-face () + "Test that modeline string has warning face." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should (eq 'warning (get-text-property 0 'face result)))))) + +(provide 'test-transcription-counter) +;;; test-transcription-counter.el ends here diff --git a/tests/test-transcription-duration.el b/tests/test-transcription-duration.el new file mode 100644 index 00000000..370c439b --- /dev/null +++ b/tests/test-transcription-duration.el @@ -0,0 +1,58 @@ +;;; test-transcription-duration.el --- Tests for duration calculation -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--transcription-duration function +;; Categories: Normal cases, Boundary cases + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--transcription-duration-zero-seconds () + "Test duration calculation for current time (should be 00:00)." + (let ((now (current-time))) + (should (string= (cj/--transcription-duration now) "00:00")))) + +(ert-deftest test-cj/--transcription-duration-30-seconds () + "Test duration calculation for 30 seconds ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 30)))) + (should (string= (cj/--transcription-duration start-time) "00:30")))) + +(ert-deftest test-cj/--transcription-duration-1-minute () + "Test duration calculation for 1 minute ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 60)))) + (should (string= (cj/--transcription-duration start-time) "01:00")))) + +(ert-deftest test-cj/--transcription-duration-2-minutes-30-seconds () + "Test duration calculation for 2:30 ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 150)))) + (should (string= (cj/--transcription-duration start-time) "02:30")))) + +(ert-deftest test-cj/--transcription-duration-10-minutes () + "Test duration calculation for 10 minutes ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 600)))) + (should (string= (cj/--transcription-duration start-time) "10:00")))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--transcription-duration-59-seconds () + "Test duration just before 1 minute." + (let ((start-time (time-subtract (current-time) (seconds-to-time 59)))) + (should (string= (cj/--transcription-duration start-time) "00:59")))) + +(ert-deftest test-cj/--transcription-duration-1-hour () + "Test duration for 1 hour (60 minutes)." + (let ((start-time (time-subtract (current-time) (seconds-to-time 3600)))) + (should (string= (cj/--transcription-duration start-time) "60:00")))) + +(ert-deftest test-cj/--transcription-duration-format () + "Test that duration is always in MM:SS format with zero-padding." + (let ((start-time (time-subtract (current-time) (seconds-to-time 65)))) + (let ((result (cj/--transcription-duration start-time))) + (should (string-match-p "^[0-9][0-9]:[0-9][0-9]$" result))))) + +(provide 'test-transcription-duration) +;;; test-transcription-duration.el ends here diff --git a/tests/test-transcription-log-cleanup.el b/tests/test-transcription-log-cleanup.el new file mode 100644 index 00000000..82c902d8 --- /dev/null +++ b/tests/test-transcription-log-cleanup.el @@ -0,0 +1,44 @@ +;;; test-transcription-log-cleanup.el --- Tests for log cleanup logic -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--should-keep-log function +;; Categories: Normal cases, Boundary cases + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--should-keep-log-success-keep-disabled () + "Test that logs are deleted on success when keep-log is nil." + (let ((cj/transcription-keep-log-when-done nil)) + (should-not (cj/--should-keep-log t)))) + +(ert-deftest test-cj/--should-keep-log-success-keep-enabled () + "Test that logs are kept on success when keep-log is t." + (let ((cj/transcription-keep-log-when-done t)) + (should (cj/--should-keep-log t)))) + +(ert-deftest test-cj/--should-keep-log-error-keep-disabled () + "Test that logs are always kept on error, even if keep-log is nil." + (let ((cj/transcription-keep-log-when-done nil)) + (should (cj/--should-keep-log nil)))) + +(ert-deftest test-cj/--should-keep-log-error-keep-enabled () + "Test that logs are kept on error when keep-log is t." + (let ((cj/transcription-keep-log-when-done t)) + (should (cj/--should-keep-log nil)))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--should-keep-log-default-behavior () + "Test default behavior (should not keep on success)." + ;; Default is nil based on defcustom + (let ((cj/transcription-keep-log-when-done nil)) + (should-not (cj/--should-keep-log t)) + (should (cj/--should-keep-log nil)))) + +(provide 'test-transcription-log-cleanup) +;;; test-transcription-log-cleanup.el ends here diff --git a/tests/test-transcription-paths.el b/tests/test-transcription-paths.el new file mode 100644 index 00000000..5ee80e67 --- /dev/null +++ b/tests/test-transcription-paths.el @@ -0,0 +1,80 @@ +;;; test-transcription-paths.el --- Tests for transcription file path logic -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--transcription-output-files and cj/--transcription-script-path +;; Categories: Normal cases, Boundary cases, Error cases + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--transcription-output-files-simple () + "Test output file paths for simple filename." + (let ((result (cj/--transcription-output-files "meeting.m4a"))) + (should (string= (car result) "meeting.txt")) + (should (string= (cdr result) "meeting.log")))) + +(ert-deftest test-cj/--transcription-output-files-with-path () + "Test output file paths with full path." + (let ((result (cj/--transcription-output-files "/home/user/audio/podcast.mp3"))) + (should (string= (car result) "/home/user/audio/podcast.txt")) + (should (string= (cdr result) "/home/user/audio/podcast.log")))) + +(ert-deftest test-cj/--transcription-output-files-different-extensions () + "Test output files for various audio extensions." + (dolist (ext '("m4a" "mp3" "wav" "flac" "ogg")) + (let* ((input (format "audio.%s" ext)) + (result (cj/--transcription-output-files input))) + (should (string= (car result) "audio.txt")) + (should (string= (cdr result) "audio.log"))))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--transcription-output-files-multiple-dots () + "Test output files for filename with multiple dots." + (let ((result (cj/--transcription-output-files "meeting.2025-11-04.final.m4a"))) + (should (string= (car result) "meeting.2025-11-04.final.txt")) + (should (string= (cdr result) "meeting.2025-11-04.final.log")))) + +(ert-deftest test-cj/--transcription-output-files-no-extension () + "Test output files for filename without extension." + (let ((result (cj/--transcription-output-files "meeting"))) + (should (string= (car result) "meeting.txt")) + (should (string= (cdr result) "meeting.log")))) + +(ert-deftest test-cj/--transcription-output-files-spaces-in-name () + "Test output files for filename with spaces." + (let ((result (cj/--transcription-output-files "team meeting 2025.m4a"))) + (should (string= (car result) "team meeting 2025.txt")) + (should (string= (cdr result) "team meeting 2025.log")))) + +(ert-deftest test-cj/--transcription-output-files-special-chars () + "Test output files for filename with special characters." + (let ((result (cj/--transcription-output-files "meeting_(final).m4a"))) + (should (string= (car result) "meeting_(final).txt")) + (should (string= (cdr result) "meeting_(final).log")))) + +;; ----------------------------- Script Path Tests ----------------------------- + +(ert-deftest test-cj/--transcription-script-path-local-whisper () + "Test script path for local-whisper backend." + (let ((cj/transcribe-backend 'local-whisper)) + (should (string-suffix-p "scripts/local-whisper" + (cj/--transcription-script-path))))) + +(ert-deftest test-cj/--transcription-script-path-openai-api () + "Test script path for openai-api backend." + (let ((cj/transcribe-backend 'openai-api)) + (should (string-suffix-p "scripts/oai-transcribe" + (cj/--transcription-script-path))))) + +(ert-deftest test-cj/--transcription-script-path-absolute () + "Test that script path is absolute." + (let ((path (cj/--transcription-script-path))) + (should (file-name-absolute-p path)))) + +(provide 'test-transcription-paths) +;;; test-transcription-paths.el ends here diff --git a/tests/test-video-audio-recording-check-ffmpeg.el b/tests/test-video-audio-recording-check-ffmpeg.el new file mode 100644 index 00000000..5c264b64 --- /dev/null +++ b/tests/test-video-audio-recording-check-ffmpeg.el @@ -0,0 +1,46 @@ +;;; test-video-audio-recording-check-ffmpeg.el --- Tests for cj/recording-check-ffmpeg -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-check-ffmpeg function. +;; Tests detection of ffmpeg availability. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-check-ffmpeg-normal-ffmpeg-found-returns-t () + "Test that function returns t when ffmpeg is found." + (cl-letf (((symbol-function 'executable-find) + (lambda (cmd) + (when (equal cmd "ffmpeg") "/usr/bin/ffmpeg")))) + (let ((result (cj/recording-check-ffmpeg))) + (should (eq t result))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-check-ffmpeg-error-ffmpeg-not-found-signals-error () + "Test that function signals user-error when ffmpeg is not found." + (cl-letf (((symbol-function 'executable-find) + (lambda (_cmd) nil))) + (should-error (cj/recording-check-ffmpeg) :type 'user-error))) + +(ert-deftest test-video-audio-recording-check-ffmpeg-error-message-mentions-pacman () + "Test that error message includes installation command." + (cl-letf (((symbol-function 'executable-find) + (lambda (_cmd) nil))) + (condition-case err + (cj/recording-check-ffmpeg) + (user-error + (should (string-match-p "pacman -S ffmpeg" (error-message-string err))))))) + +(provide 'test-video-audio-recording-check-ffmpeg) +;;; test-video-audio-recording-check-ffmpeg.el ends here diff --git a/tests/test-video-audio-recording-detect-mic-device.el b/tests/test-video-audio-recording-detect-mic-device.el new file mode 100644 index 00000000..e95889e3 --- /dev/null +++ b/tests/test-video-audio-recording-detect-mic-device.el @@ -0,0 +1,152 @@ +;;; test-video-audio-recording-detect-mic-device.el --- Tests for cj/recording-detect-mic-device -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-detect-mic-device function. +;; Tests auto-detection of microphone input device from pactl output. +;; Mocks shell-command-to-string to test regex matching logic. +;; +;; IMPORTANT: These tests document actual behavior, which appears to have a bug. +;; The function currently returns the pactl ID number (e.g., "50") instead of +;; the device name (e.g., "alsa_input.pci-0000_00_1f.3.analog-stereo"). +;; This is because the regex captures group 1 is \\([^\t\n]+\\) which stops +;; at the first tab, capturing only the ID. +;; +;; This function may not be actively used (parse-sources is preferred). +;; Tests document current behavior to catch regressions if function is fixed. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-detect-mic-device-normal-built-in-analog-stereo-found () + "Test detection of built-in analog stereo microphone. +Note: Returns first match which is the monitor (ID 49), not the input." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (stringp result)) + ;; BUG: Returns first match "49" (monitor), not input "50" + (should (equal "49" result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-normal-usb-analog-stereo-found () + "Test detection of USB analog stereo microphone. +Note: Returns ID '100', not device name." + (let ((output "100\talsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (stringp result)) + ;; Current behavior: returns ID "100" + (should (equal "100" result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-normal-first-match-returned () + "Test that first matching device is returned when multiple exist. +Note: Returns first ID, not device name." + (let ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "100\talsa_input.usb-device.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; Current behavior: returns first ID "50" + (should (equal "50" result)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-empty-output-returns-nil () + "Test that empty output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-only-monitors-returns-nil () + "Test that output with only monitor devices still matches (documents bug). +Current regex doesn't exclude monitors, so this returns ID '49'." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; BUG: Should return nil for monitors, but regex doesn't exclude them + (should (equal "49" result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-mono-fallback-no-match () + "Test that mono-fallback device doesn't match (not stereo)." + (let ((output "100\talsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-bluetooth-no-match () + "Test that Bluetooth devices without 'analog stereo' don't match." + (let ((output "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-whitespace-only-returns-nil () + "Test that whitespace-only output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) " \n\t\n "))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-case-insensitive-analog () + "Test that 'ANALOG' (uppercase) matches (case-insensitive regex). +Documents that regex is actually case-insensitive." + (let ((output "50\talsa_input.pci-0000_00_1f.3.ANALOG-STEREO\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; Regex is case-insensitive, matches uppercase + (should (equal "50" result)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-detect-mic-device-error-malformed-output-returns-nil () + "Test that malformed output returns nil." + (let ((output "This is not valid pactl output\nRandom text here\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-error-partial-match-analog-only () + "Test that 'analog' without 'stereo' doesn't match." + (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-mono\tPipeWire\ts32le 1ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-error-partial-match-stereo-only () + "Test that 'stereo' without 'analog' doesn't match." + (let ((output "50\talsa_input.pci-0000_00_1f.3.digital-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-error-monitor-with-analog-stereo-matches-bug () + "Test that monitor device with 'analog stereo' incorrectly matches (documents bug). +Should return nil for monitors, but current regex doesn't filter them." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; BUG: Returns ID "49" even though this is a monitor (output device) + (should (equal "49" result)))))) + +(provide 'test-video-audio-recording-detect-mic-device) +;;; test-video-audio-recording-detect-mic-device.el ends here diff --git a/tests/test-video-audio-recording-detect-system-device.el b/tests/test-video-audio-recording-detect-system-device.el new file mode 100644 index 00000000..bea20e8a --- /dev/null +++ b/tests/test-video-audio-recording-detect-system-device.el @@ -0,0 +1,151 @@ +;;; test-video-audio-recording-detect-system-device.el --- Tests for cj/recording-detect-system-device -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-detect-system-device function. +;; Tests auto-detection of system audio monitor device from pactl output. +;; Mocks shell-command-to-string to test regex matching logic. +;; +;; NOTE: This function works correctly - returns the full device name ending in .monitor. +;; The regex \\([^\t\n]+\\.monitor\\) matches any non-tab/newline chars ending with .monitor, +;; which correctly captures the device name field from pactl output. +;; +;; This function may not be actively used (parse-sources is preferred). +;; Tests document current behavior to catch regressions. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-detect-system-device-normal-built-in-monitor-found () + "Test detection of built-in system audio monitor. +Returns full device name." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (stringp result)) + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-normal-usb-monitor-found () + "Test detection of USB system audio monitor." + (let ((output "99\talsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (stringp result)) + (should (equal "alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-normal-bluetooth-monitor-found () + "Test detection of Bluetooth monitor device." + (let ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (stringp result)) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-normal-first-match-returned () + "Test that first matching monitor is returned when multiple exist." + (let ((output (concat "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n" + "99\talsa_output.usb-device.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Returns first monitor device name + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-empty-output-returns-nil () + "Test that empty output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-detect-system-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-only-inputs-returns-nil () + "Test that output with only input devices (no monitors) returns nil." + (let ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-whitespace-only-returns-nil () + "Test that whitespace-only output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) " \n\t\n "))) + (let ((result (cj/recording-detect-system-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-monitor-different-states () + "Test that monitors in different states are all matched." + (let ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Should match regardless of state (RUNNING, SUSPENDED, IDLE) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-case-insensitive-monitor () + "Test that regex is case-insensitive for '.monitor' suffix. +Documents that .MONITOR (uppercase) also matches." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.MONITOR\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Case-insensitive: .MONITOR matches + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.MONITOR" result)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-detect-system-device-error-malformed-output-returns-nil () + "Test that malformed output returns nil." + (let ((output "This is not valid pactl output\nRandom text here\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-error-partial-monitor-matches () + "Test that device with .monitor in middle partially matches (documents quirk). +The regex matches up to first .monitor occurrence, even if not at end of device name." + (let ((output "50\talsa_input.monitor-device.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; QUIRK: Matches partial string "alsa_input.monitor" + (should (equal "alsa_input.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-error-incomplete-line () + "Test that incomplete lines with .monitor are still matched." + (let ((output "49\tincomplete-line.monitor\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Should match device name ending in .monitor + (should (equal "incomplete-line.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-error-mixed-valid-invalid () + "Test that mix of valid and invalid lines returns first valid monitor." + (let ((output (concat "invalid line without tabs\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "another invalid line\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result)))))) + +(provide 'test-video-audio-recording-detect-system-device) +;;; test-video-audio-recording-detect-system-device.el ends here diff --git a/tests/test-video-audio-recording-friendly-state.el b/tests/test-video-audio-recording-friendly-state.el new file mode 100644 index 00000000..91b47998 --- /dev/null +++ b/tests/test-video-audio-recording-friendly-state.el @@ -0,0 +1,65 @@ +;;; test-video-audio-recording-friendly-state.el --- Tests for cj/recording-friendly-state -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-friendly-state function. +;; Tests conversion of technical pactl state names to user-friendly labels. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-friendly-state-normal-suspended-returns-ready () + "Test that SUSPENDED state converts to Ready." + (should (string= "Ready" (cj/recording-friendly-state "SUSPENDED")))) + +(ert-deftest test-video-audio-recording-friendly-state-normal-running-returns-active () + "Test that RUNNING state converts to Active." + (should (string= "Active" (cj/recording-friendly-state "RUNNING")))) + +(ert-deftest test-video-audio-recording-friendly-state-normal-idle-returns-ready () + "Test that IDLE state converts to Ready." + (should (string= "Ready" (cj/recording-friendly-state "IDLE")))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-friendly-state-boundary-empty-string-returns-empty () + "Test that empty string passes through unchanged." + (should (string= "" (cj/recording-friendly-state "")))) + +(ert-deftest test-video-audio-recording-friendly-state-boundary-lowercase-suspended-returns-unchanged () + "Test that lowercase 'suspended' is not converted (case-sensitive)." + (should (string= "suspended" (cj/recording-friendly-state "suspended")))) + +(ert-deftest test-video-audio-recording-friendly-state-boundary-mixed-case-returns-unchanged () + "Test that mixed case 'Running' passes through unchanged." + (should (string= "Running" (cj/recording-friendly-state "Running")))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-friendly-state-error-unknown-state-returns-unchanged () + "Test that unknown state passes through unchanged." + (should (string= "UNKNOWN" (cj/recording-friendly-state "UNKNOWN")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-random-string-returns-unchanged () + "Test that random string passes through unchanged." + (should (string= "foobar" (cj/recording-friendly-state "foobar")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-numeric-string-returns-unchanged () + "Test that numeric string passes through unchanged." + (should (string= "12345" (cj/recording-friendly-state "12345")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-special-chars-returns-unchanged () + "Test that string with special characters passes through unchanged." + (should (string= "!@#$%" (cj/recording-friendly-state "!@#$%")))) + +(provide 'test-video-audio-recording-friendly-state) +;;; test-video-audio-recording-friendly-state.el ends here diff --git a/tests/test-video-audio-recording-get-devices.el b/tests/test-video-audio-recording-get-devices.el new file mode 100644 index 00000000..b1b8470b --- /dev/null +++ b/tests/test-video-audio-recording-get-devices.el @@ -0,0 +1,142 @@ +;;; test-video-audio-recording-get-devices.el --- Tests for cj/recording-get-devices -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-get-devices function. +;; Tests device auto-detection fallback logic. +;; +;; Note: This function has interactive prompts, but we test the core logic paths +;; without mocking y-or-n-p. We focus on testing: +;; - Already-set devices (no auto-detection needed) +;; - Successful auto-detection +;; - Failed auto-detection → error + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-get-devices-setup () + "Reset device variables before each test." + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +(defun test-get-devices-teardown () + "Clean up device variables after each test." + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-get-devices-normal-already-set-returns-devices () + "Test that already-set devices are returned without auto-detection." + (test-get-devices-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "test-mic") + (setq cj/recording-system-device "test-monitor") + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "test-mic" (car result))) + (should (equal "test-monitor" (cdr result))))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-normal-auto-detect-success () + "Test that auto-detection succeeds and returns devices." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () "auto-detected-mic")) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () "auto-detected-monitor"))) + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "auto-detected-mic" (car result))) + (should (equal "auto-detected-monitor" (cdr result))) + ;; Verify variables were set + (should (equal "auto-detected-mic" cj/recording-mic-device)) + (should (equal "auto-detected-monitor" cj/recording-system-device)))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-normal-partial-auto-detect () + "Test when only one device is already set, only the other is auto-detected." + (test-get-devices-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "preset-mic") + (cl-letf (((symbol-function 'cj/recording-detect-system-device) + (lambda () "auto-detected-monitor"))) + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "preset-mic" (car result))) + (should (equal "auto-detected-monitor" (cdr result)))))) + (test-get-devices-teardown))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-get-devices-error-auto-detect-fails-signals-error () + "Test that failed auto-detection signals user-error. +When auto-detection fails and user doesn't manually select, function errors." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () nil)) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () nil)) + ;; Mock y-or-n-p to say no to manual selection + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-only-mic-detected-signals-error () + "Test that detecting only mic (no monitor) signals error." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () "detected-mic")) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () nil)) + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-only-monitor-detected-signals-error () + "Test that detecting only monitor (no mic) signals error." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () nil)) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () "detected-monitor")) + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-message-mentions-select-devices () + "Test that error message guides user to manual selection command." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () nil)) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () nil)) + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (condition-case err + (cj/recording-get-devices) + (user-error + (should (string-match-p "cj/recording-select-devices" (error-message-string err)))))) + (test-get-devices-teardown))) + +(provide 'test-video-audio-recording-get-devices) +;;; test-video-audio-recording-get-devices.el ends here diff --git a/tests/test-video-audio-recording-group-devices-by-hardware.el b/tests/test-video-audio-recording-group-devices-by-hardware.el new file mode 100644 index 00000000..0abe5f6c --- /dev/null +++ b/tests/test-video-audio-recording-group-devices-by-hardware.el @@ -0,0 +1,194 @@ +;;; test-video-audio-recording-group-devices-by-hardware.el --- Tests for cj/recording-group-devices-by-hardware -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-group-devices-by-hardware function. +;; Tests grouping of audio sources by physical hardware device. +;; Critical test: Bluetooth MAC address normalization (colons vs underscores). +;; +;; This function is used by the quick setup command to automatically pair +;; microphone and monitor devices from the same hardware. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-all-types-grouped () + "Test grouping of all three device types (built-in, USB, Bluetooth). +This is the key test validating the complete grouping logic." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (= 3 (length result))) + ;; Check that we have all three device types + (let ((names (mapcar #'car result))) + (should (member "Built-in Laptop Audio" names)) + (should (member "Bluetooth Headset" names)) + (should (member "Jabra SPEAK 510 USB" names))) + ;; Verify each device has both mic and monitor + (dolist (device result) + (should (stringp (car device))) ; friendly name + (should (stringp (cadr device))) ; mic device + (should (stringp (cddr device))) ; monitor device + (should-not (string-suffix-p ".monitor" (cadr device))) ; mic not monitor + (should (string-suffix-p ".monitor" (cddr device)))))))) ; monitor has suffix + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-built-in-paired () + "Test that built-in laptop audio devices are correctly paired." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (built-in (assoc "Built-in Laptop Audio" result))) + (should built-in) + (should (string-match-p "pci-0000_00_1f" (cadr built-in))) + (should (string-match-p "pci-0000_00_1f" (cddr built-in))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (cadr built-in))) + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" (cddr built-in))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-usb-paired () + "Test that USB devices (Jabra) are correctly paired." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (jabra (assoc "Jabra SPEAK 510 USB" result))) + (should jabra) + (should (string-match-p "Jabra" (cadr jabra))) + (should (string-match-p "Jabra" (cddr jabra))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-bluetooth-paired () + "Test that Bluetooth devices are correctly paired. +CRITICAL: Tests MAC address normalization (colons in input, underscores in output)." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (bluetooth (assoc "Bluetooth Headset" result))) + (should bluetooth) + ;; Input has colons: bluez_input.00:1B:66:C0:91:6D + (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth))) + ;; Output has underscores: bluez_output.00_1B_66_C0_91_6D.1.monitor + ;; But they should still be grouped together (MAC address normalized) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth))))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-empty-returns-empty () + "Test that empty pactl output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-inputs-returns-empty () + "Test that only input devices (no monitors) returns empty list. +Devices must have BOTH mic and monitor to be included." + (let ((output (test-load-fixture "pactl-output-inputs-only.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-monitors-returns-empty () + "Test that only monitor devices (no inputs) returns empty list." + (let ((output (test-load-fixture "pactl-output-monitors-only.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-single-complete-device () + "Test that single device with both mic and monitor is returned." + (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + (should (equal "Built-in Laptop Audio" (caar result))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-mixed-complete-incomplete () + "Test that only devices with BOTH mic and monitor are included. +Incomplete devices (only mic or only monitor) are filtered out." + (let ((output (concat + ;; Complete device (built-in) + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + ;; Incomplete: USB mic with no monitor + "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n" + ;; Incomplete: Bluetooth monitor with no mic + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + ;; Only the complete built-in device should be returned + (should (= 1 (length result))) + (should (equal "Built-in Laptop Audio" (caar result))))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-malformed-output-returns-empty () + "Test that malformed pactl output returns empty list." + (let ((output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-unknown-device-type () + "Test that unknown device types get generic 'USB Audio Device' name." + (let ((output (concat + "100\talsa_input.usb-unknown_device-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n" + "99\talsa_output.usb-unknown_device-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + ;; Should get generic USB name (not matching Jabra pattern) + (should (equal "USB Audio Device" (caar result))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-bluetooth-mac-case-variations () + "Test that Bluetooth MAC addresses work with different formatting. +Tests the normalization logic handles various MAC address formats." + (let ((output (concat + ;; Input with colons (typical) + "79\tbluez_input.AA:BB:CC:DD:EE:FF\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + ;; Output with underscores (typical) + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + (should (equal "Bluetooth Headset" (caar result))) + ;; Verify both devices paired despite different MAC formats + (let ((device (car result))) + (should (string-match-p "AA:BB:CC" (cadr device))) + (should (string-match-p "AA_BB_CC" (cddr device)))))))) + +(provide 'test-video-audio-recording-group-devices-by-hardware) +;;; test-video-audio-recording-group-devices-by-hardware.el ends here diff --git a/tests/test-video-audio-recording-parse-pactl-output.el b/tests/test-video-audio-recording-parse-pactl-output.el new file mode 100644 index 00000000..db49a897 --- /dev/null +++ b/tests/test-video-audio-recording-parse-pactl-output.el @@ -0,0 +1,157 @@ +;;; test-video-audio-recording-parse-pactl-output.el --- Tests for cj/recording--parse-pactl-output -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording--parse-pactl-output function. +;; Tests parsing of pactl sources output into structured data. +;; Uses fixture files with sample pactl output for reproducible testing. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-all-devices-returns-list () + "Test parsing normal pactl output with all device types." + (let* ((output (test-load-fixture "pactl-output-normal.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 6 (length result))) + ;; Check first device (built-in monitor) + (should (equal '("alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + "PipeWire" + "SUSPENDED") + (nth 0 result))) + ;; Check Bluetooth input + (should (equal '("bluez_input.00:1B:66:C0:91:6D" + "PipeWire" + "SUSPENDED") + (nth 2 result))) + ;; Check USB device + (should (equal '("alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback" + "PipeWire" + "SUSPENDED") + (nth 5 result))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-single-device-returns-list () + "Test parsing output with single device." + (let* ((output (test-load-fixture "pactl-output-single.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 1 (length result))) + (should (equal '("alsa_input.pci-0000_00_1f.3.analog-stereo" + "PipeWire" + "SUSPENDED") + (car result))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-monitors-only-returns-list () + "Test parsing output with only monitor devices." + (let* ((output (test-load-fixture "pactl-output-monitors-only.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 3 (length result))) + ;; All should end with .monitor + (dolist (device result) + (should (string-suffix-p ".monitor" (car device)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-inputs-only-returns-list () + "Test parsing output with only input devices." + (let* ((output (test-load-fixture "pactl-output-inputs-only.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 3 (length result))) + ;; None should end with .monitor + (dolist (device result) + (should-not (string-suffix-p ".monitor" (car device)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-empty-string-returns-empty-list () + "Test parsing empty string returns empty list." + (let ((result (cj/recording--parse-pactl-output ""))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-empty-file-returns-empty-list () + "Test parsing empty file returns empty list." + (let* ((output (test-load-fixture "pactl-output-empty.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-whitespace-only-returns-empty-list () + "Test parsing whitespace-only string returns empty list." + (let ((result (cj/recording--parse-pactl-output " \n\t\n "))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-single-newline-returns-empty-list () + "Test parsing single newline returns empty list." + (let ((result (cj/recording--parse-pactl-output "\n"))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-device-with-running-state-parsed () + "Test that RUNNING state (not just SUSPENDED) is parsed correctly." + (let* ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n") + (result (cj/recording--parse-pactl-output output))) + (should (= 1 (length result))) + (should (equal "RUNNING" (nth 2 (car result)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-device-with-idle-state-parsed () + "Test that IDLE state is parsed correctly." + (let* ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tIDLE\n") + (result (cj/recording--parse-pactl-output output))) + (should (= 1 (length result))) + (should (equal "IDLE" (nth 2 (car result)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-malformed-lines-ignored () + "Test that malformed lines are silently ignored." + (let* ((output (test-load-fixture "pactl-output-malformed.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (null result)))) ; All lines malformed, so empty list + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-mixed-valid-invalid-returns-valid () + "Test that mix of valid and invalid lines returns only valid ones." + (let* ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "This is invalid\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + "Also invalid\n")) + (result (cj/recording--parse-pactl-output output))) + (should (= 2 (length result))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (car (nth 0 result)))) + (should (equal "bluez_input.00:1B:66:C0:91:6D" (car (nth 1 result)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-missing-fields-ignored () + "Test that lines with missing fields are ignored." + (let* ((output "50\tincomplete-line\tPipeWire\n") ; Missing state and format + (result (cj/recording--parse-pactl-output output))) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-nil-input-returns-error () + "Test that nil input signals an error." + (should-error (cj/recording--parse-pactl-output nil))) + +(provide 'test-video-audio-recording-parse-pactl-output) +;;; test-video-audio-recording-parse-pactl-output.el ends here diff --git a/tests/test-video-audio-recording-parse-sources.el b/tests/test-video-audio-recording-parse-sources.el new file mode 100644 index 00000000..d6d445b5 --- /dev/null +++ b/tests/test-video-audio-recording-parse-sources.el @@ -0,0 +1,98 @@ +;;; test-video-audio-recording-parse-sources.el --- Tests for cj/recording-parse-sources -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-parse-sources function. +;; Tests the wrapper that calls pactl and delegates to internal parser. +;; Mocks shell-command-to-string to avoid system dependencies. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-parse-sources-normal-calls-pactl-and-parses () + "Test that parse-sources calls shell command and returns parsed list." + (let ((fixture-output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (= 6 (length result))) + ;; Verify it returns structured data + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + (car (nth 0 result)))) + (should (equal "PipeWire" (nth 1 (nth 0 result)))) + (should (equal "SUSPENDED" (nth 2 (nth 0 result)))))))) + +(ert-deftest test-video-audio-recording-parse-sources-normal-single-device-returns-list () + "Test parse-sources with single device." + (let ((fixture-output (test-load-fixture "pactl-output-single.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (= 1 (length result))))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-parse-sources-boundary-empty-output-returns-empty-list () + "Test that empty pactl output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result))))) + +(ert-deftest test-video-audio-recording-parse-sources-boundary-whitespace-output-returns-empty-list () + "Test that whitespace-only output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) " \n\t\n "))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-parse-sources-error-malformed-output-returns-empty-list () + "Test that malformed output is handled gracefully." + (let ((fixture-output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-parse-sources-error-mixed-valid-invalid-returns-valid-only () + "Test that mix of valid and invalid lines returns only valid entries." + (let ((mixed-output (concat + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "invalid line\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) mixed-output))) + (let ((result (cj/recording-parse-sources))) + (should (= 2 (length result))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (car (nth 0 result)))) + (should (equal "bluez_input.00:1B:66:C0:91:6D" (car (nth 1 result)))))))) + +(provide 'test-video-audio-recording-parse-sources) +;;; test-video-audio-recording-parse-sources.el ends here @@ -17,7 +17,7 @@ If the answer is "no" to all five → DON'T ADD IT. V2MOM is located at: [[file:docs/emacs-config-v2mom.org][emacs-config-v2mom.org]] Research/ideas that don't serve vision: [[file:docs/someday-maybe.org][someday-maybe.org]] -* Method 1: Make Using Emacs Frictionless +* Method 1: Make Using Emacs Frictionless [8/14] ** DONE [#A] Remove network check from startup (saves 1+ seconds) CLOSED: [2025-10-31 Fri] @@ -25,50 +25,259 @@ CLOSED: [2025-10-31 Fri] ✅ Deleted blocking ping check. Startup improved from 6.2s to 5.4s. Now uses package priorities: .localrepo (200) > online repos (25). -** TODO [#A] Fix cj/goto-git-gutter-diff-hunks (missing function causing errors) +** DONE [#A] Fix cj/goto-git-gutter-diff-hunks (missing function causing errors) +CLOSED: [2025-11-03 Sun] -Quick win - 15 minutes. Just needs to be defined. +✅ Implemented missing function in modules/vc-config.el +- Added `cj/goto-git-gutter-diff-hunks` function (lines 118-124) +- Uses consult-line to search for diff markers (^[+\-]) +- Enables interactive jumping to any changed line via C-; v d +- Function properly documented with docstring +- No linting issues, compiles cleanly -** TODO [#A] Fix chime-check throw with no catch block +Quick win completed - 5 minutes actual time. + +** DONE [#A] Fix chime-check throw with no catch block +CLOSED: [2025-11-01 Fri] Error: "(no-catch --cl-block-chime-check-- nil)" -Chime is throwing to a catch block that doesn't exist. -Location: chime package - chime-check() function -Fix: Change defun to cl-defun to get implicit catch block, or wrap in catch block. +Chime was throwing to a catch block that doesn't exist. +Location: chime package - chime-check() function at line 1540 +Fix: Changed defun to cl-defun to provide implicit catch block for cl-return-from. -** TODO [#B] Fix go-ts-mode-map keybinding error (void-variable) +Fixed in ~/code/chime.el/chime.el:1540 -Error: "Debugger entered--Lisp error: (void-variable go-ts-mode-map)" -Location: modules/prog-go.el - trying to bind keys before mode loads. -Fix: Wrap keybinding in with-eval-after-load or check if variable is bound. +Added regression test: test-integration-startup-early-return-on-validation-failure +Created Makefile with test targets (unit, integration, all, by-file, by-name) +Updated README.org with Development/Testing section + +All integration tests pass (5/5) ✅ ** DONE [#A] Implement cj/diff-buffer-with-file (compare buffer with saved version) CLOSED: [2025-10-31 Fri] Bound to C-; b D. Weekly need satisfied. -** TODO [#B] Optimize org-agenda performance using built-in profiler +** DONE [#B] Fix go-ts-mode-map keybinding error (void-variable) +CLOSED: [2025-11-03 Sun] -THE BOTTLENECK. Currently 30+ seconds, target < 5 seconds. -Use M-x profiler-start before Method 3 debug-profiling.el is built. +Error: "Debugger entered--Lisp error: (void-variable go-ts-mode-map)" +Location: modules/prog-go.el - trying to bind keys before mode loads. + +✅ Already fixed in commit 196b289 (Nov 2, 2025) +- Moved keybinding from `:bind (:map go-ts-mode-map ...)` to hook function +- Keybinding now set in `cj/go-mode-keybindings` called via `:hook` +- Function executes after mode loads, ensuring keymap exists +- Today's cleanup: Removed unused forward declarations (lines 34-35) + +Fix was: Wrap keybinding in hook function instead of :bind clause. +Result: No more void-variable error, keybinding works correctly. ** TODO [#B] Fix org-noter (reading/annotation workflow currently "so painful") High priority - daily pain point. -** TODO [#B] Fix video/audio recording module (use constantly, just broke) - -Use constantly, needs to work reliably. +** DONE [#B] Fix video/audio recording module (use constantly, just broke) +CLOSED: [2025-11-03 Sun] + +Main issue: No way to select audio devices when multiple are available. +Plugging in external audio interface broke recording - only captured input, not output. + +✅ COMPLETED - Module now has robust device detection and selection. + +*** DONE [#A] Add diagnostic command cj/recording-list-devices +CLOSED: [2025-11-03 Sun] + +✅ Created `cj/recording-list-devices` command (C-; r d) +Shows ALL available PulseAudio/PipeWire sources with drivers and states. +Displays current configuration. +Helps debug why auto-detection fails. + +*** DONE [#A] Add device selection UI +CLOSED: [2025-11-03 Sun] + +✅ Created TWO selection workflows: +1. `cj/recording-select-devices` (C-; r s) - Full manual control + - Select mic and system audio separately + - Interactive completion with device states +2. `cj/recording-quick-setup-for-calls` (C-; r c) - Quick call setup + - Smart device pairing (groups mic + monitor by hardware) + - One selection for both mic and system audio + - Handles Bluetooth MAC normalization + +Devices cached in variables for future recordings. +Can switch devices without restarting Emacs. + +*** DONE [#B] Improve error messages +CLOSED: [2025-11-03 Sun] + +✅ Error messages now include: +- Guidance to run `cj/recording-select-devices` +- Clear indication when auto-detection fails +- User-friendly prompts for manual selection + +*** DONE [#B] Make device detection more flexible +CLOSED: [2025-11-03 Sun] + +✅ Implemented multi-level fallback system: +1. Auto-detect using pactl output parsing +2. Prompt user to select manually if auto-detect fails +3. Error with helpful guidance if user declines + +✅ Smart device grouping in `cj/recording-group-devices-by-hardware`: +- Handles USB, PCI (built-in), and Bluetooth devices +- Normalizes Bluetooth MAC addresses (colons ↔ underscores) +- Assigns friendly names (e.g., "Built-in Laptop Audio", "Bluetooth Headset") +- Filters incomplete devices (must have both mic and monitor) + +✅ Supports both PulseAudio and PipeWire (both use pactl). + +*** TODO [#B] Validate recording startup +Check process status after starting. +Parse ffmpeg output for errors. +Show actual ffmpeg command for debugging. + +** DONE [#A] Add comprehensive test coverage for video-audio-recording module +CLOSED: [2025-11-03 Sun] + +✅ Created comprehensive test suite following quality-engineer.org standards: +- 9 test files (8 unit + 1 integration) +- 83 test cases with 100% pass rate +- Test categories: Normal, Boundary, Error cases +- 6 test fixtures for reproducible testing +- Mocking strategy using cl-letf +- Integration test validates complete parse→group→friendly-names workflow +- Tests document bugs in legacy detect functions +- Critical validation: Bluetooth MAC normalization across component boundaries + +✅ Refactored code for testability: +- Extracted `cj/recording--parse-pactl-output` internal parser +- Separated I/O from business logic + +✅ Fixed all linting warnings (checkdoc): +- 6 spacing/grammar issues resolved +- `make lint` now passes cleanly + +✅ All changes committed and pushed to all remotes (origin + github) + +Commits: +- 0a69c58: test: Add comprehensive test suite for video-audio-recording module +- b086539: style: Fix checkdoc warnings in video-audio-recording.el + +*** TODO [#C] Add device testing command cj/recording-test-devices +Records 3 seconds of audio. +Plays it back. +Confirms devices work before real recording. + +*** TODO [#C] Add recording status display (optional via flag, default off) +Show "Recording: 00:05:23" in modeline or echo area. +Timer showing duration. +File size updating. + +*** TODO [#C] Add recording presets +Screencast (video + audio, high quality). +Podcast (audio only, voice optimized). +Meeting (balanced, lower filesize). +Quick note (audio, low quality, small file). + +*** TODO [#C] Build recording history buffer +*Recordings* buffer showing history. +Duration, file size, location. +Quick actions: play, delete, rename, move. + +*** TODO [#C] Add post-processing hooks +Auto-compress after recording. +Move to cloud sync directory. +Generate transcript (once transcription workflow exists). ** TODO [#B] Fix mail attachment workflow (currently awkward) Daily workflow improvement. -** TODO [#C] Fix grammar checker performance (currently disabled) +** TODO [#A] Fix Google Calendar password prompts every 15 minutes + +IRRITANT: gcal-sync triggers password prompts approximately every 15 minutes, +interrupting workflow and breaking focus. This defeats the purpose of having +passphrase caching configured. + +**Current Setup:** +- GPG agent configured with 400-day cache (gpg-agent.conf): + - default-cache-ttl 34560000 + - max-cache-ttl 34560000 + - allow-loopback-pinentry enabled +- Plstore caching enabled (auth-config.el:54): + - plstore-cache-passphrase-for-symmetric-encryption t + - plstore-encrypt-to nil (symmetric encryption) +- Auth-source cache: 24 hours (auth-config.el:31) +- Auto-sync interval: 30 minutes (org-gcal-config.el:50) + +**Problem:** +Despite proper GPG agent caching, oauth2-auto.plist prompts for passphrase +every ~15 minutes during gcal-sync operations. This suggests: +1. plstore may not be using GPG agent cache properly for symmetric encryption +2. oauth2-auto token refresh might be bypassing cache +3. EPinentry mode may need explicit configuration (currently commented out) + +**Goal:** +Passphrase should be entered ONCE per Emacs session, then cached until Emacs +closes. No interruptions during normal work. + +**Investigation Paths:** +1. Check if oauth2-auto respects plstore passphrase caching +2. Investigate plstore symmetric encryption cache behavior with GPG agent +3. Test enabling epa-pinentry-mode 'loopback (auth-config.el:42) +4. Check oauth2-auto token refresh cycle vs password prompt timing +5. Consider oauth2-auto configuration options for token persistence +6. Review org-gcal or oauth2-auto issues for similar problems + +**Files:** +- modules/auth-config.el (plstore and GPG configuration) +- modules/org-gcal-config.el (org-gcal and oauth2-auto setup) +- ~/.gnupg/gpg-agent.conf (GPG agent cache settings) +- oauth2-auto.plist (encrypted OAuth tokens - prompts every access?) + +**Related:** +This violates the "Frictionless" value - interruptions every 15 minutes during +calendar sync breaks concentration and workflow momentum. + +** TODO [#B] Optimize org-agenda performance using built-in profiler + +THE BOTTLENECK. Currently 30+ seconds, target < 5 seconds. +Use M-x profiler-start before Method 3 debug-profiling.el is built. + +** TODO [#B] Optimize org-capture target building performance + +15-20 seconds every time capturing a task (12+ times/day). +Major daily bottleneck - minutes lost waiting, plus context switching cost. + +** DONE [#C] Fix grammar checker performance (currently disabled) +CLOSED: [2025-11-04 Mon] + +✅ **Installed and configured LanguageTool for comprehensive grammar checking** + +Replaced disabled grammar checker with on-demand LanguageTool integration: +- Installed LanguageTool 6.6-2 from Arch repos (222MB) +- Created wrapper script: scripts/languagetool-flycheck (Python 3) +- Integrated with flycheck for on-demand checking via C-; ? +- Removed proselint (redundant - LanguageTool catches more) +- No performance impact: only runs when explicitly invoked +- Installation instructions added to modules/flycheck-config.el commentary -Currently disabled because it breaks flow when writing. +LanguageTool catches: +- Real grammar errors (subject-verb agreement, tense, etc.) +- Missing punctuation (commas, periods) +- Common mistakes (could of → could have) +- Style issues (redundant phrases, wordiness) -* Method 2: Stop Problems Before They Appear +Workflow: Open org/text/markdown file → press C-; ? → see errors in *Flycheck errors* buffer + +** TODO [#D] Fix EMMS keybinding inconsistency with other buffers + +EMMS keybindings conflict with standard buffer keybindings, causing mistypes. +Results in accidental destructive actions (clearing buffers), requires undo + context switch. +Violates Intuitive value - muscle memory should help, not hurt. + +* Method 2: Stop Problems Before They Appear [3/5] ** TODO [#B] Migrate from Company to Corfu :PROPERTIES: @@ -77,29 +286,72 @@ Currently disabled because it breaks flow when writing. Complete config already exists in someday-maybe.org. Just needs to be executed. -** TODO [#C] Switch to mood-line +** DONE [#C] Switch to mood-line (actually: built custom modeline) +CLOSED: [2025-11-03 Sun] + +✅ **Built custom modeline (better than mood-line)** + +Went beyond mood-line and created a fully custom modeline using only built-in +Emacs functionality to avoid native-compilation issues. Features: + +**Architecture:** +- Named segment system using defvar-local (easy to reorder) +- Emacs 30 built-in right-alignment (mode-line-format-right-align) +- All segments marked as risky-local-variable for proper evaluation -Already researched. Lighter modeline. +**Interactive Features:** +- Color-coded buffer names (green=writeable, red=read-only, gold=overwrite) +- VC branch with git symbol () and state-based coloring +- Mouse click handlers: buffer name (prev/next), branch (vc-diff), mode (describe-mode) +- Help-echo tooltips on all segments +- String truncation for narrow windows (< 100 chars wide) +- Active-window-only display for branch and misc-info (less clutter) -** TODO [#C] Remove deprecated tree-sitter package (rely on treesit-auto) +**Layout:** +- Left: Major mode, buffer name, position (L:# C:#) +- Right: Git branch, chime notifications -Already have treesit-auto installed. Just remove old package. +**Bug Fixes:** +- Disabled async native compilation (prevented "Selecting deleted buffer" errors) +- Fixed difftastic loading (:demand → :defer) +- Abstracted buffer status colors to user-constants.el -** TODO [#C] Add org-appear (show emphasis markers only when point is on them) +Inspired by Prot's modeline design. See commit c0c4b17. -Cleaner org files, supports Intuitive value. +** DONE [#C] Remove deprecated tree-sitter package (rely on treesit-auto) +CLOSED: [2025-11-03 Sun] + +✅ Removed deprecated tree-sitter package from modules/prog-general.el +- Deleted `(use-package tree-sitter)` on line 97 +- Now relies solely on Emacs 29+ built-in treesit with treesit-auto +- treesit-auto continues to manage tree-sitter grammars automatically +- No functionality lost, cleaner dependency tree + +** DONE [#C] Add org-appear (show emphasis markers only when point is on them) +CLOSED: [2025-11-03 Sun] + +✅ Added org-appear package to modules/org-config.el +- Shows emphasis markers (* / _) only when cursor is on them +- Also shows link markup and sub/superscripts when editing +- Changed org-fontify-emphasized-text from nil to t (line 75) +- Updated comment on line 71 to reference org-appear +- Cleaner editing experience while maintaining visual clarity ** TODO [#C] Integrate prescient with Corfu (smart sorting) Already using prescient with vertico. Extend to Corfu after migration. -* Method 3: Make *Fixing* Emacs Frictionless +* Method 3: Make *Fixing* Emacs Frictionless [1/4] ** TODO [#B] Build debug-profiling.el module Reusable profiling infrastructure for any future performance work. -** TODO [#C] Document localrepo limitations (treesitter grammars not included) +** TODO [#C] Build localrepo out (package snapshot system) + +Repeatable installs and safe rollbacks. + +*** TODO [#C] Document localrepo limitations (treesitter grammars not included) .localrepo only contains packages from package.el archives. Treesitter grammars are downloaded separately by treesit-auto on first use. @@ -109,15 +361,48 @@ For true offline reproducibility, need to cache treesitter grammars separately. Complex workflow testing capability. -** TODO [#C] Build localrepo out (package snapshot system) +** DONE [#C] Integrate difftastic (structural diffs) +CLOSED: [2025-11-03 Sun] -Repeatable installs and safe rollbacks. +✅ Completed difftastic integration: +1. **Upgraded cj/diff-buffer-with-file to use ediff** (modules/custom-buffer-file.el) + - Replaced basic unified diff with ediff-current-file + - Now uses same ediff config (horizontal split, j/k navigation) + - Keybinding C-; b D now launches interactive ediff + +2. **Added difftastic for git diffs** (modules/vc-config.el) + - Installed binary: difftastic 0.64.0 + - Added difftastic.el package integrated with magit + - Keybindings in magit-diff: D for dwim, S for show + - Provides structural, language-aware diffs for git changes + +Result: Better diffs everywhere - ediff for interactive buffer comparison, +difftastic for understanding git changes. -** TODO [#C] Integrate difftastic (structural diffs) +** TODO [#C] Remove orphaned dwim-shell-security tests and unused production code -Structural diffs that show semantic changes. Hook into cj/diff-buffer-with-file. +Why: 12 tests in test-dwim-shell-security.el fail because the functions they test +are inside a use-package :config block (dwim-shell-config.el:101-108) that only +loads when the dwim-shell-command package is available. During batch testing, +the package isn't loaded, so functions are never defined (void-function errors). -* Method 4: Contribute to the Emacs Ecosystem +These are PDF password protection and ZIP encryption functions that likely have +never been used in practice - they're placeholder code from initial setup. + +What to delete: +1. Test file: tests/test-dwim-shell-security.el (12 failing tests) +2. Production functions in modules/dwim-shell-config.el (lines ~302-347): + - cj/dwim-shell-commands-pdf-password-protect (lines 302-324) + - cj/dwim-shell-commands-pdf-password-unprotect (lines 326-347) + - cj/dwim-shell-commands-create-encrypted-zip (search for it) + - cj/dwim-shell-commands-remove-zip-encryption (search for it) + +After deletion: Run "make test-all" to confirm 18 failures → 6 failures +(only benchmark performance tests remain, which are environment-dependent). + +Aligns with: Reducing test failures from 18 to 6, cleaning up unused code. + +* Method 4: Contribute to the Emacs Ecosystem [0/4] ** TODO [#C] Set up package-lint for elisp linting (chime, org-msg, wttrin) @@ -135,7 +420,7 @@ Zero-config CI for Emacs packages. Measure and track test coverage over time. -* Method 5: Be Kind To Your Future Self +* Method 5: Be Kind To Your Future Self [0/2] ** TODO [#C] Add transcription workflow :PROPERTIES: @@ -148,7 +433,7 @@ Complete code already exists in someday-maybe.org. Need today and recurring. Create reveal.js slides from org-mode. -* Method 6: Develop Disciplined Engineering Practices +* Method 6: Develop Disciplined Engineering Practices [0/3] ** TODO [#A] First weekly triage by Sunday (establish habit) SCHEDULED: <2025-11-03 Sun> @@ -165,9 +450,4 @@ Review this inbox, cancel stale items, keep < 20 active. Track in calendar. Can't research next thing until current thing is implemented. * Emacs Config Inbox -** TODO [#B] Performance: Speed First Org Capture Target Building -** TODO [#B] Irritant: Press Key to Launch Dashboard Icon App -** TODO [#C] Irritant: Consistent Keybindings in EMMS Playlist with Buffer - -** TODO [#A] BREAK: Fix Chime (currently disabled) -** TODO [#B] Irritant: Move Persistence Files Into a Single Directory +** TODO cj/flyspell-then-abbrev loses keybinding in scratch org-mode buffer |
