summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-12 12:45:40 -0500
committerCraig Jennings <c@cjennings.net>2026-05-12 12:45:40 -0500
commit162d52dfc5a401c95dcbb6f5630d4373568a70e6 (patch)
tree2a4cbc2822f614a7e6a4a517cfad76125e3e036e
parentf49a863a0cb75beeb2dbed6ae9b3df245d2336e8 (diff)
downloaddotemacs-162d52dfc5a401c95dcbb6f5630d4373568a70e6.tar.gz
dotemacs-162d52dfc5a401c95dcbb6f5630d4373568a70e6.zip
feat(org-drill): drill any Org file from anywhere
`org-drill` has no fixed home — with `org-drill-scope` left at its default it just drills the current buffer. So the only thing in my config tied to a location was `cj/drill-start`, which forced a pick from `drill-dir`. `C-; D f` (`cj/drill-this-file`) drills whatever Org buffer is current, so a drill file living anywhere works. It `user-error`s when the buffer isn't an Org buffer. `C-u C-; D s` (and `C-u C-; D e`) now prompts for the directory to pick from instead of always using `drill-dir`. Bare `C-; D s` is unchanged. I pulled the picking logic into `cj/--drill-files-in`, `cj/--drill-pick-dir`, and `cj/--drill-pick-file` so it's unit-testable. New `tests/test-org-drill-config.el`: 12 ERT tests over those helpers, `cj/drill-this-file`, `cj/drill-start`, and the keymap.
-rw-r--r--modules/org-drill-config.el56
-rw-r--r--tests/test-org-drill-config.el152
2 files changed, 193 insertions, 15 deletions
diff --git a/modules/org-drill-config.el b/modules/org-drill-config.el
index 2fe49a37..8555d30f 100644
--- a/modules/org-drill-config.el
+++ b/modules/org-drill-config.el
@@ -3,7 +3,9 @@
;;; Commentary:
;;
;; Notes: Org-Drill
-;; Start out your org-drill with C-d s, then select your file.
+;; `C-; D s' picks a flashcard file from `drill-dir' and starts a session;
+;; `C-u C-; D s' lets you pick the directory first. `C-; D f' drills
+;; whatever Org file is current, so any drill file anywhere works.
;; Capture templates:
;; - "d" for web/EPUB drill captures (uses %i for selected text, %:link for source)
@@ -42,20 +44,42 @@
(setq org-drill-use-variable-pitch t) ;; use variable-pitch font for readability
(setq org-drill-hide-modeline-during-session t) ;; hide modeline for cleaner display
- (defun cj/drill-start ()
- "Prompt user to pick a drill org file, then start an org-drill session."
+ (defun cj/--drill-files-in (dir)
+ "Return the drill Org file names directly inside DIR (no leading dots)."
+ (directory-files dir nil "^[^.].*\\.org$"))
+
+ (defun cj/--drill-pick-file (dir)
+ "Prompt for one of the drill Org files in DIR; return its absolute path."
+ (expand-file-name
+ (completing-read "Choose flashcard file: " (cj/--drill-files-in dir) nil t)
+ dir))
+
+ (defun cj/--drill-pick-dir (other-dir)
+ "Return the directory to pick drill files from.
+With OTHER-DIR non-nil, prompt for one; otherwise use `drill-dir'."
+ (if other-dir (read-directory-name "Drill files in: ") drill-dir))
+
+ (defun cj/drill-start (&optional other-dir)
+ "Pick a drill Org file and start an `org-drill' session.
+With a prefix arg OTHER-DIR, prompt for the directory to choose from
+instead of the default `drill-dir'."
+ (interactive "P")
+ (find-file (cj/--drill-pick-file (cj/--drill-pick-dir other-dir)))
+ (org-drill))
+
+ (defun cj/drill-this-file ()
+ "Start an `org-drill' session on the current Org buffer.
+Use this to drill any drill file you have open, wherever it lives."
(interactive)
- (let* ((choices (directory-files drill-dir nil "^[^.].*\\.org$"))
- (chosen-drill-file (completing-read "Choose Flashcard File:" choices)))
- (find-file (concat drill-dir chosen-drill-file))
- (org-drill)))
+ (unless (derived-mode-p 'org-mode)
+ (user-error "Not an Org buffer -- visit a `.org' file first"))
+ (org-drill))
- (defun cj/drill-edit ()
- "Prompts the user to pick a drill org file, then opens it for editing."
- (interactive)
- (let* ((choices (directory-files drill-dir nil "^[^.].*\\.org$"))
- (chosen-drill-file (completing-read "Choose Flashcard File:" choices)))
- (find-file (concat drill-dir chosen-drill-file))))
+ (defun cj/drill-edit (&optional other-dir)
+ "Pick a drill Org file and open it for editing.
+With a prefix arg OTHER-DIR, prompt for the directory instead of `drill-dir'."
+ (interactive "P")
+ (find-file (cj/--drill-pick-file (cj/--drill-pick-dir other-dir))))
(defun cj/drill-capture ()
"Quickly capture a drill question."
@@ -75,6 +99,7 @@
(defvar-keymap cj/drill-map
:doc "Keymap for org-drill"
"s" #'cj/drill-start
+ "f" #'cj/drill-this-file
"e" #'cj/drill-edit
"c" #'cj/drill-capture
"r" #'cj/drill-refile
@@ -84,8 +109,9 @@
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
"C-; D" "org-drill menu"
- "C-; D s" "start drill"
- "C-; D e" "edit drill file"
+ "C-; D s" "start drill (C-u: pick dir)"
+ "C-; D f" "drill current file"
+ "C-; D e" "edit drill file (C-u: pick dir)"
"C-; D c" "capture question"
"C-; D r" "refile to drill"
"C-; D R" "resume drill")))
diff --git a/tests/test-org-drill-config.el b/tests/test-org-drill-config.el
new file mode 100644
index 00000000..d3057de2
--- /dev/null
+++ b/tests/test-org-drill-config.el
@@ -0,0 +1,152 @@
+;;; test-org-drill-config.el --- Tests for org-drill navigation helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Covers the file/directory picking in `modules/org-drill-config.el':
+;; - `cj/--drill-files-in' (pure: list drill Org files in a dir)
+;; - `cj/--drill-pick-dir' (pure: default `drill-dir' vs prompted dir)
+;; - `cj/--drill-pick-file' (prompt -> absolute path, `completing-read' stubbed)
+;; - `cj/drill-this-file' (drill the current Org buffer / refuse otherwise)
+;; - `cj/drill-start' (pick + `find-file' + `org-drill', boundaries stubbed)
+;; - the `cj/drill-map' bindings
+;;
+;; The defuns live inside the `use-package org-drill' `:config' block, which
+;; runs once `org' and `org-capture' are loaded -- so those are required first.
+;; `cj/custom-keymap' and `drill-dir' are stubbed the way the other module
+;; tests stub the constants/keymaps their modules expect from the init layer.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'package)
+(package-initialize)
+(require 'org)
+(require 'org-capture)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub custom keymap for org-drill-config tests.")
+(defvar drill-dir (file-name-as-directory
+ (expand-file-name "cj-drill-default" temporary-file-directory))
+ "Stub `drill-dir' for org-drill-config tests.")
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-drill-config)
+
+;; -------------------------------- temp-dir help ------------------------------
+
+(defmacro test-org-drill--with-dir (var &rest body)
+ "Bind VAR to a fresh temp directory, run BODY, then delete it."
+ (declare (indent 1))
+ `(let ((,var (file-name-as-directory (make-temp-file "cj-drill-test" t))))
+ (unwind-protect (progn ,@body)
+ (delete-directory ,var t))))
+
+(defun test-org-drill--touch (dir &rest names)
+ "Create empty files NAMES inside DIR."
+ (dolist (n names)
+ (with-temp-file (expand-file-name n dir))))
+
+;; ----------------------------- cj/--drill-files-in ---------------------------
+
+(ert-deftest test-org-drill--drill-files-in-lists-org-files ()
+ "Normal: returns the `.org' file names in the directory, sorted."
+ (test-org-drill--with-dir dir
+ (test-org-drill--touch dir "b.org" "a.org")
+ (should (equal '("a.org" "b.org") (cj/--drill-files-in dir)))))
+
+(ert-deftest test-org-drill--drill-files-in-empty-dir ()
+ "Boundary: an empty directory yields nil."
+ (test-org-drill--with-dir dir
+ (should (null (cj/--drill-files-in dir)))))
+
+(ert-deftest test-org-drill--drill-files-in-skips-dotfiles-and-non-org ()
+ "Boundary: leading-dot files and non-`.org' files are excluded."
+ (test-org-drill--with-dir dir
+ (test-org-drill--touch dir ".hidden.org" "notes.txt" "cards.org" "deck.org.bak")
+ (should (equal '("cards.org") (cj/--drill-files-in dir)))))
+
+(ert-deftest test-org-drill--drill-files-in-missing-dir-signals ()
+ "Error: a directory that does not exist signals."
+ (should-error (cj/--drill-files-in "/no/such/cj-drill/dir/")))
+
+;; ----------------------------- cj/--drill-pick-dir ---------------------------
+
+(ert-deftest test-org-drill--drill-pick-dir-defaults-to-drill-dir ()
+ "Normal: with no prefix arg the picker uses `drill-dir'."
+ (should (equal drill-dir (cj/--drill-pick-dir nil))))
+
+(ert-deftest test-org-drill--drill-pick-dir-prompts-with-prefix ()
+ "Normal: with a prefix arg the picker prompts for a directory."
+ (cl-letf (((symbol-function 'read-directory-name)
+ (lambda (&rest _) "/tmp/other-decks/")))
+ (should (equal "/tmp/other-decks/" (cj/--drill-pick-dir t)))))
+
+;; ----------------------------- cj/--drill-pick-file --------------------------
+
+(ert-deftest test-org-drill--drill-pick-file-returns-absolute-path ()
+ "Normal: the chosen name is expanded against the directory."
+ (test-org-drill--with-dir dir
+ (test-org-drill--touch dir "spanish.org")
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "spanish.org")))
+ (should (equal (expand-file-name "spanish.org" dir)
+ (cj/--drill-pick-file dir))))))
+
+;; ----------------------------- cj/drill-this-file ----------------------------
+
+(ert-deftest test-org-drill-this-file-drills-an-org-buffer ()
+ "Normal: in an Org buffer it starts a drill session."
+ (let ((called 0))
+ (cl-letf (((symbol-function 'org-drill) (lambda (&rest _) (cl-incf called))))
+ (with-temp-buffer
+ (delay-mode-hooks (org-mode))
+ (cj/drill-this-file)))
+ (should (= 1 called))))
+
+(ert-deftest test-org-drill-this-file-refuses-a-non-org-buffer ()
+ "Error: outside an Org buffer it raises a `user-error' and drills nothing."
+ (let ((called 0))
+ (cl-letf (((symbol-function 'org-drill) (lambda (&rest _) (cl-incf called))))
+ (with-temp-buffer
+ (fundamental-mode)
+ (should-error (cj/drill-this-file) :type 'user-error)))
+ (should (= 0 called))))
+
+;; ------------------------------- cj/drill-start ------------------------------
+
+(ert-deftest test-org-drill-start-opens-the-pick-and-drills ()
+ "Normal: opens the picked file, then starts a drill session."
+ (let (opened (drilled 0))
+ (cl-letf (((symbol-function 'cj/--drill-pick-file)
+ (lambda (_dir) "/decks/french.org"))
+ ((symbol-function 'find-file) (lambda (f) (setq opened f)))
+ ((symbol-function 'org-drill) (lambda (&rest _) (cl-incf drilled))))
+ (cj/drill-start))
+ (should (equal "/decks/french.org" opened))
+ (should (= 1 drilled))))
+
+(ert-deftest test-org-drill-start-with-prefix-uses-the-prompted-dir ()
+ "Normal: a prefix arg routes the file pick through the prompted directory."
+ (test-org-drill--with-dir dir
+ (test-org-drill--touch dir "latin.org")
+ (let (opened)
+ (cl-letf (((symbol-function 'read-directory-name) (lambda (&rest _) dir))
+ ((symbol-function 'completing-read) (lambda (&rest _) "latin.org"))
+ ((symbol-function 'find-file) (lambda (f) (setq opened f)))
+ ((symbol-function 'org-drill) #'ignore))
+ (cj/drill-start t))
+ (should (equal (expand-file-name "latin.org" dir) opened)))))
+
+;; -------------------------------- cj/drill-map -------------------------------
+
+(ert-deftest test-org-drill-map-bindings ()
+ "Normal: the drill keymap exposes the documented commands."
+ (dolist (b '(("s" . cj/drill-start)
+ ("f" . cj/drill-this-file)
+ ("e" . cj/drill-edit)
+ ("c" . cj/drill-capture)
+ ("r" . cj/drill-refile)
+ ("R" . org-drill-resume)))
+ (should (eq (cdr b) (keymap-lookup cj/drill-map (car b))))))
+
+(provide 'test-org-drill-config)
+;;; test-org-drill-config.el ends here