From 3f5641f6e9738816a9a278e083f7b868bd425b5d Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Wed, 9 Oct 2024 06:01:11 +0300 Subject: [Refactor] Rewrite gnosis-dashboard as part of gnosis.el --- gnosis.el | 538 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 526 insertions(+), 12 deletions(-) (limited to 'gnosis.el') diff --git a/gnosis.el b/gnosis.el index c2dbb5b..4b36625 100644 --- a/gnosis.el +++ b/gnosis.el @@ -40,19 +40,20 @@ ;;; Code: -(require 'cl-lib) -(require 'subr-x) + +(eval-when-compile + (require 'cl-lib) + (require 'calendar) + (require 'subr-x)) (require 'vc) (require 'emacsql-sqlite) +(require 'transient) +(require 'animate) (require 'gnosis-algorithm) (require 'gnosis-string-edit) -(require 'gnosis-dashboard) - -(require 'gnosis-org) - -(require 'animate) +;; (require 'gnosis-org) (defgroup gnosis nil "Spaced Repetition System For Note Taking & Self Testing." @@ -280,6 +281,31 @@ When nil, review new notes last." "Face for next review." :group 'gnosis-face) +(defface gnosis-face-review-action-next + '((t :inherit match)) + "Face for review action *next*." + :group 'gnosis-face) + +(defface gnosis-face-review-action-override + '((t :inherit match)) + "Face for review action *override*." + :group 'gnosis-face) + +(defface gnosis-face-review-action-suspend + '((t :inherit match)) + "Face for review action *suspend*." + :group 'gnosis-face) + +(defface gnosis-face-review-action-edit + '((t :inherit match)) + "Face for review action *edit*." + :group 'gnosis-face) + +(defface gnosis-face-review-action-quit + '((t :inherit match)) + "Face for review action *quit*." + :group 'gnosis-face) + (cl-defun gnosis-select (value table &optional (restrictions '1=1) (flatten nil)) "Select VALUE from TABLE, optionally with RESTRICTIONS. @@ -1800,11 +1826,11 @@ To customize the keybindings, adjust `gnosis-review-keybindings'." (let* ((choice (read-char-choice (format "Action: %sext gnosis, %sverride result, %suspend note, %sdit note, %suit review session" - (propertize "n" 'face 'match) - (propertize "o" 'face 'match) - (propertize "s" 'face 'match) - (propertize "e" 'face 'match) - (propertize "q" 'face 'match)) + (propertize "n" 'face 'gnosis-face-review-action-next) + (propertize "o" 'face 'gnosis-face-review-action-override) + (propertize "s" 'face 'gnosis-face-review-action-suspend) + (propertize "e" 'face 'gnosis-face-review-action-edit) + (propertize "q" 'face 'gnosis-face-review-action-quit)) '(?n ?o ?s ?e ?q)))) (pcase choice (?n (gnosis-review-result note success)) @@ -2561,6 +2587,494 @@ If STRING-SECTION is nil, apply FACE to the entire STRING." :id (number-to-string (caddr note)) :type (cadddr note))))) +;; Dashboard +;;;;;;;;;;;; + +(defface gnosis-dashboard-header-face + '((t :foreground "#ff0a6a" :weight bold)) + "My custom face for both light and dark backgrounds.") + +(defvar gnosis-dashboard-note-ids nil + "Store note ids for dashboard.") + +(defvar gnosis-dashboard-buffer-name "*Gnosis Dashboard*" + "Name of gnosis-dashboard buffer.") + +(defvar gnosis-dashboard-search-value nil + "Store search value.") + +(defvar gnosis-dashboard--current + '(:type nil :ids nil) + "Current values to return after edits.") + +(defvar gnosis-dashboard--selected-ids nil + "Selected ids from the tabulated list.") + + +(defun gnosis-dashboard-return (&optional current-values) + "Return to dashboard for CURRENT-VALUES." + (interactive) + (let* ((current-values (or current-values gnosis-dashboard--current)) + (type (plist-get current-values :type)) + (ids (plist-get current-values :ids))) + (cond ((eq type 'notes) + (gnosis-dashboard-output-notes ids)) + ((eq type 'decks ) + (gnosis-dashboard-output-decks)) + ((eq type 'tags ) + (gnosis-dashboard-output-tags))))) + +(defun gnosis-dashboard-generate-dates (&optional year) + "Return a list of all dates (year month day) for YEAR." + (let* ((current-year (or (decoded-time-year (decode-time)) year)) + (result '())) + (dotimes (month 12) + (let ((days-in-month (calendar-last-day-of-month (+ month 1) current-year))) + (dotimes (day days-in-month) + (push (list current-year (+ month 1) (+ day 1)) result)))) + (nreverse result))) + +(defun gnosis-dashboard-year-stats (&optional year) + "Return YEAR review stats." + (let ((notes nil)) + (cl-loop for date in (gnosis-dashboard-generate-dates (and year)) + do (setq notes (append notes (list (gnosis-get-date-total-notes date))))) + notes)) + +(defun gnosis-dashboard--streak (dates &optional num date) + "Return current review streak. + +DATES: Dates in the activity log. +NUM: Streak number. +DATE: Integer, used with `gnosis-algorithm-date' to get previous dates." + (let ((num (or num 0)) + (date (or date 0))) + (if (member (gnosis-algorithm-date date) dates) + (gnosis-dashboard--streak dates (cl-incf num) (- date 1)) + num))) + +(defun gnosis-dashboard-month-reviews (month) + "Return reviewes for MONTH in current year." + (cl-assert (and (integerp month) + (< month 12)) + nil "Month must be an integer, lower than 12.") + (let* ((month-dates (cl-loop for date in (gnosis-dashboard-generate-dates) + if (and (= (nth 1 date) month) + (= (nth 0 date) (decoded-time-year (decode-time)))) + collect date)) + (month-reviews (cl-loop for date in month-dates + collect (gnosis-get-date-total-notes date)))) + month-reviews)) + +;; TODO: Optionally, add dates where no review was made. +(defun gnosis-dashboard-output-average-rev () + "Output the average daily notes reviewed for current year. + +Skips days where no note was reviewed." + (let ((total 0) + (entries (gnosis-dashboard-year-stats))) + (cl-loop for entry in entries + when (not (= entry 0)) + do (setq total (+ total entry))) + (/ total (max (length (remove 0 entries)) 1)))) + +(defun gnosis-dashboard-output-note (id) + "Output contents for note with ID, formatted for gnosis dashboard." + (cl-loop for item in (append (gnosis-select '[main options answer tags type] 'notes `(= id ,id) t) + (gnosis-select 'suspend 'review-log `(= id ,id) t)) + if (listp item) + collect (mapconcat #'identity item ",") + else + collect (replace-regexp-in-string "\n" " " (format "%s" item)))) + +(defun gnosis-dashboard-edit-note (&optional id) + "Edit note with ID." + (interactive) + (let ((id (or id (string-to-number (tabulated-list-get-id))))) + (gnosis-edit-note id))) + +(defun gnosis-dashboard-suspend-note () + "Suspend note." + (interactive) + (if gnosis-dashboard--selected-ids + (gnosis-dashboard-marked-suspend) + (gnosis-suspend-note (string-to-number (tabulated-list-get-id))) + (gnosis-dashboard-output-notes gnosis-dashboard-note-ids) + (revert-buffer t t t))) + +(defun gnosis-dashboard-delete () + "Delete note." + (interactive) + (if gnosis-dashboard--selected-ids + (gnosis-dashboard-marked-delete) + (gnosis-delete-note (string-to-number (tabulated-list-get-id))) + (gnosis-dashboard-output-notes gnosis-dashboard-note-ids) + (revert-buffer t t t))) + +(defvar-keymap gnosis-dashboard-notes-mode-map + :doc "Keymap for notes dashboard." + "e" #'gnosis-dashboard-edit-note + "s" #'gnosis-dashboard-suspend-note + "a" #'gnosis-add-note + "r" #'gnosis-dashboard-return + "g" #'gnosis-dashboard-return + "d" #'gnosis-dashboard-delete + "m" #'gnosis-dashboard-mark-toggle + "u" #'gnosis-dashboard-mark-toggle) + +(define-minor-mode gnosis-dashboard-notes-mode + "Minor mode for gnosis dashboard notes output." + :keymap gnosis-dashboard-notes-mode-map) + +(defun gnosis-dashboard-output-notes (note-ids) + "Return NOTE-IDS contents on gnosis dashboard." + (cl-assert (listp note-ids) t "`note-ids' must be a list of note ids.") + (pop-to-buffer-same-window gnosis-dashboard-buffer-name) + (gnosis-dashboard-enable-mode) + (gnosis-dashboard-notes-mode) + (setf tabulated-list-format `[("Main" ,(/ (window-width) 4) t) + ("Options" ,(/ (window-width) 6) t) + ("Answer" ,(/ (window-width) 6) t) + ("Tags" ,(/ (window-width) 5) t) + ("Type" ,(/ (window-width) 10) T) + ("Suspend" ,(/ (window-width) 6) t)] + tabulated-list-entries (cl-loop for id in note-ids + for output = (gnosis-dashboard-output-note id) + when output + collect (list (number-to-string id) (vconcat output))) + gnosis-dashboard-note-ids note-ids) + (tabulated-list-init-header) + (tabulated-list-print t) + (setf gnosis-dashboard--current `(:type notes :ids ,note-ids))) + +(defun gnosis-dashboard-deck-note-count (id) + "Return total note count for deck with ID." + (let ((note-count (length (gnosis-select 'id 'notes `(= deck-id ,id) t)))) + (when (gnosis-select 'id 'decks `(= id ,id)) + (list (number-to-string note-count))))) + +(defun gnosis-dashboard-output-tag (tag) + "Output TAG name and total notes." + (let ((notes (gnosis-get-tag-notes tag))) + `(,tag ,(number-to-string (length notes))))) + +(defun gnosis-dashboard-sort-total-notes (entry1 entry2) + "Sort function for the total notes column, for ENTRY1 and ENTRY2." + (let ((total1 (string-to-number (elt (cadr entry1) 1))) + (total2 (string-to-number (elt (cadr entry2) 1)))) + (< total1 total2))) + +(defun gnosis-dashboard-rename-tag (&optional tag new-tag ) + "Rename TAG to NEW-TAG." + (interactive) + (let ((new-tag (or new-tag (read-string "News tag name: "))) + (tag (or tag (tabulated-list-get-id)))) + (cl-loop for note in (gnosis-get-tag-notes tag) + do (let* ((tags (car (gnosis-select '[tags] 'notes `(= id ,note) t))) + (new-tags (cl-substitute new-tag tag tags :test #'string-equal))) + (gnosis-update 'notes `(= tags ',new-tags) `(= id ,note)))))) + +(defun gnosis-dashboard-rename-deck (&optional deck-id new-name) + "Rename deck where DECK-ID with NEW-NAME." + (interactive) + (let ((deck-id (or deck-id (string-to-number (tabulated-list-get-id)))) + (new-name (or new-name (read-string "New deck name: ")))) + (gnosis-update 'decks `(= name ,new-name) `(= id ,deck-id)) + (gnosis-dashboard-output-decks))) + +(defun gnosis-dashboard-suspend-tag (&optional tag) + "Suspend notes of TAG." + (interactive) + (let* ((tag (or tag (tabulated-list-get-id))) + (notes (gnosis-get-tag-notes tag))) + (when (y-or-n-p "Toggle SUSPEND for tagged notes?") + (cl-loop for note in notes + do (gnosis-suspend-note note t))))) + +(defun gnosis-dashboard-tag-view-notes (&optional tag) + "View notes for TAG." + (interactive) + (let ((tag (or tag (tabulated-list-get-id)))) + (gnosis-dashboard-output-notes (gnosis-get-tag-notes tag)))) + +(defvar-keymap gnosis-dashboard-tags-mode-map + "RET" #'gnosis-dashboard-tag-view-notes + "e" #'gnosis-dashboard-rename-tag + "s" #'gnosis-dashboard-suspend-tag + "r" #'gnosis-dashboard-rename-tag + "g" #'gnosis-dashboard-return) + +(define-minor-mode gnosis-dashboard-tags-mode + "Mode for dashboard output of tags." + :keymap gnosis-dashboard-tags-mode-map) + +(defun gnosis-dashboard-output-tags (&optional tags) + "Format gnosis dashboard with output of TAGS." + (let ((tags (or tags (gnosis-get-tags--unique)))) + (pop-to-buffer-same-window gnosis-dashboard-buffer-name) + (gnosis-dashboard-enable-mode) + (gnosis-dashboard-tags-mode) + (setf gnosis-dashboard--current '(:type 'tags)) + (setq tabulated-list-format [("Name" 35 t) + ("Total Notes" 10 gnosis-dashboard-sort-total-notes)]) + (tabulated-list-init-header) + (setq tabulated-list-entries + (cl-loop for tag in tags + collect (list (car (gnosis-dashboard-output-tag tag)) + (vconcat (gnosis-dashboard-output-tag tag))))) + (tabulated-list-print t))) + +(defun gnosis-dashboard-output-deck (id) + "Output contents from deck with ID, formatted for gnosis dashboard." + (cl-loop for item in (append (gnosis-select 'name + 'decks `(= id ,id) t) + (mapcar 'string-to-number (gnosis-dashboard-deck-note-count id))) + when (listp item) + do (cl-remove-if (lambda (x) (and (vectorp x) (zerop (length x)))) item) + collect (format "%s" item))) + +(defvar-keymap gnosis-dashboard-decks-mode-map + "e" #'gnosis-dashboard-rename-deck + "r" #'gnosis-dashboard-rename-deck + "a" #'gnosis-dashboard-decks-add + "s" #'gnosis-dashboard-decks-suspend-deck + "d" #'gnosis-dashboard-decks-delete + "RET" #'gnosis-dashboard-decks-view-deck) + +(define-minor-mode gnosis-dashboard-decks-mode + "Minor mode for deck output." + :keymap gnosis-dashboard-decks-mode-map) + +(defun gnosis-dashboard-output-decks () + "Return deck contents for gnosis dashboard." + (pop-to-buffer-same-window gnosis-dashboard-buffer-name) + (gnosis-dashboard-enable-mode) + (gnosis-dashboard-decks-mode) + (setq tabulated-list-format [("Name" 15 t) + ("Total Notes" 10 gnosis-dashboard-sort-total-notes)]) + (tabulated-list-init-header) + (setq tabulated-list-entries + (cl-loop for id in (gnosis-select 'id 'decks '1=1 t) + for output = (gnosis-dashboard-output-deck id) + when output + collect (list (number-to-string id) (vconcat output)))) + (tabulated-list-print t) + (setf gnosis-dashboard--current `(:type decks :ids ,(gnosis-select 'id 'decks '1=1 t)))) + +(defun gnosis-dashboard-decks-add () + "Add deck & refresh." + (interactive) + (gnosis-add-deck (read-string "Deck name: ")) + (gnosis-dashboard-output-decks) + (revert-buffer t t t)) + +(defun gnosis-dashboard-decks-suspend-deck (&optional deck-id) + "Suspend notes for DECK-ID. + +When called with called with a prefix, unsuspend all notes of deck." + (interactive) + (let ((deck-id (or deck-id (string-to-number (tabulated-list-get-id))))) + (gnosis-suspend-deck deck-id) + (gnosis-dashboard-output-decks) + (revert-buffer t t t))) + +(defun gnosis-dashboard-decks-delete (&optional deck-id) + "Delete DECK-ID." + (interactive) + (let ((deck-id (or deck-id (string-to-number (tabulated-list-get-id))))) + (gnosis-delete-deck deck-id) + (gnosis-dashboard-output-decks) + (revert-buffer t t t))) + +(defun gnosis-dashboard-decks-view-deck (&optional deck-id) + "View notes of DECK-ID." + (interactive) + (let ((deck-id (or deck-id (string-to-number (tabulated-list-get-id))))) + (gnosis-dashboard-output-notes (gnosis-collect-note-ids :deck deck-id)))) + +(defvar-keymap gnosis-dashboard-mode-map + :doc "gnosis-dashboard keymap" + "q" #'quit-window + "h" #'gnosis-dashboard-menu + "r" #'gnosis-review + "a" #'gnosis-add-note + "A" #'gnosis-add-deck + "s" #'gnosis-dashboard-suffix-query + "n" #'(lambda () (interactive) (gnosis-dashboard-output-notes (gnosis-collect-note-ids))) + "d" #'gnosis-dashboard-suffix-decks + "t" #'(lambda () (interactive) (gnosis-dashboard-output-tags))) + +(define-derived-mode gnosis-dashboard-mode tabulated-list-mode "Gnosis Dashboard" + "Major mode for displaying Gnosis dashboard." + :keymap gnosis-dashboard-mode-map + :interactive nil + (setq tabulated-list-padding 2 + tabulated-list-sort-key nil + gnosis-dashboard--selected-ids nil) + (display-line-numbers-mode 0)) + +(defun gnosis-dashboard-enable-mode () + "Enable `gnosis-dashboard-mode'. + +This should only be enabled in a gnosis dashboard buffer." + (when (string= (buffer-name) gnosis-dashboard-buffer-name) + (gnosis-dashboard-mode))) + +(cl-defun gnosis-dashboard--search (&optional dashboard-type (note-ids nil)) + "Display gnosis dashboard. + +NOTE-IDS: List of note ids to display on dashboard. When nil, prompt +for dashboard type. + +DASHBOARD-TYPE: either 'Notes' or 'Decks' to display the respective dashboard." + (interactive) + (let ((dashboard-type (or dashboard-type + (cadr (read-multiple-choice + "Display dashboard for:" + '((?n "notes") + (?d "decks") + (?t "tags") + (?s "search"))))))) + (if note-ids (gnosis-dashboard-output-notes note-ids) + (pcase dashboard-type + ("notes" (gnosis-dashboard-output-notes (gnosis-collect-note-ids))) + ("decks" (gnosis-dashboard-output-decks)) + ("tags" (gnosis-dashboard-output-notes (gnosis-collect-note-ids :tags t))) + ("search" (gnosis-dashboard-output-notes + (gnosis-collect-note-ids :query (read-string "Search for note: ")))))) + (tabulated-list-print t))) + +(defun gnosis-dashboard-mark-toggle () + "Toggle mark on the current item in the tabulated-list." + (interactive) + (let ((inhibit-read-only t) + (entry (tabulated-list-get-entry)) + (id (tabulated-list-get-id))) + (if (derived-mode-p 'tabulated-list-mode) + (if entry + (let ((beg (line-beginning-position)) + (end (line-end-position)) + (overlays (overlays-in (line-beginning-position) (line-end-position)))) + (if (cl-some (lambda (ov) (overlay-get ov 'gnosis-mark)) overlays) + (progn + (remove-overlays beg end 'gnosis-mark t) + (setq gnosis-dashboard--selected-ids (remove id gnosis-dashboard--selected-ids)) + ;; (message "Unmarked: %s" (aref entry 0)) + ) + (let ((ov (make-overlay beg end))) + (setf gnosis-dashboard--selected-ids + (append gnosis-dashboard--selected-ids (list id))) + (overlay-put ov 'face 'highlight) + (overlay-put ov 'gnosis-mark t) + ;; (message "Marked: %s" (aref entry 0)) + ))) + (message "No entry at point")) + (message "Not in a tabulated-list-mode")))) + +(defun gnosis-dashboard-unmark-all () + "Unmark all items in the tabulated-list." + (interactive) + (let ((inhibit-read-only t)) + (setq gnosis-dashboard--selected-ids nil) + (remove-overlays nil nil 'gnosis-mark t) + (message "All items unmarked"))) + +(defun gnosis-dashboard-marked-delete () + "Delete marked note entries." + (interactive) + (when (y-or-n-p "Delete selected notes?") + (cl-loop for note in gnosis-dashboard--selected-ids + do (gnosis-delete-note (string-to-number note) t)) + (gnosis-dashboard-return))) + +(defun gnosis-dashboard-marked-suspend () + "Suspend marked note entries." + (interactive) + (when (y-or-n-p "Toggle SUSPEND on selected notes?") + (cl-loop for note in gnosis-dashboard--selected-ids + do (gnosis-suspend-note (string-to-number note) t)) + (gnosis-dashboard-return))) + +(transient-define-suffix gnosis-dashboard-suffix-query (query) + "Search for note content for QUERY." + (interactive "sSearch for note content: ") + (gnosis-dashboard-output-notes (gnosis-collect-note-ids :query query))) + +(transient-define-suffix gnosis-dashboard-suffix-decks () + (interactive) + (gnosis-dashboard-output-decks)) + +(transient-define-prefix gnosis-dashboard-menu () + "Transient buffer for gnosis dashboard interactions." + [["Actions" + ("r" "Review" gnosis-review) + ("a" "Add note" gnosis-add-note) + ("A" "Add deck" gnosis-add-deck) + ("q" "Quit" quit-window) + "\n"] + ["Notes" + ("s" "Search" gnosis-dashboard-suffix-query) + ("n" "Notes" (lambda () (interactive) (gnosis-dashboard-output-notes (gnosis-collect-note-ids)))) + ("d" "Decks" gnosis-dashboard-suffix-decks) + ("t" "Tags" (lambda () (interactive) (gnosis-dashboard-output-tags)))]]) + +;;;###autoload +(defun gnosis-dashboard () + "Test function to create an editable field and a search button." + (interactive) + (delete-other-windows) + (let ((buffer-name gnosis-dashboard-buffer-name) + (due-notes (gnosis-review-get-due-notes))) + (when (get-buffer buffer-name) + (kill-buffer buffer-name)) ;; Kill the existing buffer if it exists + (let ((buffer (get-buffer-create buffer-name))) + (with-current-buffer buffer + (widget-insert "\n" + (gnosis-center-string + (format "%s" (propertize "Gnosis Dashboard" 'face 'gnosis-dashboard-header-face)))) + (gnosis-insert-separator) + ;; (widget-insert (gnosis-center-string (propertize "Stats:" 'face 'underline)) "\n\n") + (widget-insert (gnosis-center-string + (format "Reviewed today: %s (New: %s)" + (propertize + (number-to-string (gnosis-get-date-total-notes)) + 'face + 'font-lock-variable-use-face) + (propertize + (number-to-string (gnosis-get-date-new-notes)) + 'face + 'font-lock-keyword-face)))) + (insert "\n") + (widget-insert (gnosis-center-string + (format "Due notes: %s (Overdue: %s)" + (propertize + (number-to-string (length due-notes)) + 'face 'error) + (propertize + (number-to-string (length (gnosis-review-get-overdue-notes due-notes))) + 'face 'warning)))) + (insert "\n\n") + (widget-insert (gnosis-center-string + (format "Daily Average: %s" + (propertize (number-to-string (gnosis-dashboard-output-average-rev)) + 'face 'font-lock-type-face)))) + (insert "\n") + (widget-insert (gnosis-center-string + (format "Current streak: %s days" + (propertize + (number-to-string + (gnosis-dashboard--streak + (gnosis-select 'date 'activity-log '1=1 t))) + 'face 'success)))) + (insert "\n\n") + ;; (gnosis-dashboard-month-overview (or gnosis-dashboard-months 0)) + (use-local-map widget-keymap) + (widget-setup)) + (pop-to-buffer-same-window buffer) + (goto-char (point-min)) + (gnosis-dashboard-enable-mode) + (gnosis-dashboard-menu)))) + ;; Gnosis mode ;; ;;;;;;;;;;;;;;;;; -- cgit v1.2.3