summaryrefslogtreecommitdiff
path: root/org-fc.el
blob: 0d4dd01747d77eae787a2c85c1daf0af700103eb (about) (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
(require 'hydra)

(require 'org-fc-overlay)
(require 'org-fc-review)
(require 'org-fc-awk)
(require 'org-fc-dashboard)

;;; Configuration

(defgroup org-fc nil
  "Manage and review flashcards with emacs"
  :group 'external
  :group 'text)

;; TODO: a combination of (load-path) and (buffer-file-name) could be
;; used for this
(defcustom org-fc-source-path "~/src/org-fc/"
  "Location of the org-fc sources, used to generate absolute
  paths to the awk scripts"
  :type 'string
  :group 'org-fc)

(defcustom org-fc-review-history-file "~/org/fc_reviews.tsv"
  "File to store review results in."
  :type 'string
  :group 'org-fc)

(defcustom org-fc-type-property "FC_TYPE"
  "Property used to store the cards type."
  :type 'string
  :group 'org-fc)

(defcustom org-fc-created-property "FC_CREATED"
  "Property used to store the cards creation time."
  :type 'string
  :group 'org-fc)

(defcustom org-fc-suspended-tag "suspended"
  "Tag for marking suspended cards"
  :type 'string
  :group 'org-fc)

(defcustom org-fc-flashcard-tag "fc"
  "Tag for marking headlines as flashcards"
  :type 'string
  :group 'org-fc)

(defcustom org-fc-prefix-key (kbd "C-f")
  "Prefix key for all flashcard key bindings"
  :type 'key-sequence
  :group 'org-fc)

(defcustom org-fc-directories '("~/org")
  "Directories to search for flashcards"
  :type 'string
  :group 'org-fc)

(defcustom org-fc-unsuspend-overdue-percentage 0.1
  "Cards overdue by this percentage of their interval keep their
  spacing parameters when they are unsuspended. Cards overdue by
  more than that are reset."
  :type 'float
  :group 'org-fc)

;; TODO: Allow customizing this, currently that's not possible because
;; the indexers / filters expect a ISO8601 format.
(defvar org-fc-timestamp-format "%FT%H:%M:%S"
  "Format to use for storing timestamps.
Defaults to ISO8601")

;; TODO: Allow customizing this once different indexers are supported
(defvar org-fc-indexer
  'awk
  "Indexer to use for finding cards / positions.
Only 'awk is supported at the moment.")

(defvar org-fc-demo-mode nil
  "If set to a non-nil value, a cards review data is not
  updated. Used by `org-fc-demo'")
(make-variable-buffer-local 'org-fc-demo-mode)

;;; Helper Functions

(defun org-fc-timestamp-now ()
  "ISO8601 timestamp of the current time in the UTC0 timezone"
  (format-time-string org-fc-timestamp-format nil "UTC0"))

(defun org-fc-days-overdue (ts)
  "Number of days between now and the ISO8601 timestamp TS."
  (/ (- (time-to-seconds)
        (time-to-seconds (date-to-time ts)))
     (* 24 60 60)))

(defun org-fc-show-latex ()
  "Show / re-display latex fragments."
  (org-clear-latex-preview)
  (org-latex-preview))

;; TODO: Rewrite using skip parameter
(defun org-fc-has-back-heading-p ()
  "Check if the entry at point has a 'Back' subheading.
Used to determine if a card uses the compact style."
  (let ((found nil))
    (org-map-entries
     (lambda ()
       (when (string= (fifth (org-heading-components)) "Back")
         (setq found t)))
     t 'tree)
    found))

;;; Checking for / going to flashcard headings

(defun org-fc-entry-p ()
  "Check if the current heading is a flashcard"
  (member org-fc-flashcard-tag (org-get-tags-at nil 'local)))

(defun org-fc-suspended-entry-p ()
  "Check if the current heading is a suspended flashcard"
  (let ((tags (org-get-tags-at nil 'local)))
    (and (member org-fc-flashcard-tag tags)
         (member org-fc-suspended-tag tags))))

(defun org-fc-part-of-entry-p ()
  "Check if the current heading belongs to a flashcard"
  (member org-fc-flashcard-tag (org-get-tags-at)))

(defun org-fc-goto-entry-heading ()
  "Move up to the parent heading marked as a flashcard."
  (unless (org-fc-part-of-entry-p)
    (error "Not inside a flashcard entry"))
  (unless (org-at-heading-p)
    (org-back-to-heading))
  (while (not (org-fc-entry-p))
    (unless (org-up-heading-safe)
      (error "Cannot find a parent heading that is marked as a flashcard"))))

;;; Adding / Removing Tags

(defun org-fc-add-tag (tag)
  "Add TAG to the heading at point."
  (org-set-tags-to (remove-duplicates
                    (cons tag (org-get-tags-at (point) t))
                    :test #'string=)))

(defun org-fc-remove-tag (tag)
  "Add TAG to the heading at point."
  (org-set-tags-to
   (remove tag (org-get-tags-at (point) t))))

;;; Registering Card Types

(defvar org-fc-types '()
  "Alist for registering card types.
Entries should be lists (name handler-fn update-fn).
Use `org-fc-register-type' for adding card types.")

(defun org-fc-register-type (name setup-fn flip-fn update-fn)
  "Register a new card type."
  (push
   (list name setup-fn flip-fn update-fn)
   org-fc-types))

(defun org-fc-type-setup-fn (type)
  "Get the review function for a card of TYPE."
  (let ((entry (alist-get type org-fc-types nil nil #'string=)))
    (if entry
        (first entry)
      (error "No such flashcard type: %s" type))))

(defun org-fc-type-flip-fn (type)
  "Get the flip function for a card of TYPE."
  (let ((entry (alist-get type org-fc-types nil nil #'string=)))
    (if entry
        (second entry)
      (error "No such flashcard type: %s" type))))

(defun org-fc-type-update-fn (type)
  "Get the update function for a card of TYPE."
  (let ((entry (alist-get type org-fc-types nil nil #'string=)))
    (if entry
        (third entry)
      (error "No such flashcard type: %s" type))))

;;; Card Initialization

(defun org-fc--init-card (type)
  "Initialize the current card as a flashcard.
Should only be used by the init functions of card types."
  (if (org-fc-part-of-entry-p)
      (error "Headline is already a flashcard"))
  (org-back-to-heading)
  (org-set-property
   org-fc-created-property
   (org-fc-timestamp-now))
  (org-set-property org-fc-type-property type)
  (org-id-get-create)
  (org-fc-add-tag org-fc-flashcard-tag))

;;; Default Card Types

(require 'org-fc-type-normal)
(require 'org-fc-type-text-input)
(require 'org-fc-type-double)
(require 'org-fc-type-cloze)

;;; Updating Cards

(defun org-fc-map-cards (fn)
  "Call FN for each flashcard headline in the current buffer.
FN is called with point at the headline and no arguments."
  (org-map-entries
   (lambda () (if (org-fc-entry-p) (funcall fn)))))

(defun org-fc-update ()
  "Re-process the current flashcard"
  (interactive)
  (unless (org-fc-part-of-entry-p)
      (error "Not part of a flashcard entry"))
  (save-excursion
    (org-fc-goto-entry-heading)
    (let ((type (org-entry-get (point) "FC_TYPE")))
      (funcall (org-fc-type-update-fn type)))))

(defun org-fc-update-all ()
  "Re-process all flashcards in the current buffer"
  (interactive)
  (org-fc-map-cards 'org-fc-update))

;;; Suspending / Unsuspending Cards

(defun org-fc-suspend-card ()
  "Suspend the headline at point if it is a flashcard."
  (interactive)
  (if (org-fc-entry-p)
      (progn
        (org-fc-goto-entry-heading)
        (org-fc-add-tag org-fc-suspended-tag))
    (message "Entry at point is not a flashcard")))

(defun org-fc-suspend-buffer ()
  "Suspend all cards in the current buffer"
  (interactive)
  (org-fc-map-cards 'org-fc-suspend-card))

(defun org-fc--unsuspend-card ()
  "If a position is overdue by more than
`org-fc-unsuspend-overdue-percentage' of its interval, reset it to box 0,
if not, keep the current parameters."
  (when (org-fc-suspended-entry-p)
    (org-fc-remove-tag org-fc-suspended-tag)
    (-as-> (org-fc-get-review-data) data
           (--map
            (let* ((pos (first it))
                   (interval (string-to-number (fourth it)))
                   (due (fifth it))
                   (days-overdue (org-fc-days-overdue due)))
              (print days-overdue)
              (if (< days-overdue (* org-fc-unsuspend-overdue-percentage interval))
                  it
                  (org-fc-review-data-default pos)))
            data)
           (org-fc-set-review-data data))))

(defun org-fc-unsuspend-card ()
  "Un-suspend the headline at point if it is a suspended
flashcard."
  (interactive)
  (if (org-fc-suspended-entry-p)
      (progn (org-fc-goto-entry-heading)
             (org-fc--unsuspend-card))
    (message "Entry at point is not a suspended flashcard")))

(defun org-fc-unsuspend-buffer ()
  "Un-suspend all cards in the current buffer"
  (interactive)
  (org-fc-map-cards 'org-fc-unsuspend-card))

;;; Indexing Cards

(defun org-fc-due-positions-for-paths (paths)
  (if (eq org-fc-indexer 'awk)
      (org-fc-awk-due-positions-for-paths paths)
    (error
     'org-fc-indexer-error
     (format "Indexer %s not implemented yet" org-fc-indexer-error))))

(defun org-fc-due-positions (context)
  "Return a shuffled list of elements (file id position) of due cards."
  (case context
    ('all (org-fc-due-positions-for-paths org-fc-directories))
    ('buffer (org-fc-due-positions-for-paths (list (buffer-file-name))))
    (t (error "Unknown review context %s" context))))

;;; Demo Mode

(defun org-fc-demo ()
  "Start a review of the demo file."
  (interactive)
  (let ((path (expand-file-name "demo.org" org-fc-source-path)))
    (with-current-buffer (find-file path)
      (setq-local org-fc-demo-mode t)
      (org-fc-review-buffer))))

;;; Exports

(provide 'org-fc)