;;; gnosis-dashboard.el --- Spaced Repetition Algorithm for Gnosis -*- lexical-binding: t; -*- ;; Copyright (C) 2024 Thanos Apollo ;; Author: Thanos Apollo ;; Keywords: extensions ;; URL: https://git.thanosapollo.org/gnosis ;; Version: 0.0.1 ;; Package-Requires: ((emacs "27.2") (compat "29.1.4.2") (transient "0.7.2")) ;; 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 . ;;; Commentary: ;; This an extension of gnosis.el ;;; Code: (require 'cl-lib) (require 'calendar) (require 'transient) (declare-function gnosis-select "gnosis.el") (declare-function gnosis-delete-note "gnosis.el") (declare-function gnosis-suspend-note "gnosis.el") (declare-function gnosis-collect-note-ids "gnosis.el") (declare-function gnosis-edit-deck "gnosis.el") (declare-function gnosis-edit-note "gnosis.el") (declare-function gnosis-delete-deck "gnosis.el") (declare-function gnosis-suspend-deck "gnosis.el") (declare-function gnosis-add-deck "gnosis.el") (declare-function gnosis-add-note "gnosis.el") (declare-function gnosis-insert-separator "gnosis.el") (declare-function gnosis-get-date-total-notes "gnosis.el") (declare-function gnosis-center-string "gnosis.el") (declare-function gnosis-get-date-new-notes "gnosis.el") (declare-function gnosis-review-get-due-notes "gnosis.el") (declare-function gnosis-algorithm-date "gnosis-algorithm.el") (declare-function gnosis-get-tags--unique "gnosis.el") (declare-function gnosis-get-tag-notes "gnosis.el") (declare-function gnosis-edit-update "gnosis.el") (declare-function gnosis-update "gnosis.el") (defcustom gnosis-dashboard-months 2 "Number of additional months to display on dashboard." :type 'integer :group 'gnosis) (defvar gnosis-dashboard-note-ids nil "Store note ids for dashboard.") (defvar gnosis-dashboard-search-value nil "Store search value.") (defvar gnosis-dashboard--current '(:type nil :ids nil) "Current values to return after edits.") (defface gnosis-dashboard-header-face '((t :foreground "#ff0a6a" :weight bold)) "My custom face for both light and dark backgrounds.") (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)))) ;; TODO: Add more conds & faces (defun gnosis-dashboard--graph-propertize (string num) "Propertize STRING depending on the NUM of reviews." (cond ((= num 0) (propertize string 'face 'shadow)) ((> num 0) (propertize string 'face 'font-lock-constant-face)))) (defun gnosis-dashboard--add-padding (str-length) "Add padding for STR-LENGTH." (let ((padding (/ (- (window-width) str-length) 2))) (make-string padding ?\s))) (defun gnosis-dashboard-reviews-graph (dates &optional ) "Insert graph for month DATES. Optionally, use when using multiple months." (let ((count 0) (row 0) (start-column (current-column)) (end-column nil)) (cl-loop for day in dates when (= count 0) do (let ((current-column (current-column))) (and (< (move-to-column start-column) start-column) ;; Add spaces to reach start-column. (insert (make-string (- start-column current-column) ?\s)))) (insert " ") do (end-of-line) (insert (gnosis-dashboard--graph-propertize (format "[%s] " (if (= day 0) "-" "x")) day)) (cl-incf count) when (= count 7) do (setq end-column (current-column)) (setq count 0) (insert " ") (cl-incf row) (end-of-line) (when (and (/= (forward-line 1) 0) (eobp)) (insert "\n") (forward-line 0))) (insert (make-string (- end-column (current-column)) ?\s)) (insert " "))) ;; TODO: Refactor this! (defun gnosis-dashboard-month-overview (&optional num) "Insert review graph for MONTHS." (gnosis-insert-separator) (let* ((point (point)) (month (car (calendar-current-date)))) (insert (gnosis-dashboard--add-padding (min (* (max num 1) 50) (window-width)))) (while (<= month (+ (car (calendar-current-date)) num)) ;; (insert (format "%d" month)) (gnosis-dashboard-reviews-graph (gnosis-dashboard-month-reviews month)) (goto-char point) (end-of-line) (cl-incf month)))) (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*") (gnosis-dashboard-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-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*") (gnosis-dashboard-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-edit-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*") (gnosis-dashboard-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-edit-deck () "Get deck id from tabulated list and edit it." (interactive) (let ((id (tabulated-list-get-id))) (gnosis-edit-deck (string-to-number id)))) (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) (define-derived-mode gnosis-dashboard-mode tabulated-list-mode "Gnosis Dashboard" "Major mode for displaying Gnosis dashboard." :keymap gnosis-dashboard-mode-map (setq tabulated-list-padding 2 tabulated-list-sort-key nil gnosis-dashboard--selected-ids nil) (display-line-numbers-mode 0)) (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) ("q" "Quit" quit-window)] ["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)))]]) ;; TODO: Create a dashboard utilizing widgets ;;;###autoload (defun gnosis-dashboard () "Test function to create an editable field and a search button." (interactive) (delete-other-windows) (let ((buffer-name "*Gnosis Dashboard*")) (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 "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 "Due notes: %s" (propertize (number-to-string (length (gnosis-review-get-due-notes))) 'face 'error)))) (insert "\n\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-mode) (gnosis-dashboard-menu)))) (provide 'gnosis-dashboard) ;;; gnosis-dashboard.el ends here