branch: elpa/gnosis
commit 8ebfe1c848b3daa7ad34a2c273ec67a352d8f916
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
[New] Add gnosis-review module.
---
gnosis-review.el | 781 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 781 insertions(+)
diff --git a/gnosis-review.el b/gnosis-review.el
new file mode 100644
index 0000000000..d7b6a4ee58
--- /dev/null
+++ b/gnosis-review.el
@@ -0,0 +1,781 @@
+;;; gnosis-review.el --- Review system for gnosis -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Free Software Foundation, Inc.
+
+;; Author: Thanos Apollo <[email protected]>
+;; Keywords: extensions
+;; URL: https://thanosapollo.org/projects/gnosis
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Review system for gnosis spaced repetition.
+;;
+;; This module handles:
+;; - Scheduling: due/overdue thema detection (`gnosis-review-is-due-p',
+;; `gnosis-review-get-due-themata', `gnosis-review-get-overdue-themata')
+;; - Review display: keimenon, images, clozes, answers, hints, parathema
+;; - Type-specific review logic: MCQ, basic, cloze, MC-cloze
+;; - Review session management and actions (next, override, suspend,
+;; edit, quit, view-link)
+;; - Algorithm bridge: computing next intervals and gnosis scores
+;; - Monkeytype integration for typing practice
+;; - Link view mode for viewing org-gnosis nodes during review
+
+;;; Code:
+
+(require 'gnosis)
+(require 'gnosis-algorithm)
+(require 'gnosis-monkeytype)
+(require 'gnosis-utils)
+(require 'org-gnosis)
+
+;;; Review vars
+
+(defvar gnosis-review-types '("Due themata"
+ "Due themata of deck"
+ "Due themata of specified tag(s)"
+ "Overdue themata"
+ "Due themata (Without Overdue)"
+ "All themata of deck"
+ "All themata of tag(s)"))
+
+(defvar gnosis-review-themata nil
+ "Review themata.")
+
+(defvar gnosis-review-buffer-name "*gnosis*"
+ "Review buffer name.")
+
+;;; Display functions
+
+(defun gnosis-display-keimenon (str)
+ "Display STR as keimenon."
+ (with-current-buffer gnosis-review-buffer-name
+ (erase-buffer)
+ (insert "\n" (gnosis-format-string str))
+ (gnosis-insert-separator)
+ (gnosis-apply-center-buffer-overlay)))
+
+(defun gnosis-display-image (keimenon)
+ "Display image link from KEIMENON in new window."
+ (let ((image-path (and (string-match "\\[file:\\(.*?\\)\\]" keimenon)
+ (match-string 1 keimenon))))
+ (when image-path
+ (find-file-other-window image-path)
+ (switch-to-buffer-other-window gnosis-review-buffer-name))))
+
+(defun gnosis-display-cloze-string (str clozes hints correct false)
+ "Display STR with CLOZES and HINTS.
+
+Applies highlighting for CORRECT & FALSE."
+ (let* ((cloze-str (gnosis-cloze-create str clozes))
+ (str-with-hints (gnosis-cloze-add-hints cloze-str hints))
+ (str-with-c-answers
+ (gnosis-utils-highlight-words str-with-hints correct
'gnosis-face-correct))
+ (final (gnosis-cloze-mark-false str-with-c-answers false)))
+ (gnosis-display-keimenon final)))
+
+(defun gnosis-display-basic-answer (answer success user-input)
+ "Display ANSWER.
+
+When SUCCESS nil, display USER-INPUT as well"
+ (with-current-buffer gnosis-review-buffer-name
+ (goto-char (point-max))
+ (insert "\n\n"
+ (propertize "Answer:" 'face 'gnosis-face-directions)
+ " "
+ (propertize answer 'face 'gnosis-face-correct))
+ (when gnosis-center-content
+ (gnosis-center-current-line))
+ ;; Insert user wrong answer
+ (when (not success)
+ (insert "\n"
+ (propertize "Your answer:" 'face 'gnosis-face-directions)
+ " "
+ (propertize user-input 'face 'gnosis-face-false))
+ (when gnosis-center-content
+ (gnosis-center-current-line)))))
+
+(defun gnosis-display-hint (hint)
+ "Display HINT."
+ (let ((hint (or hint "")))
+ (unless (string-empty-p hint)
+ (goto-char (point-max))
+ (and (not (string-empty-p hint))
+ (insert "\n" (gnosis-format-string (propertize hint 'face
'gnosis-face-hint))))
+ (gnosis-insert-separator))))
+
+(defun gnosis-display-cloze-user-answer (user-input &optional false)
+ "Display USER-INPUT answer for cloze thema upon failed review.
+
+If FALSE t, use gnosis-face-false face"
+ (goto-char (point-max))
+ (insert "\n\n"
+ (propertize "Your answer:" 'face 'gnosis-face-directions)
+ " "
+ (propertize user-input 'face
+ (if false 'gnosis-face-false 'gnosis-face-correct)))
+ (when gnosis-center-content
+ (gnosis-center-current-line))
+ (newline))
+
+(defun gnosis-display-correct-answer-mcq (answer user-choice)
+ "Display correct ANSWER & USER-CHOICE for MCQ thema."
+ (goto-char (point-max))
+ (insert (gnosis-format-string
+ (format "%s %s\n%s %s"
+ (propertize "Correct Answer:" 'face 'gnosis-face-directions)
+ (propertize answer 'face 'gnosis-face-correct)
+ (propertize "Your answer:" 'face 'gnosis-face-directions)
+ (propertize user-choice 'face (if (string= answer
user-choice)
+ 'gnosis-face-correct
+ 'gnosis-face-false))))
+ "\n")
+ (gnosis-insert-separator))
+
+(defun gnosis-display-parathema (parathema)
+ "Display PARATHEMA."
+ (when (and parathema (not (string-empty-p parathema)))
+ (search-backward "----") ; search back for separator
+ (forward-line 1)
+ (insert "\n" (gnosis-format-string (gnosis-org-format-string parathema))
"\n")))
+
+(defun gnosis-display-next-review (id success)
+ "Display next interval of thema ID for SUCCESS."
+ (with-current-buffer gnosis-review-buffer-name
+ (let* ((interval (car (gnosis-review-algorithm id success)))
+ (next-review-msg (format "\n\n%s %s"
+ (propertize "Next review:" 'face
'gnosis-face-directions)
+ (propertize
+ (replace-regexp-in-string
+ "[]()[:space:]]"
+ (lambda (match)
+ (if (string= match " ") "/" ""))
+ (format "%s" interval) t t)
+ 'face 'gnosis-face-next-review))))
+ (if (search-backward "Next review" nil t)
+ ;; Delete previous result, and override with new this should
+ ;; occur only when used for overriding review result.
+ (progn (delete-region (point) (progn (end-of-line) (point)))
+ (insert (propertize (replace-regexp-in-string "\n" ""
next-review-msg)
+ 'face (if success 'gnosis-face-correct
+ 'gnosis-face-false))))
+ ;; Default behaviour
+ (goto-char (point-max))
+ (insert (gnosis-format-string next-review-msg))))))
+
+;;; Link view mode
+
+(defun gnosis-get-linked-nodes (id)
+ "Return the title of linked org-gnosis node(s) for thema ID."
+ (let* ((links (gnosis-select 'dest 'links `(= source ,id) t))
+ (org-gnosis-nodes (cl-loop for node-id in links
+ collect (org-gnosis-select 'title 'nodes
`(= id ,node-id) t))))
+ (and links (apply #'append org-gnosis-nodes))))
+
+(defun gnosis-view-linked-node (id)
+ "Visit linked node(s) for thema ID."
+ (let* ((node (gnosis-completing-read "Select node: "
(gnosis-get-linked-nodes id) t)))
+ (window-configuration-to-register :gnosis-link-view)
+ (org-gnosis-find node)
+ (gnosis-link-view-mode)))
+
+(defun gnosis-link-view--exit ()
+ "Exit link view mode."
+ (interactive nil gnosis-link-view-mode)
+ (gnosis-link-view-mode -1)
+ (jump-to-register :gnosis-link-view)
+ (exit-recursive-edit))
+
+(defvar-keymap gnosis-link-view-mode-map
+ :doc "Keymap for `gnosis-link-view-mode'."
+ "C-c C-c" #'gnosis-link-view--exit)
+
+(define-minor-mode gnosis-link-view-mode "Gnosis Link View."
+ :interactive nil
+ :lighter " Gnosis Link View"
+ :keymap gnosis-link-view-mode-map
+ (if gnosis-link-view-mode
+ (setq-local header-line-format
+ (substitute-command-keys
+ " Return to review with: \\[gnosis-link-view--exit]"))
+ (setq-local header-line-format nil)))
+
+;;; Due/scheduling
+
+(defun gnosis-review-is-due-p (thema-id)
+ "Check if thema with value of THEMA-ID for id is due for review.
+
+Check if it's suspended, and if it's due today."
+ (and (not (gnosis-suspended-p thema-id))
+ (gnosis-review-is-due-today-p thema-id)))
+
+(defun gnosis-review-is-due-today-p (id)
+ "Return t if thema with ID is due today.
+
+This function ignores if thema is suspended. Refer to
+`gnosis-review-is-due-p' if you need to check for suspended value as
+well."
+ (let ((next-rev (gnosis-get 'next-rev 'review-log `(= id ,id))))
+ (gnosis-past-or-present-p next-rev)))
+
+(defun gnosis-review-get--due-themata ()
+ "Return due thema IDs & due dates."
+ (let* ((today (gnosis--date-to-int (gnosis-algorithm-date)))
+ (old-themata (cl-loop for thema in
+ (gnosis-select '[id next-rev] 'review-log
+ '(and (> n 0)
+ (= suspend 0))
+ nil)
+ when (<= (gnosis--date-to-int (cadr thema))
today)
+ collect thema))
+ (new-themata (cl-loop for thema in
+ (gnosis-select '[id next-rev] 'review-log
+ '(and (= n 0)
+ (= suspend 0))
+ nil)
+ when (<= (gnosis--date-to-int (cadr thema))
today)
+ collect thema)))
+ (if gnosis-review-new-first
+ (append (cl-subseq new-themata 0 gnosis-new-themata-limit) old-themata)
+ (append old-themata (cl-subseq new-themata 0
gnosis-new-themata-limit)))))
+
+(defun gnosis-review-get-due-themata ()
+ "Return all due thema IDs."
+ (mapcar #'car (gnosis-review-get--due-themata)))
+
+(defun gnosis-review-get-overdue-themata (&optional thema-ids)
+ "Return overdue themata for current DATE.
+
+Optionally, provide THEMA-IDS of which the overdue ones will be returned."
+ (cl-loop for thema in (or thema-ids (gnosis-review-get--due-themata))
+ when (not (equal (cadr thema) (gnosis-algorithm-date)))
+ collect (car thema)))
+
+;;; Algorithm bridge
+
+(defun gnosis-review-last-interval (id)
+ "Return last review interval for thema ID."
+ (let* ((last-rev (gnosis-get 'last-rev 'review-log `(= id ,id)))
+ (rev-date (gnosis-get 'next-rev 'review-log `(= id ,id))))
+ (gnosis-algorithm-date-diff last-rev rev-date)))
+
+(defun gnosis-review-algorithm (id success)
+ "Return next review date & gnosis for thema with value of id ID.
+
+SUCCESS is a boolean value, t for success, nil for failure.
+
+Returns a list of the form ((yyyy mm dd) (ef-increase ef-decrease ef-total))."
+ (let ((amnesia (gnosis-get-thema-amnesia id))
+ (gnosis (gnosis-get 'gnosis 'review `(= id ,id)))
+ (t-success (gnosis-get 't-success 'review-log `(= id ,id))) ;; total
successful reviews
+ (c-success (gnosis-get 'c-success 'review-log `(= id ,id))) ;;
consecutive successful reviews
+ (c-fails (gnosis-get 'c-fails 'review-log `(= id ,id))) ;; consecutive
failed reviews
+ ;; (t-fails (gnosis-get 't-fails 'review-log `(= id ,id))) ;; total
failed reviews
+ ;; (review-num (gnosis-get 'n 'review-log `(= id ,id))) ;; total reviews
+ ;; (last-interval (max (gnosis-review--get-offset id) 1))
+ (last-interval (gnosis-review-last-interval id))) ;; last interval
+ (list
+ (gnosis-algorithm-next-interval
+ :last-interval last-interval
+ :gnosis-synolon (nth 2 gnosis)
+ :success success
+ :successful-reviews t-success
+ :c-fails c-fails
+ :lethe (gnosis-get-thema-lethe id)
+ :amnesia amnesia
+ :proto (gnosis-get-thema-proto id))
+ (gnosis-algorithm-next-gnosis
+ :gnosis gnosis
+ :success success
+ :epignosis (gnosis-get-thema-epignosis id)
+ :agnoia (gnosis-get-thema-agnoia id)
+ :anagnosis (gnosis-get-thema-anagnosis id)
+ :c-successes c-success
+ :c-failures c-fails
+ :lethe (gnosis-get-thema-lethe id)))))
+
+(defun gnosis-review--update (id success)
+ "Update review-log for thema ID.
+
+SUCCESS is a boolean value, t for success, nil for failure."
+ (let* ((result (gnosis-review-algorithm id success))
+ (next-rev (car result))
+ (gnosis-score (cadr result))
+ (log (car (gnosis-select '[n c-success c-fails t-success t-fails]
+ 'review-log `(= id ,id))))
+ (n (nth 0 log))
+ (c-success (nth 1 log))
+ (c-fails (nth 2 log))
+ (t-success (nth 3 log))
+ (t-fails (nth 4 log)))
+ (gnosis-review-increment-activity-log (not (> n 0)))
+ ;; Single review-log UPDATE
+ (emacsql gnosis-db
+ "UPDATE review_log SET last_rev = $s1, next_rev = $s2, n = $s3,
c_success = $s4, c_fails = $s5, t_success = $s6, t_fails = $s7 WHERE id = $s8"
+ (gnosis-algorithm-date) next-rev (1+ n)
+ (if success (1+ c-success) 0)
+ (if success 0 (1+ c-fails))
+ (if success (1+ t-success) t-success)
+ (if success t-fails (1+ t-fails))
+ id)
+ ;; Single review UPDATE
+ (gnosis-update 'review `(= gnosis ',gnosis-score) `(= id ,id))))
+
+(defun gnosis-review-result (id success)
+ "Update review thema ID results for SUCCESS."
+ (gnosis-review--update id success)
+ (setf gnosis-due-themata-total (length (gnosis-review-get-due-themata))))
+
+;;; Type-specific review
+
+(defun gnosis-review-mcq (id)
+ "Review MCQ thema with ID."
+ (gnosis-display-image (gnosis-get 'keimenon 'themata `(= id ,id)))
+ (gnosis-display-keimenon (gnosis-org-format-string
+ (gnosis-get 'keimenon 'themata `(= id ,id))))
+ (let* ((answer (car (gnosis-get 'answer 'themata `(= id ,id))))
+ (user-choice (gnosis-mcq-answer id))
+ (success (string= answer user-choice)))
+ (gnosis-display-correct-answer-mcq answer user-choice)
+ (gnosis-display-parathema (gnosis-get 'parathema 'extras `(= id ,id)))
+ (gnosis-display-next-review id success)
+ success))
+
+(defun gnosis-review-basic (id)
+ "Review basic type thema for ID."
+ (let* ((hypothesis (car (gnosis-get 'hypothesis 'themata `(= id ,id))))
+ (parathema (gnosis-get 'parathema 'extras `(= id ,id)))
+ (keimenon (gnosis-get 'keimenon 'themata `(= id ,id)))
+ (answer (car (gnosis-get 'answer 'themata `(= id ,id)))))
+ (gnosis-display-image keimenon)
+ (gnosis-display-keimenon (gnosis-org-format-string keimenon))
+ (gnosis-display-hint hypothesis)
+ (let* ((answer answer)
+ (user-input (read-string "Answer: "))
+ (success (gnosis-compare-strings answer user-input)))
+ (gnosis-display-basic-answer answer success user-input)
+ (gnosis-display-parathema parathema)
+ (gnosis-display-next-review id success)
+ success)))
+
+(defun gnosis-review-cloze--input (clozes &optional user-input)
+ "Prompt for USER-INPUT during cloze review.
+
+CLOZES is a list of possible correct answers.
+
+Returns a cons; ='(position . user-input) if correct,
+='(nil . user-input) if incorrect."
+ (let* ((user-input (or user-input (read-string "Answer: ")))
+ (position (cl-position user-input clozes :test
#'gnosis-compare-strings)))
+ (cons position user-input)))
+
+(defun gnosis-review-cloze (id)
+ "Review cloze type thema for ID."
+ (let* ((keimenon (gnosis-get 'keimenon 'themata `(= id ,id)))
+ (all-clozes (gnosis-get 'answer 'themata `(= id ,id)))
+ (all-hints (gnosis-get 'hypothesis 'themata `(= id ,id)))
+ (revealed-clozes '()) ;; List of revealed clozes
+ (unrevealed-clozes all-clozes)
+ (unrevealed-hints all-hints)
+ (parathema (gnosis-get 'parathema 'extras `(= id ,id)))
+ (success t))
+ ;; Initially display the sentence with no reveals
+ (gnosis-display-cloze-string keimenon unrevealed-clozes unrevealed-hints
nil nil)
+ (catch 'done
+ (while unrevealed-clozes
+ (let* ((input (gnosis-review-cloze--input unrevealed-clozes))
+ (position (car input))
+ (matched-cloze (when position (nth position unrevealed-clozes)))
+ (matched-hint (when (and position (< position (length
unrevealed-hints)))
+ (nth position unrevealed-hints))))
+ (if matched-cloze
+ ;; Correct answer - move cloze from unrevealed to revealed
+ (progn
+ ;; Add to revealed clozes list, preserving original order
+ (setq revealed-clozes
+ (cl-sort (cons matched-cloze revealed-clozes)
+ #'< :key (lambda (cloze)
+ (cl-position cloze all-clozes))))
+ ;; Remove from unrevealed lists by position
+ (setq unrevealed-clozes (append (cl-subseq unrevealed-clozes 0
position)
+ (cl-subseq unrevealed-clozes
(1+ position))))
+ (when (and matched-hint (< position (length unrevealed-hints)))
+ (setq unrevealed-hints (append (cl-subseq unrevealed-hints 0
position)
+ (cl-subseq unrevealed-hints
(1+ position)))))
+ ;; Display with updated revealed/unrevealed lists
+ (gnosis-display-cloze-string keimenon unrevealed-clozes
unrevealed-hints
+ revealed-clozes nil))
+ ;; Incorrect answer
+ (gnosis-display-cloze-string keimenon nil nil
+ revealed-clozes unrevealed-clozes)
+ (gnosis-display-cloze-user-answer (cdr input))
+ (setq success nil)
+ (throw 'done nil)))))
+ (gnosis-display-parathema parathema)
+ (gnosis-display-next-review id success)
+ success))
+
+(defun gnosis-review-mc-cloze (id)
+ "Review mc-cloze type thema for ID."
+ (let* ((keimenon (gnosis-get 'keimenon 'themata `(= id ,id)))
+ (cloze (gnosis-get 'answer 'themata `(= id ,id)))
+ (options (gnosis-get 'hypothesis 'themata `(= id ,id)))
+ (parathema (gnosis-get 'parathema 'extras `(= id ,id)))
+ (user-input)
+ (success))
+ (gnosis-display-cloze-string keimenon cloze nil nil nil)
+ (setq user-input (gnosis-completing-read "Select answer: "
+ (gnosis-shuffle options)))
+ (if (string= user-input (car cloze))
+ (progn
+ (gnosis-display-cloze-string keimenon nil nil cloze nil)
+ (setq success t))
+ (gnosis-display-cloze-string keimenon nil nil nil cloze)
+ (gnosis-display-correct-answer-mcq (car cloze) user-input))
+ (gnosis-display-parathema parathema)
+ (gnosis-display-next-review id success)
+ success))
+
+(defun gnosis-review-is-thema-new-p (id)
+ "Return t if thema with ID is new."
+ (let ((reviews (car (gnosis-select 'n 'review-log `(= id ,id) t))))
+ (not (> reviews 0))))
+
+;;; Activity log
+
+(defun gnosis-review-increment-activity-log (new? &optional date)
+ "Increment activity log for DATE by one.
+
+If NEW? is non-nil, increment new themata log by 1."
+ (let* ((current-total-value (gnosis-get-date-total-themata))
+ (inc-total (cl-incf current-total-value))
+ (current-new-value (gnosis-get-date-new-themata))
+ (inc-new (cl-incf current-new-value))
+ (date (or date (gnosis-algorithm-date))))
+ (gnosis-update 'activity-log `(= reviewed-total ,inc-total) `(= date
',date))
+ (and new? (gnosis-update 'activity-log `(= reviewed-new ,inc-new) `(= date
',date)))))
+
+(defun gnosis-history-clear ()
+ "Delete all activity log entries."
+ (interactive)
+ (when (y-or-n-p "Delete all activity log?")
+ (emacsql gnosis-db [:delete :from activity-log])))
+
+;;; Session management
+
+(defun gnosis-review--display-thema (id)
+ "Display thema with ID and call the appropriate review func."
+ (let* ((type (gnosis-get 'type 'themata `(= id ,id)))
+ (func-name (intern (format "gnosis-review-%s" (downcase type)))))
+ (if (fboundp func-name)
+ (progn
+ (unless (eq major-mode 'gnosis-mode)
+ (pop-to-buffer-same-window (get-buffer-create
gnosis-review-buffer-name))
+ (gnosis-mode)
+ (gnosis-review-update-header 0))
+ (window-configuration-to-register :gnosis-pre-image)
+ (funcall func-name id))
+ (error "Malformed thema type: '%s'" type))))
+
+(defun gnosis-review-process-thema (thema &optional thema-count)
+ "Process review for THEMA and update session statistics.
+
+Displays the thema, processes the review result, and updates the
+header. Returns the incremented THEMA-COUNT after processing.
+
+This is a helper function for `gnosis-review-session'."
+ (let ((success (gnosis-review--display-thema thema))
+ (thema-count (or thema-count 0)))
+ (cl-incf thema-count)
+ (unless success (gnosis-monkeytype-answer thema))
+ (gnosis-review-actions success thema thema-count)
+ ;; Use jump-to-register after first review.
+ (and (not (null (get-register :gnosis-pre-image))) (jump-to-register
:gnosis-pre-image))
+ (setq gnosis-review-themata (remove thema gnosis-review-themata))
+ (gnosis-review-update-header thema-count (length gnosis-review-themata))
+ thema-count))
+
+(defun gnosis-review-update-header (reviewed-count &optional remaining-reviews)
+ "Update the review session header with current stats.
+
+REVIEWED-COUNT: Total number of items that have been reviewed in
+current session.
+REMAINING-REVIEWS: Total number of remaining items to be reviewed."
+ (with-current-buffer (get-buffer-create gnosis-review-buffer-name)
+ (let ((remaining-reviews (or remaining-reviews (1+ (length
gnosis-review-themata)))))
+ (setq-local header-line-format
+ (gnosis-center-string
+ (format "%s %s %s"
+ (propertize (number-to-string reviewed-count)
+ 'face 'font-lock-type-face)
+ (propertize "|" 'face 'font-lock-comment-face)
+ (propertize (number-to-string remaining-reviews)
+ 'face 'gnosis-face-false)))))))
+
+(defun gnosis-review-session (themata &optional due thema-count)
+ "Start review session for THEMATA.
+THEMATA: List of thema ids
+DUE: If due is non-nil, session will loop for due themata.
+THEMA-COUNT: Total themata to be commited for session."
+ (let ((thema-count (or thema-count 0)))
+ (if (null themata)
+ (message "No themata for review.")
+ (setf gnosis-review-themata themata)
+ (catch 'review-loop
+ (cl-loop for thema in themata
+ do (setq thema-count (gnosis-review-process-thema thema
thema-count))
+ finally
+ (and due (gnosis-review-session
+ (gnosis-collect-thema-ids :due t) t thema-count))))
+ (gnosis-dashboard)
+ (gnosis-review-commit thema-count))))
+
+(defun gnosis-review-commit (thema-num)
+ "Commit review session on git repository.
+
+This function initializes the `gnosis-dir' as a Git repository if it is not
+already one. It then adds the gnosis.db file to the repository and commits
+the changes with a message containing the reviewed number THEMA-NUM."
+ (let ((git (executable-find "git"))
+ (default-directory gnosis-dir))
+ (unless git
+ (error "Git not found, please install git"))
+ (unless (file-exists-p (expand-file-name ".git" gnosis-dir))
+ (vc-git-create-repo))
+ (unless gnosis-testing
+ (shell-command
+ (format "%s add gnosis.db" git))
+ (gnosis--shell-cmd-with-password
+ (format "%s commit -m 'Total themata reviewed: %d'" git thema-num)))
+ (sit-for 0.1)
+ (when (and gnosis-vc-auto-push (not gnosis-testing))
+ (gnosis-vc-push))
+ (message "Review session finished. %d themata reviewed." thema-num)))
+
+;;; Review actions
+
+(defun gnosis-review-action--edit (success thema thema-count)
+ "Edit THEMA during review.
+
+Save current contents of *gnosis-edit* buffer, if any, and start
+editing THEMA with it's new contents.
+
+After done editing, call `gnosis-review-actions' with SUCCESS THEMA
+THEMA-COUNT."
+ (gnosis-edit-thema thema)
+ (setf gnosis-review-editing-p t)
+ (recursive-edit)
+ (gnosis-review-actions success thema thema-count))
+
+(defun gnosis-review-action--quit (success thema)
+ "Quit review session.
+
+Update result for THEMA review with SUCCESS and commit session for THEMA-COUNT.
+
+This function should be used with `gnosis-review-actions', to finish
+the review session."
+ (gnosis-review-result thema success)
+ ;; Break the review loop of `gnosis-review-session'
+ (throw 'review-loop t))
+
+(defun gnosis-review-action--suspend (success thema thema-count)
+ "Suspend/Unsuspend THEMA.
+
+This function should be used with `gnosis-review-actions', which
+should be recursively called using SUCCESS, THEMA, THEMA-COUNT."
+ (gnosis-toggle-suspend-themata (list thema))
+ (gnosis-review-actions success thema thema-count))
+
+(defun gnosis-review-action--override (success thema thema-count)
+ "Override current review result for SUCCESS.
+
+This function should be used with `gnosis-review-actions', which will
+be called with new SUCCESS value plus THEMA & THEMA-COUNT."
+ (setf success (if success nil t))
+ (gnosis-display-next-review thema success)
+ (gnosis-review-actions success thema thema-count))
+
+(defun gnosis-review-action--view-link (success thema thema-count)
+ "View linked node(s) for THEMA."
+ (if (gnosis-get-linked-nodes thema)
+ (progn (gnosis-view-linked-node thema)
+ (recursive-edit))
+ (message (format "No linked nodes for thema: %d" thema))
+ (sleep-for 0.5))
+ (gnosis-review-actions success thema thema-count))
+
+(defun gnosis-review-actions (success id thema-count)
+ "Specify action during review of thema.
+
+SUCCESS: Review result
+ID: Thema ID
+THEMA-COUNT: Total themata reviewed
+
+To customize the keybindings, adjust `gnosis-review-keybindings'."
+ (let* ((prompt
+ "Action: %sext, %sverride result, %suspend, %selete, %sdit thema,
%siew link, %suit: ")
+ (choice (read-char-choice
+ (apply #'format prompt
+ (mapcar
+ (lambda (str) (propertize str 'face 'match))
+ '("n" "o" "s" "d" "e" "v" "q")))
+ '(?n ?o ?s ?d ?e ?v ?q))))
+ (pcase choice
+ (?n (gnosis-review-result id success))
+ (?o (gnosis-review-action--override success id thema-count))
+ (?s (gnosis-review-action--suspend success id thema-count))
+ (?d (gnosis-delete-thema id))
+ (?e (gnosis-review-action--edit success id thema-count))
+ (?v (gnosis-review-action--view-link success id thema-count))
+ (?q (gnosis-review-action--quit success id)))))
+
+;;; Monkeytype integration
+
+(defun gnosis-monkeytype-session (themata &rest _)
+ "Start monkeytype session for THEMATA ids."
+ (cl-assert (listp themata) nil "Themata must be a list of ids")
+ (catch 'monkeytype-loop
+ (cl-loop for thema in themata
+ do (gnosis-monkeytype-thema thema))))
+
+(defun gnosis-monkeytype-start ()
+ "Gnosis Monkeytype Session"
+ (interactive)
+ (gnosis-review #'gnosis-monkeytype-session))
+
+(defun gnosis-monkeytype-thema (thema)
+ "Process monkeytyping for THEMA id.
+
+This is used to type the keimenon of thema, with the answers highlighted.
+To monkeytype only the wrong answers use `gnosis-monkeytype-answer'."
+ (let* ((thema-context (gnosis-select '[keimenon type answer] 'themata `(= id
,thema) t))
+ (keimenon (replace-regexp-in-string
+ "\\[\\[\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" "\\2" ;; remove
links
+ (nth 0 thema-context)))
+ (type (nth 1 thema-context))
+ (answer (cl-loop for answer in (nth 2 thema-context)
+ collect (gnosis-utils-trim-quotes answer))))
+ (cond ((string= type "basic")
+ (gnosis-monkeytype (concat keimenon "\n" (car answer)) type
+ answer))
+ (t (gnosis-monkeytype keimenon type answer)))))
+
+(defun gnosis-monkeytype-answer (thema)
+ "Monkeytype answer for THEMA id."
+ (let* ((thema-context (gnosis-select '[type answer] 'themata `(= id ,thema)
t))
+ (type (nth 0 thema-context))
+ (answer (cl-loop for answer in (nth 1 thema-context)
+ collect (gnosis-utils-trim-quotes answer))))
+ (gnosis-monkeytype (mapconcat #'identity answer " ") type answer)))
+
+;;; Entry points
+
+;;;###autoload
+(defun gnosis-review (&optional fn)
+ "Start gnosis review session.
+
+FN: Review function, defaults to `gnosis-review-session'"
+ (interactive)
+ (setq gnosis-due-themata-total (length (gnosis-review-get-due-themata)))
+ (set-register :gnosis-pre-image nil)
+ (let ((review-type (gnosis-completing-read "Review: " gnosis-review-types))
+ (fn (or fn #'gnosis-review-session)))
+ (pcase review-type
+ ("Due themata"
+ (funcall fn (gnosis-collect-thema-ids :due t) t))
+ ("Due themata of deck"
+ (funcall fn (gnosis-collect-thema-ids :due t :deck
(gnosis--get-deck-id))))
+ ("Due themata of specified tag(s)"
+ (funcall fn (gnosis-collect-thema-ids :due t :tags t)))
+ ("Overdue themata"
+ (funcall fn (gnosis-review-get-overdue-themata)))
+ ("Due themata (Without Overdue)"
+ (funcall fn (cl-set-difference (mapcar #'car
(gnosis-review-get--due-themata))
+ (gnosis-review-get-overdue-themata))))
+ ("All themata of deck"
+ (funcall fn (gnosis-collect-thema-ids :deck (gnosis--get-deck-id))))
+ ("All themata of tag(s)"
+ (funcall fn (gnosis-collect-thema-ids :tags t))))))
+
+(defun gnosis-review--select-topic ()
+ "Prompt for topic from org-gnosis database and return it's id."
+ (let* ((topic-title (gnosis-completing-read "Select topic: "
+ (org-gnosis-select 'title
'nodes)))
+ (topic-id (caar (org-gnosis-select 'id 'nodes `(= title
,topic-title)))))
+ topic-id))
+
+(defun gnosis-collect-nodes-at-depth (node-id &optional fwd-depth back-depth)
+ "Collect node IDs reachable from NODE-ID within depth limits.
+FWD-DEPTH is max hops for forward links (default 0).
+BACK-DEPTH is max hops for backlinks (default 0).
+Returns a deduplicated list including NODE-ID itself."
+ (let ((fwd-depth (or fwd-depth 0))
+ (back-depth (or back-depth 0))
+ (max-depth (max fwd-depth back-depth))
+ (visited (make-hash-table :test 'equal))
+ (queue (list node-id)))
+ (puthash node-id t visited)
+ (dotimes (level max-depth)
+ (when queue
+ (let* ((qvec (vconcat queue))
+ (neighbors (append
+ (when (< level fwd-depth)
+ (org-gnosis-select 'dest 'links
+ `(in source ,qvec) t))
+ (when (< level back-depth)
+ (org-gnosis-select 'source 'links
+ `(in dest ,qvec) t))))
+ (next-queue nil))
+ (dolist (neighbor neighbors)
+ (unless (gethash neighbor visited)
+ (puthash neighbor t visited)
+ (push neighbor next-queue)))
+ (setq queue next-queue))))
+ (hash-table-keys visited)))
+
+;;;###autoload
+(defun gnosis-review-topic (&optional node-id fwd-depth back-depth)
+ "Review themata linked to topic NODE-ID.
+FWD-DEPTH and BACK-DEPTH control forward/backlink traversal depth.
+With prefix arg, prompt for depths."
+ (interactive
+ (list nil
+ (when current-prefix-arg (read-number "Forward link depth: " 1))
+ (when current-prefix-arg (read-number "Backlink depth: " 0))))
+ (let* ((node-id (or node-id (gnosis-review--select-topic)))
+ (fwd-depth (or fwd-depth 0))
+ (back-depth (or back-depth 0))
+ (node-title (car (org-gnosis-select 'title 'nodes
+ `(= id ,node-id) t)))
+ (node-ids (if (or (> fwd-depth 0) (> back-depth 0))
+ (gnosis-collect-nodes-at-depth
+ node-id fwd-depth back-depth)
+ (list node-id)))
+ (gnosis-questions (gnosis-select 'source 'links
+ `(in dest ,(vconcat node-ids)) t)))
+ (if (and gnosis-questions
+ (y-or-n-p
+ (format "Review %s thema(s) for '%s'%s?"
+ (length gnosis-questions) node-title
+ (if (> (length node-ids) 1)
+ (format " (%d nodes, fwd:%d back:%d)"
+ (length node-ids) fwd-depth back-depth)
+ ""))))
+ (gnosis-review-session gnosis-questions)
+ (message "No thema found for %s (id:%s)" node-title node-id))))
+
+(provide 'gnosis-review)
+;;; gnosis-review.el ends here