diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-12 12:45:40 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-12 12:45:40 -0500 |
| commit | 162d52dfc5a401c95dcbb6f5630d4373568a70e6 (patch) | |
| tree | 2a4cbc2822f614a7e6a4a517cfad76125e3e036e | |
| parent | f49a863a0cb75beeb2dbed6ae9b3df245d2336e8 (diff) | |
| download | dotemacs-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.el | 56 | ||||
| -rw-r--r-- | tests/test-org-drill-config.el | 152 |
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 |
