aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-21 07:27:58 -0500
committerCraig Jennings <c@cjennings.net>2026-07-21 07:27:58 -0500
commita30074db7a7ca06e1baf312928076130be37e676 (patch)
treef4fa4dd6ca6e1429fd88769edf66d47074379093
parent24f8c5aac8873837824ca6d61e566e3e24c96ad5 (diff)
downloadorg-drill-main.tar.gz
org-drill-main.zip
fix: scan the deck buffer, not the dashboard, in the statistics viewsHEADmain
With the default file scope, the dashboard rendered and exported from its own buffer, so every count read zero and cards.csv came out empty. The render, refresh, cycle, and CSV-export paths now run their card scans in the deck buffer the command was invoked from, stored buffer-locally, and a dead deck signals a clear user-error instead of scanning the wrong buffer. Card links are now a registered org-drill-card link type with a follow handler, and the link path carries the file identity so RET jumps work across files. Eleven new ERT tests cover the render, refresh, export, link, and dead-buffer paths.
-rw-r--r--org-drill.el127
-rw-r--r--tests/test-org-drill-statistics-render-attention.el12
-rw-r--r--tests/test-org-drill-statistics-source-buffer.el174
3 files changed, 274 insertions, 39 deletions
diff --git a/org-drill.el b/org-drill.el
index 11f161e..8c895b6 100644
--- a/org-drill.el
+++ b/org-drill.el
@@ -5003,9 +5003,11 @@ an integer (0 when absent). AVG-QUALITY is DRILL_AVERAGE_QUALITY as a
float, or nil when absent. DAYS-SINCE-REVIEW is the integer day count
since DRILL_LAST_REVIEWED, or nil when never reviewed. DAYS-SINCE-ADDED
is the integer day count since DATE_ADDED, or nil when absent.
-TOTAL-REPEATS is DRILL_TOTAL_REPEATS as an integer (0 when absent)."
+TOTAL-REPEATS is DRILL_TOTAL_REPEATS as an integer (0 when absent).
+FILE is the file the entry's buffer visits, or nil for a fileless
+buffer; card links carry it so the dashboard can jump across files."
heading pos failure-count avg-quality
- days-since-review days-since-added total-repeats)
+ days-since-review days-since-added total-repeats file)
;; Short aliases for the long struct accessors, used by the sort
;; comparators below so the comparator lines stay readable.
@@ -5057,7 +5059,8 @@ via `org-get-heading', so the point must be on a drill heading."
(org-drill-statistics--days-since-org-timestamp last-raw today-day)
:days-since-added
(org-drill-statistics--days-since-org-timestamp added-raw today-day)
- :total-repeats (if repeats-raw (string-to-number repeats-raw) 0))))
+ :total-repeats (if repeats-raw (string-to-number repeats-raw) 0)
+ :file (buffer-file-name))))
(defun org-drill-statistics--days-since-org-timestamp (timestamp today-day)
"Return integer days from org TIMESTAMP string to TODAY-DAY.
@@ -5266,25 +5269,51 @@ with no SCHEDULED time are ignored."
;; each scan and cap) gives the pre-cap totals needed for the "+N more"
;; footers for free.
-(defun org-drill-statistics--card-link (heading pos)
+(defun org-drill-statistics--card-link (heading pos &optional file)
"Return an org bracket link to a drill card heading.
HEADING is the card's outline heading string, used as the link
-description. POS is the integer buffer position of the heading, carried
-in the link path so the dashboard's RET handler can jump to the card.
-The path has the form \"org-drill-card:POS\". Any closing bracket in
-HEADING is replaced so a literal \"]]\" cannot terminate the link early.
-An empty or nil HEADING falls back to a position-based description."
+description. POS is the integer buffer position of the heading. FILE
+is the file the card's buffer visits, or nil for a fileless buffer.
+The path has the form \"org-drill-card:POS@FILE\" (FILE may be empty),
+so the follow handler can jump to the card even across files. Any
+closing bracket in HEADING is replaced so a literal \"]]\" cannot
+terminate the link early. An empty or nil HEADING falls back to a
+position-based description."
(let* ((desc (if (and heading (not (string-empty-p heading)))
heading
(format "card at %d" pos)))
(safe (replace-regexp-in-string "]" "}" desc)))
- (format "[[org-drill-card:%d][%s]]" pos safe)))
+ (format "[[org-drill-card:%d@%s][%s]]" pos (or file "") safe)))
+
+(defun org-drill-statistics--follow-card-link (path &optional _prefix)
+ "Follow an org-drill-card link to its card.
+PATH is \"POS@FILE\" (FILE may be empty) or a legacy bare \"POS\".
+With a file, visit it in another window and move point to POS. Without
+one, fall back to the dashboard's stored source buffer. Signals a
+`user-error' when neither identifies a live target."
+ (let* ((at (string-match "@" path))
+ (pos (string-to-number (if at (substring path 0 at) path)))
+ (file (and at (substring path (1+ at))))
+ (source (and (boundp 'org-drill-statistics--source-buffer)
+ org-drill-statistics--source-buffer)))
+ (cond
+ ((and file (not (string-empty-p file)))
+ (find-file-other-window file)
+ (goto-char (min (max 1 pos) (point-max))))
+ ((buffer-live-p source)
+ (pop-to-buffer source)
+ (goto-char (min (max 1 pos) (point-max))))
+ (t (user-error
+ "Card buffer is gone; refresh the dashboard from a deck buffer")))))
+
+(org-link-set-parameters "org-drill-card"
+ :follow #'org-drill-statistics--follow-card-link)
(defun org-drill-statistics--render-attention-table
(title rows total empty-note)
"Render one needs-attention subsection as an org string.
TITLE is the third-level heading text for the subsection. ROWS is a
-list of (HEADING . POS) cons cells, already capped at
+list of (HEADING POS FILE) lists, already capped at
`org-drill-statistics-attention-row-limit' and already sorted. TOTAL is
the full count of matching cards before capping, used for the
\"+N more\" footer. EMPTY-NOTE is the line shown when ROWS is empty.
@@ -5304,7 +5333,8 @@ the number of rows shown."
(mapconcat
(lambda (row)
(format "| %s |"
- (org-drill-statistics--card-link (car row) (cdr row))))
+ (org-drill-statistics--card-link
+ (nth 0 row) (nth 1 row) (nth 2 row))))
rows
"\n")
"\n"
@@ -5368,8 +5398,9 @@ pre-cap totals are available for the footers without rescanning."
(lambda (structs)
(mapcar
(lambda (d)
- (cons (org-drill-statistics--entry-attention-data-heading d)
- (org-drill-statistics--entry-attention-data-pos d)))
+ (list (org-drill-statistics--entry-attention-data-heading d)
+ (org-drill-statistics--entry-attention-data-pos d)
+ (org-drill-statistics--entry-attention-data-file d)))
(org-drill-statistics--cap-rows structs)))))
(concat
"** Needs attention\n"
@@ -5634,6 +5665,14 @@ algorithms. Set by `org-drill-statistics' and rotated by
`org-drill-statistics-cycle-algorithm'. Read by the render helpers via
the filtered log they are handed.")
+(defvar-local org-drill-statistics--source-buffer nil
+ "Buffer-local deck buffer the dashboard's card scans run in.
+Set by `org-drill-statistics' to the buffer the command was invoked
+from and read by `org-drill-statistics--render' (via refresh and the
+cycle commands) so every card traversal sees the deck, not the
+dashboard's own text. Also the fallback jump target for card links
+whose path carries no file.")
+
(defun org-drill-statistics--range-cutoff-float (label)
"Return the float-time cutoff for range LABEL, or nil for all time.
LABEL is a key in `org-drill-statistics-range-presets'. When its DAYS
@@ -5720,24 +5759,34 @@ is put in `org-mode'; not intended to be toggled by hand."
:lighter " OrgDrillStats"
:keymap org-drill-statistics-mode-map)
-(defun org-drill-statistics--render (buffer scope range algorithm)
+(defun org-drill-statistics--render (buffer scope range algorithm
+ &optional source)
"Render the dashboard for SCOPE, RANGE, ALGORITHM into BUFFER.
-BUFFER is the target buffer. Its read-only state is lifted for the
-write, the assembled body replaces the contents, point returns to the
-top, and the buffer is left read-only. The three filter values are
-stored buffer-locally so refresh and the cycle commands can read and
-rotate them. Returns BUFFER."
- (with-current-buffer buffer
- (let ((inhibit-read-only t)
- (body (org-drill-statistics--render-all scope range algorithm)))
- (erase-buffer)
- (insert body)
- (goto-char (point-min)))
- (setq org-drill-statistics--scope scope
- org-drill-statistics--range range
- org-drill-statistics--algorithm algorithm)
- (setq buffer-read-only t)
- buffer))
+BUFFER is the target buffer. SOURCE is the deck buffer the card scans
+run in, defaulting to the current buffer, so the traversals see the
+deck rather than the dashboard's own text. A dead SOURCE signals a
+`user-error'. BUFFER's read-only state is lifted for the write, the
+assembled body replaces the contents, point returns to the top, and the
+buffer is left read-only. The filter values and SOURCE are stored
+buffer-locally so refresh and the cycle commands can read and rotate
+them. Returns BUFFER."
+ (let ((source (or source (current-buffer))))
+ (unless (buffer-live-p source)
+ (user-error
+ "The dashboard's deck buffer is gone; run org-drill-statistics from a deck"))
+ (let ((body (with-current-buffer source
+ (org-drill-statistics--render-all scope range algorithm))))
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert body)
+ (goto-char (point-min)))
+ (setq org-drill-statistics--scope scope
+ org-drill-statistics--range range
+ org-drill-statistics--algorithm algorithm
+ org-drill-statistics--source-buffer source)
+ (setq buffer-read-only t)
+ buffer))))
;;;###autoload
(defun org-drill-statistics ()
@@ -5748,13 +5797,14 @@ filter header and the five render sections, using the current
to it. Refresh and the s/r/a cycle commands re-render in place."
(interactive)
(let ((buffer (get-buffer-create org-drill-statistics--buffer-name))
+ (source (current-buffer))
(scope org-drill-scope)
(range (caar org-drill-statistics-range-presets))
(algorithm nil))
(with-current-buffer buffer
(org-mode)
(org-drill-statistics-mode 1))
- (org-drill-statistics--render buffer scope range algorithm)
+ (org-drill-statistics--render buffer scope range algorithm source)
(switch-to-buffer buffer)
buffer))
@@ -5767,7 +5817,8 @@ every section against the current log and card state. Bound to g."
(current-buffer)
org-drill-statistics--scope
org-drill-statistics--range
- org-drill-statistics--algorithm))
+ org-drill-statistics--algorithm
+ org-drill-statistics--source-buffer))
(defun org-drill-statistics-cycle-scope ()
"Cycle the dashboard scope filter and refresh.
@@ -5990,7 +6041,15 @@ do. Interactively, prompts for DIRECTORY."
(org-drill-statistics--write-csv
(expand-file-name "cards.csv" directory)
org-drill-statistics--cards-csv-header
- (org-drill-statistics--cards-rows scope))
+ (let ((source org-drill-statistics--source-buffer))
+ (cond
+ ((buffer-live-p source)
+ (with-current-buffer source
+ (org-drill-statistics--cards-rows scope)))
+ (source
+ (user-error
+ "The dashboard's deck buffer is gone; refresh from a deck first"))
+ (t (org-drill-statistics--cards-rows scope)))))
(org-drill-statistics--write-csv
(expand-file-name "daily.csv" directory)
org-drill-statistics--daily-csv-header
diff --git a/tests/test-org-drill-statistics-render-attention.el b/tests/test-org-drill-statistics-render-attention.el
index b3f2375..4adcfcc 100644
--- a/tests/test-org-drill-statistics-render-attention.el
+++ b/tests/test-org-drill-statistics-render-attention.el
@@ -65,8 +65,9 @@ current time so the fixture never hardcodes today."
(test-org-drill-stats--attn-fixture)
(let ((out (org-drill-statistics--render-attention 'file)))
(should (string-match-p "| Card |" out))
- (should (string-match-p "\\[\\[org-drill-card:[0-9]+\\]\\[Leech 00\\]\\]"
- out)))))
+ (should (string-match-p
+ "\\[\\[org-drill-card:[0-9]+@[^]]*\\]\\[Leech 00\\]\\]"
+ out)))))
(ert-deftest test-org-drill-statistics-attention-cap-and-footer ()
"Twelve leeches over a 10 cap show 10 rows and a +2 more footer."
@@ -126,15 +127,16 @@ current time so the fixture never hardcodes today."
(ert-deftest test-org-drill-statistics-card-link-sanitizes-brackets ()
"Closing brackets in a heading cannot terminate the link early."
(let ((link (org-drill-statistics--card-link "a]] b" 42)))
- (should (string-prefix-p "[[org-drill-card:42][" link))
+ (should (string-prefix-p "[[org-drill-card:42@][" link))
(should (string-suffix-p "]]" link))
(should-not (string-match-p "a]] b" link))))
(ert-deftest test-org-drill-statistics-card-link-empty-heading-fallback ()
"An empty heading falls back to a position-based description."
(let ((link (org-drill-statistics--card-link "" 99)))
- (should (string-match-p "\\[\\[org-drill-card:99\\]\\[card at 99\\]\\]"
- link))))
+ (should (string-match-p
+ "\\[\\[org-drill-card:99@[^]]*\\]\\[card at 99\\]\\]"
+ link))))
(provide 'test-org-drill-statistics-render-attention)
diff --git a/tests/test-org-drill-statistics-source-buffer.el b/tests/test-org-drill-statistics-source-buffer.el
new file mode 100644
index 0000000..5732b63
--- /dev/null
+++ b/tests/test-org-drill-statistics-source-buffer.el
@@ -0,0 +1,174 @@
+;;; test-org-drill-statistics-source-buffer.el --- Dashboard source-buffer tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; ERT tests for the statistics dashboard's source-buffer plumbing and
+;; the org-drill-card link type. The dashboard must scan the deck the
+;; command was invoked from, not its own buffer, and its card links must
+;; be followable.
+
+;;; Code:
+
+(require 'ert)
+(require 'org-drill)
+(require 'org)
+
+(defmacro org-drill-statistics-source-test--with-deck (var &rest body)
+ "Run BODY with VAR bound to a live org deck buffer holding two drill cards."
+ (declare (indent 1))
+ `(let ((,var (generate-new-buffer "*source-deck*")))
+ (unwind-protect
+ (with-current-buffer ,var
+ (org-mode)
+ (insert "* Card one :drill:\nBody one.\n"
+ "* Card two :drill:\nBody two.\n")
+ (goto-char (point-min))
+ ,@body)
+ (when (buffer-live-p ,var) (kill-buffer ,var)))))
+
+;;; Normal cases
+
+(ert-deftest test-org-drill-statistics-render-counts-source-deck-cards ()
+ "Rendering into the dashboard counts the cards of the source deck.
+With the default file scope, the card scans must run in the source
+buffer, not the dashboard buffer being rendered into."
+ (org-drill-statistics-source-test--with-deck deck
+ (let ((dash (generate-new-buffer "*dash-test*"))
+ (org-drill-scope 'file)
+ (org-drill-session-log nil))
+ (unwind-protect
+ (progn
+ (with-current-buffer dash (org-mode))
+ (org-drill-statistics--render dash 'file "last 90d" nil deck)
+ (with-current-buffer dash
+ (goto-char (point-min))
+ ;; Overview table data row: | total | new | mature | lapsed |
+ (should (search-forward "| 2 | " nil t))))
+ (kill-buffer dash)))))
+
+(ert-deftest test-org-drill-statistics-refresh-uses-stored-source ()
+ "Refresh from inside the dashboard re-scans the stored source deck."
+ (org-drill-statistics-source-test--with-deck deck
+ (let ((dash (generate-new-buffer "*dash-test*"))
+ (org-drill-scope 'file)
+ (org-drill-session-log nil))
+ (unwind-protect
+ (progn
+ (with-current-buffer dash (org-mode))
+ (org-drill-statistics--render dash 'file "last 90d" nil deck)
+ (with-current-buffer dash
+ (org-drill-statistics-refresh)
+ (goto-char (point-min))
+ (should (search-forward "| 2 | " nil t))))
+ (kill-buffer dash)))))
+
+(ert-deftest test-org-drill-statistics-card-link-type-is-registered ()
+ "The org-drill-card link type has a follow handler registered."
+ (should (functionp (org-link-get-parameter "org-drill-card" :follow))))
+
+(ert-deftest test-org-drill-statistics-card-link-encodes-file ()
+ "Card links carry the file identity after the position."
+ (let ((link (org-drill-statistics--card-link "Heading" 42 "/tmp/deck.org")))
+ (should (string-prefix-p "[[org-drill-card:42@/tmp/deck.org][" link))))
+
+(ert-deftest test-org-drill-statistics-follow-jumps-to-file-position ()
+ "Following a card link visits the file and moves point to the position."
+ (let* ((file (make-temp-file "org-drill-follow-test" nil ".org"
+ "* Card one :drill:\nBody.\n"))
+ (buf nil))
+ (unwind-protect
+ (progn
+ (org-drill-statistics--follow-card-link (format "1@%s" file))
+ (setq buf (current-buffer))
+ (should (equal (buffer-file-name) file))
+ (should (= (point) 1)))
+ (when (and buf (buffer-live-p buf)) (kill-buffer buf))
+ (delete-file file))))
+
+;;; Boundary cases
+
+(ert-deftest test-org-drill-statistics-render-empty-deck-counts-zero ()
+ "An empty source deck renders a zero-card overview, not an error."
+ (let ((deck (generate-new-buffer "*empty-deck*"))
+ (dash (generate-new-buffer "*dash-test*"))
+ (org-drill-scope 'file)
+ (org-drill-session-log nil))
+ (unwind-protect
+ (progn
+ (with-current-buffer deck (org-mode))
+ (with-current-buffer dash (org-mode))
+ (org-drill-statistics--render dash 'file "last 90d" nil deck)
+ (with-current-buffer dash
+ (goto-char (point-min))
+ (should (search-forward "| 0 | " nil t))))
+ (kill-buffer deck)
+ (kill-buffer dash))))
+
+(ert-deftest test-org-drill-statistics-card-link-empty-file-omits-identity ()
+ "A card link built with no file still carries the position."
+ (let ((link (org-drill-statistics--card-link "Heading" 7 nil)))
+ (should (string-prefix-p "[[org-drill-card:7@][" link))))
+
+(ert-deftest test-org-drill-statistics-export-cards-from-source-deck ()
+ "CSV export scans the stored source deck, not the dashboard buffer."
+ (org-drill-statistics-source-test--with-deck deck
+ (let ((dash (generate-new-buffer "*dash-test*"))
+ (dir (make-temp-file "org-drill-export-test" t))
+ (org-drill-scope 'file)
+ (org-drill-session-log nil))
+ (unwind-protect
+ (progn
+ (with-current-buffer dash (org-mode))
+ (org-drill-statistics--render dash 'file "last 90d" nil deck)
+ (with-current-buffer dash
+ (org-drill-statistics-export-csv dir))
+ (with-temp-buffer
+ (insert-file-contents (expand-file-name "cards.csv" dir))
+ ;; Header plus one row per deck card.
+ (should (= 3 (count-lines (point-min) (point-max))))))
+ (kill-buffer dash)
+ (delete-directory dir t)))))
+
+(ert-deftest test-org-drill-statistics-follow-clamps-position-floor ()
+ "A zero or garbage position clamps to buffer start instead of erroring."
+ (let* ((file (make-temp-file "org-drill-follow-clamp" nil ".org"
+ "* Card :drill:\n"))
+ (buf nil))
+ (unwind-protect
+ (progn
+ (org-drill-statistics--follow-card-link (format "0@%s" file))
+ (setq buf (current-buffer))
+ (should (= (point) 1)))
+ (when (and buf (buffer-live-p buf)) (kill-buffer buf))
+ (delete-file file))))
+
+;;; Error cases
+
+(ert-deftest test-org-drill-statistics-refresh-dead-source-signals-user-error ()
+ "Refresh after the source deck is killed signals a clear user-error."
+ (let ((dash (generate-new-buffer "*dash-test*"))
+ (deck (generate-new-buffer "*doomed-deck*"))
+ (org-drill-scope 'file)
+ (org-drill-session-log nil))
+ (unwind-protect
+ (progn
+ (with-current-buffer deck
+ (org-mode)
+ (insert "* Card :drill:\n"))
+ (with-current-buffer dash (org-mode))
+ (org-drill-statistics--render dash 'file "last 90d" nil deck)
+ (kill-buffer deck)
+ (with-current-buffer dash
+ (should-error (org-drill-statistics-refresh)
+ :type 'user-error)))
+ (when (buffer-live-p deck) (kill-buffer deck))
+ (kill-buffer dash))))
+
+(ert-deftest test-org-drill-statistics-follow-dead-source-signals-user-error ()
+ "Following a bufferless link with no live source signals a user-error."
+ (with-temp-buffer
+ ;; No dashboard source var set in this buffer; empty file identity.
+ (should-error (org-drill-statistics--follow-card-link "5@")
+ :type 'user-error)))
+
+(provide 'test-org-drill-statistics-source-buffer)
+;;; test-org-drill-statistics-source-buffer.el ends here