aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-27 01:11:35 -0500
committerCraig Jennings <c@cjennings.net>2026-05-27 01:11:35 -0500
commit1f362535a109939081a9a65a4601add87afc052d (patch)
treec7f8b70acda4b97b165ecbc7a28fbb01c4b45c12
parent7eece407772d7c5cfba93ba914439094f0d9fbf2 (diff)
downloadorg-drill-1f362535a109939081a9a65a4601add87afc052d.tar.gz
org-drill-1f362535a109939081a9a65a4601add87afc052d.zip
feat: undo last rating, customizable keys, and configurable text limit
A batch of self-contained user-facing improvements, squashed from the feat/org-drill-solo-features branch. I added an undo for the last rating (issue #2 follow-up). The rating prompt now takes an undo key (org-drill--undo-key, default u): it restores the previous card's scheduling snapshot, drops the recorded quality, and re-queues that card, then returns to the current prompt. Each rating snapshots the scheduling properties and SCHEDULED line onto a per-session stack capped at org-drill-undo-limit (default 3). org-drill-reschedule loops on the rating read so undo doesn't rate the current card. I made the five session-control keys (quit, edit, help, skip, tags) defcustoms so they can be rebound from customize-group (issue #35), keeping their defaults. The 0-5 rating keys stay as-is, since they're tied to the quality scale rather than being variables. I lifted the hardcoded 100-line entry-text limit in org-drill-get-entry-text into the org-drill-entry-text-max-lines defcustom, defaulting to 100. I also deleted a commented-out old org-entry-empty-p that the real definition had already replaced. Existing tests stay green and each change added its own, including snapshot/restore and prompt-loop tests for undo.
-rw-r--r--org-drill.el191
-rw-r--r--tests/test-org-drill-entry-text-limit.el34
-rw-r--r--tests/test-org-drill-session-keys.el33
-rw-r--r--tests/test-org-drill-undo.el114
4 files changed, 323 insertions, 49 deletions
diff --git a/org-drill.el b/org-drill.el
index 74cbc6c..2bea27e 100644
--- a/org-drill.el
+++ b/org-drill.el
@@ -307,18 +307,42 @@ This is buffer-local variable.")
This is a buffer-local variable.")
-;; Variables defining what keys can be pressed during drill sessions to quit the
-;; session, edit the item, etc.
-(defvar org-drill--quit-key ?q
- "Character to quit the session.")
-(defvar org-drill--edit-key ?e
- "Character to suspend the session.")
-(defvar org-drill--help-key ??
- "Character to show help.")
-(defvar org-drill--skip-key ?s
- "Character to skip to the next item.")
-(defvar org-drill--tags-key ?t
- "Character to edit the tags.")
+;; Keys pressed during a drill session to quit, edit the item, etc.
+;; These are defcustoms so they can be rebound from customize-group.
+(defcustom org-drill--quit-key ?q
+ "Character to quit the session."
+ :group 'org-drill-session
+ :type 'character)
+(defcustom org-drill--edit-key ?e
+ "Character to suspend the session."
+ :group 'org-drill-session
+ :type 'character)
+(defcustom org-drill--help-key ??
+ "Character to show help."
+ :group 'org-drill-session
+ :type 'character)
+(defcustom org-drill--skip-key ?s
+ "Character to skip to the next item."
+ :group 'org-drill-session
+ :type 'character)
+(defcustom org-drill--tags-key ?t
+ "Character to edit the tags."
+ :group 'org-drill-session
+ :type 'character)
+(defcustom org-drill--undo-key ?u
+ "Character to undo the most recent rating during a session.
+Pressing it at the rating prompt restores the previous card's
+scheduling data and re-queues that card (see `org-drill-undo-last-rating')."
+ :group 'org-drill-session
+ :type 'character)
+
+(defcustom org-drill-undo-limit
+ 3
+ "How many recent ratings can be undone with `org-drill--undo-key'.
+Each rating snapshots the card's scheduling state; only this many of the
+most recent snapshots are kept."
+ :group 'org-drill-session
+ :type 'integer)
(defcustom org-drill-card-type-alist
'((nil org-drill-present-simple-card)
@@ -667,6 +691,10 @@ interval was greater than ORG-DRILL-DAYS-BEFORE-OLD days.")
(failed-entries :initform nil)
(again-entries :initform nil)
(done-entries :initform nil)
+ (undo-stack
+ :initform nil
+ :documentation "Stack of pre-rating scheduling snapshots, most recent
+first, used by `org-drill-undo-last-rating'. Capped at `org-drill-undo-limit'.")
(current-item
:initform nil
:documentation "Set to the marker for the item currently being tested.")
@@ -1563,14 +1591,16 @@ Shared by `org-drill-reschedule' and `org-drill-leitner-rebox'."
(typed-answer-statement (if typed-answer
(format "Your answer: %s\n" typed-answer)
""))
- (key-prompt (format "(0-5, %c=help, %c=edit, %c=tags, %c=quit)"
+ (key-prompt (format "(0-5, %c=help, %c=edit, %c=tags, %c=undo, %c=quit)"
org-drill--help-key
org-drill--edit-key
org-drill--tags-key
+ org-drill--undo-key
org-drill--quit-key)))
(save-excursion
(while (not (memq ch (list org-drill--quit-key
org-drill--edit-key
+ org-drill--undo-key
7 ; C-g
?0 ?1 ?2 ?3 ?4 ?5)))
(run-hooks 'org-drill-display-answer-hook)
@@ -1602,6 +1632,55 @@ Shared by `org-drill-reschedule' and `org-drill-leitner-rebox'."
(org-set-tags-command))))
ch))
+(defun org-drill--snapshot-entry-data ()
+ "Capture the scheduling state of the entry at point for undo.
+Returns (MARKER . DATA), where MARKER points at the entry heading and
+DATA is an alist mapping each scheduling property (and the special
+`scheduled' key) to its current value, or nil when unset. Restore it
+with `org-drill--restore-entry-data'."
+ (cons (save-excursion (org-back-to-heading t) (point-marker))
+ (cons (cons 'scheduled (org-entry-get (point) "SCHEDULED"))
+ (mapcar (lambda (prop)
+ (cons prop (org-entry-get (point) prop)))
+ org-drill-scheduling-properties))))
+
+(defun org-drill--restore-entry-data (snapshot)
+ "Restore the entry scheduling state captured in SNAPSHOT.
+A property absent at snapshot time is deleted; the SCHEDULED line is put
+back, or removed if there was none."
+ (org-with-point-at (car snapshot)
+ (dolist (cell (cdr snapshot))
+ (cond
+ ((eq (car cell) 'scheduled)
+ (if (cdr cell)
+ (org-schedule nil (cdr cell))
+ (org-schedule '(4))))
+ ((cdr cell)
+ (org-set-property (car cell) (cdr cell)))
+ (t
+ (org-delete-property (car cell)))))))
+
+(defun org-drill--push-undo-snapshot (session)
+ "Snapshot the entry at point onto SESSION's undo stack, capped at
+`org-drill-undo-limit'."
+ (push (org-drill--snapshot-entry-data) (oref session undo-stack))
+ (when (> (length (oref session undo-stack)) org-drill-undo-limit)
+ (setf (oref session undo-stack)
+ (cl-subseq (oref session undo-stack) 0 org-drill-undo-limit))))
+
+(defun org-drill-undo-last-rating (session)
+ "Undo the most recent rating in SESSION.
+Restore the card's pre-rating scheduling data, drop the recorded quality,
+and re-queue the card so it is presented again. Does nothing when there
+is nothing to undo."
+ (let ((snapshot (pop (oref session undo-stack))))
+ (if (null snapshot)
+ (message "Nothing to undo")
+ (org-drill--restore-entry-data snapshot)
+ (pop (oref session qualities))
+ (push (car snapshot) (oref session again-entries))
+ (message "Undid the last rating; that card will come around again"))))
+
(defun org-drill-reschedule (session)
"Return qualityrating (0-5), or nil if the user quit."
(let* ((next-review-dates (org-drill-hypothetical-next-review-dates))
@@ -1616,39 +1695,47 @@ Shared by `org-drill-reschedule' and `org-drill-leitner-rebox'."
5 - You remembered the item really easily. (+%s days)"
(round (nth 3 next-review-dates))
(round (nth 4 next-review-dates))
- (round (nth 5 next-review-dates))))
- (ch (org-drill--read-rating-key (oref session typed-answer)
- rating-help)))
- (cond
- ((and (>= ch ?0) (<= ch ?5))
- (let ((quality (- ch ?0))
- (failures (org-drill-entry-failure-count)))
- (unless (oref session cram-mode)
- (save-excursion
- (let ((quality (if (org-drill--entry-lapsed-p session) 2 quality)))
- (org-drill-smart-reschedule quality
- (nth quality next-review-dates))))
- (push quality (oref session qualities))
+ (round (nth 5 next-review-dates)))))
+ (cl-block org-drill-reschedule
+ ;; Loop so the undo key can take back the previous rating and then
+ ;; return us to the prompt for the current card.
+ (while t
+ (let ((ch (org-drill--read-rating-key (oref session typed-answer)
+ rating-help)))
(cond
- ((org-drill--quality-failed-p quality)
- (when org-drill-leech-failure-threshold
- (if (> (1+ failures) org-drill-leech-failure-threshold)
- (org-toggle-tag "leech" 'on))))
+ ((eql ch org-drill--undo-key)
+ (org-drill-undo-last-rating session))
+ ((and (>= ch ?0) (<= ch ?5))
+ (let ((quality (- ch ?0))
+ (failures (org-drill-entry-failure-count)))
+ (unless (oref session cram-mode)
+ ;; Snapshot the pre-rating state so this rating can be undone.
+ (org-drill--push-undo-snapshot session)
+ (save-excursion
+ (let ((quality (if (org-drill--entry-lapsed-p session) 2 quality)))
+ (org-drill-smart-reschedule quality
+ (nth quality next-review-dates))))
+ (push quality (oref session qualities))
+ (cond
+ ((org-drill--quality-failed-p quality)
+ (when org-drill-leech-failure-threshold
+ (if (> (1+ failures) org-drill-leech-failure-threshold)
+ (org-toggle-tag "leech" 'on))))
+ (t
+ (let ((scheduled-time (org-get-scheduled-time (point))))
+ (when scheduled-time
+ (message "Next review in %d days"
+ (- (time-to-days scheduled-time)
+ (time-to-days (current-time))))
+ (sit-for 0.5)))))
+ (org-set-property "DRILL_LAST_QUALITY" (format "%d" quality))
+ (org-set-property "DRILL_LAST_REVIEWED"
+ (org-drill-time-to-inactive-org-timestamp (current-time))))
+ (cl-return-from org-drill-reschedule quality)))
+ ((eql ch org-drill--edit-key)
+ (cl-return-from org-drill-reschedule 'edit))
(t
- (let ((scheduled-time (org-get-scheduled-time (point))))
- (when scheduled-time
- (message "Next review in %d days"
- (- (time-to-days scheduled-time)
- (time-to-days (current-time))))
- (sit-for 0.5)))))
- (org-set-property "DRILL_LAST_QUALITY" (format "%d" quality))
- (org-set-property "DRILL_LAST_REVIEWED"
- (org-drill-time-to-inactive-org-timestamp (current-time))))
- quality))
- ((= ch org-drill--edit-key)
- 'edit)
- (t
- nil))))
+ (cl-return-from org-drill-reschedule nil))))))))
(defun org-drill-hide-subheadings-if (test)
"TEST is a function taking no arguments. TEST will be called for each
@@ -2216,17 +2303,22 @@ Note: does not actually alter the item."
(when (eql 'org-drill-cloze-overlay-defaults (overlay-get ovl 'category))
(delete-overlay ovl)))))
+(defcustom org-drill-entry-text-max-lines
+ 100
+ "Maximum number of lines of an entry's body text org-drill collects.
+Used by `org-drill-get-entry-text', for example when echoing the next
+Leitner item. Raise it for decks with very long card bodies."
+ :group 'org-drill-session
+ :type 'integer)
+
(defun org-drill-get-entry-text (&optional keep-properties-p)
"Return the text of the current entry."
- (let ((text (org-agenda-get-some-entry-text (point-marker) 100)))
+ (let ((text (org-agenda-get-some-entry-text
+ (point-marker) org-drill-entry-text-max-lines)))
(if keep-properties-p
text
(substring-no-properties text))))
-;; (defun org-entry-empty-p ()
-;; (zerop (length (org-drill-get-entry-text))))
-
-;; This version is about 5x faster than the old version, above.
(defun org-drill-entry-empty-p ()
"Return non-nil if the current entry is empty.
@@ -3162,6 +3254,7 @@ CRAM, if non-nil, starts the session in cram mode."
(oref session old-mature-entries) nil
(oref session failed-entries) nil
(oref session again-entries) nil
+ (oref session undo-stack) nil
(oref session start-time) (float-time (current-time))))
(defun org-drill--collect-entries (session scope drill-match)
diff --git a/tests/test-org-drill-entry-text-limit.el b/tests/test-org-drill-entry-text-limit.el
new file mode 100644
index 0000000..c9466cc
--- /dev/null
+++ b/tests/test-org-drill-entry-text-limit.el
@@ -0,0 +1,34 @@
+;;; test-org-drill-entry-text-limit.el --- Tests for the entry-text line limit -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; org-drill-get-entry-text asks org-agenda for at most N lines of an entry's
+;; text. That limit used to be a hardcoded 100; it is now the defcustom
+;; org-drill-entry-text-max-lines so a user with very long cards can raise it.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org-drill)
+
+(ert-deftest test-org-drill-entry-text-max-lines-defaults-to-100 ()
+ "The limit keeps its historical default of 100 lines."
+ (should (eq 100 (default-value 'org-drill-entry-text-max-lines))))
+
+(ert-deftest test-org-drill-get-entry-text-uses-the-custom-limit ()
+ "get-entry-text passes the defcustom value as the line limit, not a
+hardcoded number."
+ (let ((org-drill-entry-text-max-lines 7)
+ (captured nil))
+ (cl-letf (((symbol-function 'org-agenda-get-some-entry-text)
+ (lambda (_marker n &rest _) (setq captured n) "text")))
+ (with-temp-buffer
+ (insert "* Card :drill:\nbody\n")
+ (org-mode)
+ (goto-char (point-min))
+ (org-drill-get-entry-text))
+ (should (eq 7 captured)))))
+
+(provide 'test-org-drill-entry-text-limit)
+
+;;; test-org-drill-entry-text-limit.el ends here
diff --git a/tests/test-org-drill-session-keys.el b/tests/test-org-drill-session-keys.el
new file mode 100644
index 0000000..ff1b4b1
--- /dev/null
+++ b/tests/test-org-drill-session-keys.el
@@ -0,0 +1,33 @@
+;;; test-org-drill-session-keys.el --- Tests for customizable session keys -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The session-control keys (quit/edit/help/skip/tags) are defcustoms so they
+;; can be rebound through customize-group (upstream issue #35), while keeping
+;; their historical default characters.
+
+;;; Code:
+
+(require 'ert)
+(require 'org-drill)
+
+(defconst test-org-drill-session-key-defaults
+ '((org-drill--quit-key . ?q)
+ (org-drill--edit-key . ?e)
+ (org-drill--help-key . ??)
+ (org-drill--skip-key . ?s)
+ (org-drill--tags-key . ?t))
+ "Each session-control key variable and its historical default character.")
+
+(ert-deftest test-org-drill-session-keys-are-customizable ()
+ "Each session-control key is a defcustom, so customize-group can set it."
+ (dolist (cell test-org-drill-session-key-defaults)
+ (should (custom-variable-p (car cell)))))
+
+(ert-deftest test-org-drill-session-keys-keep-their-defaults ()
+ "Promoting to defcustom does not change the default bindings."
+ (dolist (cell test-org-drill-session-key-defaults)
+ (should (eq (cdr cell) (default-value (car cell))))))
+
+(provide 'test-org-drill-session-keys)
+
+;;; test-org-drill-session-keys.el ends here
diff --git a/tests/test-org-drill-undo.el b/tests/test-org-drill-undo.el
new file mode 100644
index 0000000..a5727e4
--- /dev/null
+++ b/tests/test-org-drill-undo.el
@@ -0,0 +1,114 @@
+;;; test-org-drill-undo.el --- Tests for undo/retry of the last rating -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Undo lets the user take back a misclicked rating. Before a rating is
+;; applied, the entry's scheduling state is snapshotted onto the session's
+;; undo-stack (capped at org-drill-undo-limit). org-drill-undo-last-rating
+;; restores that state, drops the recorded quality, and re-queues the card so
+;; it comes around again.
+
+;;; Code:
+
+(require 'ert)
+(require 'org-drill)
+
+;;;; defcustoms
+
+(ert-deftest test-org-drill-undo-limit-defaults-to-3 ()
+ (should (eq 3 (default-value 'org-drill-undo-limit))))
+
+(ert-deftest test-org-drill-undo-key-is-customizable-and-defaults-to-u ()
+ (should (custom-variable-p 'org-drill--undo-key))
+ (should (eq ?u (default-value 'org-drill--undo-key))))
+
+;;;; snapshot / restore round-trip
+
+(ert-deftest test-org-drill-snapshot-restore-round-trips-scheduling-data ()
+ "Restoring a snapshot puts changed properties back to their captured values."
+ (with-temp-buffer
+ (insert "* Card :drill:\n")
+ (org-mode)
+ (goto-char (point-min))
+ (org-set-property "DRILL_LAST_QUALITY" "4")
+ (org-set-property "DRILL_EASE" "2.5")
+ (let ((snap (org-drill--snapshot-entry-data)))
+ (org-set-property "DRILL_LAST_QUALITY" "1")
+ (org-set-property "DRILL_EASE" "1.8")
+ (org-drill--restore-entry-data snap)
+ (should (equal "4" (org-entry-get (point) "DRILL_LAST_QUALITY")))
+ (should (equal "2.5" (org-entry-get (point) "DRILL_EASE"))))))
+
+(ert-deftest test-org-drill-restore-deletes-properties-absent-at-snapshot ()
+ "A property that did not exist when snapshotted is removed on restore."
+ (with-temp-buffer
+ (insert "* Card :drill:\n")
+ (org-mode)
+ (goto-char (point-min))
+ (let ((snap (org-drill--snapshot-entry-data)))
+ (org-set-property "DRILL_LAST_QUALITY" "3")
+ (org-drill--restore-entry-data snap)
+ (should (null (org-entry-get (point) "DRILL_LAST_QUALITY"))))))
+
+;;;; org-drill-undo-last-rating
+
+(ert-deftest test-org-drill-undo-last-rating-restores-pops-and-requeues ()
+ "Undo restores the entry data, drops the recorded quality, and re-queues
+the card onto the again list."
+ (with-temp-buffer
+ (insert "* Card :drill:\n")
+ (org-mode)
+ (goto-char (point-min))
+ (org-set-property "DRILL_LAST_QUALITY" "4")
+ (let ((session (org-drill-session)))
+ (push (org-drill--snapshot-entry-data) (oref session undo-stack))
+ (org-set-property "DRILL_LAST_QUALITY" "1")
+ (push 1 (oref session qualities))
+ (org-drill-undo-last-rating session)
+ (should (equal "4" (org-entry-get (point) "DRILL_LAST_QUALITY")))
+ (should (null (oref session qualities)))
+ (should (= 1 (length (oref session again-entries)))))))
+
+(ert-deftest test-org-drill-undo-last-rating-empty-stack-is-noop ()
+ "Undo with nothing recorded does not error and changes no session state."
+ (let ((session (org-drill-session)))
+ (org-drill-undo-last-rating session)
+ (should (null (oref session qualities)))
+ (should (null (oref session again-entries)))))
+
+;;;; undo-key routing through the rating prompt
+
+(ert-deftest test-org-drill-reschedule-undo-key-takes-back-previous-rating ()
+ "Pressing the undo key at the prompt restores the previous card and
+re-queues it, then the prompt loop continues (here, a quit ends it).
+
+Components integrated:
+- org-drill-reschedule (entry point, real)
+- org-drill--read-rating-key (MOCKED — returns undo key then quit key)
+- org-drill-undo-last-rating + snapshot/restore (real)
+
+Validates the undo key is handled in the prompt loop rather than treated
+as a rating, and that the loop keeps prompting afterward."
+ (with-temp-buffer
+ (insert "* Prev :drill:\n* Current :drill:\n")
+ (org-mode)
+ (let ((session (org-drill-session)))
+ (goto-char (point-min))
+ (org-set-property "DRILL_LAST_QUALITY" "5")
+ (push (org-drill--snapshot-entry-data) (oref session undo-stack))
+ (org-set-property "DRILL_LAST_QUALITY" "0") ; the misrating to take back
+ (push 0 (oref session qualities))
+ (goto-char (point-min))
+ (re-search-forward "Current")
+ (let ((keys (list org-drill--undo-key org-drill--quit-key)))
+ (cl-letf (((symbol-function 'org-drill--read-rating-key)
+ (lambda (&rest _) (pop keys)))
+ ((symbol-function 'sit-for) #'ignore))
+ (should (null (org-drill-reschedule session)))))
+ (goto-char (point-min))
+ (should (equal "5" (org-entry-get (point) "DRILL_LAST_QUALITY")))
+ (should (null (oref session qualities)))
+ (should (= 1 (length (oref session again-entries)))))))
+
+(provide 'test-org-drill-undo)
+
+;;; test-org-drill-undo.el ends here