aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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