summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLeon Rische <leon.rische@me.com>2020-01-11 15:24:56 +0100
committerLeon Rische <leon.rische@me.com>2020-01-11 15:24:56 +0100
commit1c7838eb972ac365e648fc231620cb5f18a07788 (patch)
treed0a339cec50b9ecb468f2c7d380e3d3c37e85927
Initial commit
-rw-r--r--README.org464
-rw-r--r--awk/files.awk18
-rw-r--r--awk/filter_due.awk8
-rw-r--r--awk/index_cards.awk46
-rw-r--r--awk/index_positions.awk69
-rw-r--r--awk/stats_cards.awk47
-rw-r--r--awk/stats_positions.awk40
-rw-r--r--awk/stats_reviews.awk53
-rw-r--r--awk/utils.awk23
-rw-r--r--demo.org70
-rw-r--r--org-fc-awk.el190
-rw-r--r--org-fc-dashboard.el138
-rw-r--r--org-fc-overlay.el165
-rw-r--r--org-fc-review.el288
-rw-r--r--org-fc-sm2.el82
-rw-r--r--org-fc-type-cloze.el163
-rw-r--r--org-fc-type-double.el41
-rw-r--r--org-fc-type-normal.el34
-rw-r--r--org-fc-type-text-input.el30
-rw-r--r--org-fc.el306
20 files changed, 2275 insertions, 0 deletions
diff --git a/README.org b/README.org
new file mode 100644
index 0000000..817ea24
--- /dev/null
+++ b/README.org
@@ -0,0 +1,464 @@
+* Org Flashcards
+Spaced-repetition system for use with Emacs org-mode.
+
+#+CAPTION: Review Demo
+[[file:images/review.png]]
+
+** Introduction
+In the most abstract sense, this package deals with
+
+1. Attaching timestamped review information to headlines
+2. Querying all headings where reviews are due
+3. Reviewing due *positions* of headings
+
+As mentioned in step 3, a heading can have multiple *positions*,
+e.g. to implement cloze-deletions where multiple items are reviewed
+independently from each other.
+
+In the reviewing step, display functions can be registered by card
+type. This allows easy addition of user-defined card types without
+having to think about storing and updating review data.
+
+Review functions are called with point on the headline of the card
+that should be reviewed and get passed a single argument,
+the position to be reviewed.
+
+They are expected to return either ~'quit~ to end the review or one of
+~'again~, ~'hard~, ~'good~, ~'ease~, to rate the card.
+
+While the primary application is learning information using spaced
+repetition, at the end, the API should be flexible enough to implement
+other kinds of repeating tasks where it is necessary to store data in
+addition to the next date.
+
+One example would be storing one exercise per heading, using the
+positions to store one or more sets and logging the number of
+repetitions done on each "review".
+
+** Stability
+This package should be considered *unstable*. I use it on a daily
+basis but the API might change in breaking ways.
+
+If that happens, I'll add a changelog entry with updating
+instructions. In case the card format changes, I'll include code to
+update existing collections of cards.
+** Prior Art
+There are a few other packages for implementing a SRS based on org-mode.
+
+The biggest difference between this package and the ones I've found so
+far:
+
+1. Use of =awk= for quickly finding cards due for review
+2. Support for multiple *positions* in a card
+
+Below, I've listed a few packages that are actively maintained and
+implement a lot of useful functionality.
+
+- [[https://gitlab.com/phillord/org-drill/][phillord/org-drill]]
+- [[https://github.com/abo-abo/pamparam][abo-abo/pamparam]]
+
+Thanks to the maintainers and all contributors for their work on these
+packages!
+
+*** TODO Mention supermemo, anki, memosyne
+** Design Criteria
+*** Performance
+All user-facing commands (especially during review) should be as fast
+as possible (<300ms).
+
+Using the =awk= indexer, searching 2500 org files (~200k lines in
+total) for due flashcards takes around ~500ms on my laptop (Thinkpad
+L470, SSD).
+
+Using the lisp indexer based on ~org-map-entries~,
+searching a single 6500 line file with 333 flashcards takes ~1000ms,
+indexing the same file with =awk= takes around ~50ms.
+*** All Relevant Data Kept Org Files
+For easy version control
+*** Multiple Cards per Org-Mode Heading
+*** Easy Implementation of Custom Card Types
+** Getting Started
+Before using this package, a few variables have to be set:
+
+- ~org-fc-directories~ :: list of directories to search for flashcards
+- ~org-fc-source-path~ :: should be set to the absolute path of the
+ cloned repository
+
+*** TODO Example setup using =use-package=
+*** TODO Basic Hydra
+*** TODO Demo File
+A file demonstrating all card types is included.
+~M-x org-fc-demo~ starts a review of this file.
+
+Note that the review data of the cards in this file *is not updated*.
+** Marking Headlines as Cards
+A *card* is an org-mode headline with a =:fc:= tag attached to it.
+Each card can have multiple *positions* reviewed independently from
+each other, e.g. one for each hole of a cloze card.
+
+Review data (ease, interval in days, box, due date) is stored in a table
+in a drawer inside the card.
+
+#+begin_src org
+:REVIEW_DATA:
+| position | ease | box | interval | due |
+|----------+------+-----+----------+------------------------|
+| 2 | 2.65 | 6 | 107.13 | 2020-04-07T01:01:00 |
+| 1 | 2.65 | 6 | 128.19 | 2020-04-29T06:44:00 |
+| 0 | 2.95 | 6 | 131.57 | 2020-04-30T18:03:00 |
+:END:
+#+end_src
+
+Review results are appended to a csv file to avoid cluttering the org
+files.
+
+Each card needs at least two properties, an *unique* ~:ID:~ and a
+~:FC_TYPE:~. In addition to that, the date a card was created
+(i.e. the headline was marked as a flashcard) is stored to allow
+making statistics for how many cards were created in the last day /
+week / month.
+
+#+begin_src org
+:PROPERTIES:
+:ID: 4ffe66a7-7b5c-4811-bd3e-02b5c0862f55
+:FC_TYPE: normal
+:FC_CREATED: 2019-10-11T14:08:32
+:END:
+#+end_src
+
+Card types (should) implement a ~org-fc-type-...-init~ command that
+initializes these properties and sets up the review data drawer
+
+All timestamps created and used by org-flashcards use ISO8601 format
+with second precision and without a timezone (timezone UTC0).
+
+This prevents flashcard due dates from showing up in the org-agenda
+and allows filtering for due cards by string-comparing a timestamp
+with one of the current time.
+** Review
+A review session can be started using ~org-fc-review-all~
+to review all cards that are due, or using ~org-fc-review-buffer~ to
+review only cards in the current buffer.
+
+*** Display of Cards during Review
+TODO: Add image
+
+Headlines are presented for review by hiding the all top level
+headings before and after the one the heading to be reviewed is
+located in.
+
+This is done through the function ~org-fc-org-narrow-tree~.
+~org-fc-show-all~ can be used to remove all overlays (i.e. reset the
+display of the buffer).
+
+All parent headings are shown but their body text (~section~) is
+hidden.
+
+If the file has a ~#+TITLE:~ keyword this is shown, too.
+
+To hide the title during review (e.g. for a "Definition" flashcard),
+add a ~:notitle:~ tag to the heading.
+
+To hide the heading text of the current card during review, add a
+~:noheading:~ tag.
+*** Implementation of Card Review
+Review is implemented by storing due cards in a global variable. The
+buffer the card is displayed in never leaves =org-mode=, [[https://github.com/abo-abo/hydra][abo-abo/hydra]]
+is used to show review statistics (number of cards remaining, percent
+again/hard/good/easy) and prompt for user actions.
+
+1. jump to the file + id of the current card
+2. set it up for review (i.e. hiding parts of the buffer)
+3. open a hydra prompting to flip the card
+4. flip the card or quit the review session
+5. open a hydra prompting for a rating
+6. rate the card or quit the review session
+7. set the current card to the next card due
+8. continue at 1.
+
+If an error occurs during review, ~org-fc-review-quit~ can be used to
+reset the current buffer and the review state.
+** (Un)suspending Cards
+Cards can be suspended (excluded from review) by adding a =suspended=
+tag, either by hand or using the ~org-fc-suspend-card~ command.
+
+All cards in the current buffer can be suspended using the
+~org-fc-suspend-buffer~ command.
+
+The reason for using a per-headline tag instead of a file keyword is
+that this way cards stay suspended when moved to another buffer.
+
+Cards can be un-suspended using the ~org-fc-unsuspend-card~ and
+~org-fc-unsuspend-buffer~ commands.
+
+If the card being unsuspended was not due for review yet,
+or was due less than 10% of its interval ago, its review data is not
+reset. If it was due by more than that, its review data is reset to
+the initial values.
+** Statistics
+~org-fc-dashboard~ shows a buffer with statistics for review performance
+and cards / card types.
+*** TODO Replace with R scripts run on the review history / card index
+*** Review History
+The review history is stored in a tsv file, to avoid cluttering org
+files. This makes it easy to calculate review statistics.
+
+At first, I used an org drawer to store the review history but that
+added to much overhead to the files (in one instance 6.5k lines of
+review history for a file of 9.5k lines in total).
+
+Columns:
+1. Date in ISO8601 format, second precision
+2. Filename
+3. Card ID
+4. Position
+5. Ease (before review)
+6. Box (before review)
+7. Interval (before review)
+8. Rating
+
+More advanced review algorithms might need to use the review history
+of a card. In this case, the card ID + position should be used to look
+up the review history, as the filename can change when moving cards
+from file to file.
+** Card Types
+*** Normal Cards
+During review, the heading is shown with its "Back" subheading
+collapsed, when flipping the card, the back heading is shown,
+then the user is asked to rate the review performance.
+
+Positions: =front=
+*** Text-Input Cards
+On review, the user is asked to type in a string which is then
+compared to the one stored in the ~:ANSWER:~ property of the card.
+
+Positions: =front=
+*** Double Cards
+Similar to normal cards, but reviewed both in the "Front -> Back"
+direction and in the "Back -> Front" direction.
+
+Positions: =front=, =back=
+*** Cloze Cards
+The cards text contains one or more *holes*. During review, one hole
+is hidden while the text of (some) remaining ones is shown.
+
+Flipping the card reveals the text of the hidden hole.
+
+Card titles can contain holes, too.
+
+Positions: =0=, =1=, ...
+
+Cloze cards can have a number of sub-types.
+
+**** TODO Document type-specific properties
+**** TODO Implement & document type-changing functions
+**** Deletion ~'deletion~
+Only one hole is hidden.
+**** Enumerations ~'enumeration~
+All holes *behind* the currently review one are hidden, too.
+
+Useful for memorizing lists where the order of items is important.
+**** Context ~'context~
+Holes ~org-fc-type-cloze-context~ (default 1) around the currently
+reviewed one are shown.
+
+Useful for memorizing longer lists where the order of items is important.
+**** Hole Syntax
+Deletions can have the following forms
+
+- ~{{text}}~
+- ~{{text}@id}~
+- ~{{text}{hint}}~
+- ~{{text}{hint}@id}~
+
+~text~ should not contain any "}",
+unless it is part of a ~$latex$~ block.
+In this case, ~latex~ should not contain any "$".
+
+Holes *inside* latex blocks are not handled correctly at the moment.
+As a workaround, create multiple smaller latex blocks and wrap each in
+a hole.
+*** TODO Listening Cards
+When reviewing the card, an audio file is played.
+Flipping the card, a transcription / translation is revealed.
+
+Useful for learning to understand sentences spoken in a foreign
+language.
+*** Compact Cards
+For cards without a "Back" heading, the headline text is considered as
+the front, the main text as the back.
+
+This is useful for cards with a short front text, e.g. when learning
+definitions of words.
+*** Defining Own Card Types
+To define a custom card type,
+you need to implement three functions:
+
+- ~(...-init)~ to initialize a heading as a flashcard of this type,
+ setting up the cards properties & review data.
+ Should be marked as ~(interactive)~.
+- ~(...-setup position)~ to setup ~position~ of the card for review
+- ~(...-flip)~ to flip the card
+- ~(...-update)~ to update the review data of the card, e.g. if a new
+ hole is added to a cloze card
+
+All of these are called with ~(point)~ on the cards heading.
+
+Take a look at the =org-fc-type-<name>.el= files to see how these
+functions could be implemented.
+** TODO Custom Review Spacing Algorithms :longterm:
+The interfaces defined by this package should be flexible enough to
+allow implementing custom review spacing algorithms.
+
+This is not possible at the moment because the awk scripts and the
+functions for reading / updating the review data drawer make strong
+assumptions about the format of the review data.
+
+A good implementation of this should allow using different spacing
+algorithms based on a ~:FC_SPACING:~ property in the card.
+** TODO Sharing Decks :longterm:
+It should be possible to share sets of cards by removing the review
+data and syncing them with git.
+
+At least one of the existing emacs flashcard packages implements this
+functionality.
+** Incremental Reading
+- [[https://github.com/alphapapa/org-web-tools]]
+*** TODO Supermemo link
+** Internals
+If your not interested in implementing your own card types or
+contributing to this package, you can skip this section.
+
+*** Components
+**** =org-flashcards.el=
+Main file.
+**** =org-fc-main.el=
+Main flashcards view displaying card / position / review statistics.
+**** =org-fc-review.el=
+Functions related to reviewing cards, updating the review data drawer
+and logging review results.
+**** =org-fc-sm2.el=
+Implementation of the [[https://www.supermemo.com/en/archives1990-2015/english/ol/sm2][SM2]] review spacing algorithm,
+modified to behave like the algorithm used by [[https://apps.ankiweb.net/docs/manual.html#what-algorithm][Anki]].
+
+It uses four ratings (again, hard, good, easy) instead of the six used
+in the supermemo variant.
+
+The first few reviews are done in fixed intervals
+(0.01 days / approx 15 minutes, 1 day, 6 days).
+
+After these intervals, reviews are scheduled by multiplying the cards
+current interval with its ease (initially 2.5, bound to be >= 1.3 and
+<= 5.0), then multiplying a random factor ~1 to avoid "chunking" of
+flashcards due for review.
+
+All of these parameters can be configured using the variables defined
+in =org-fc-sm2.el=.
+**** =org-fc-tsv.el=
+Functions for parsing the tsv output of awk scripts
+**** =org-fc-awk.el=
+Functions for interacting with the awk indexer / filter / stats scripts.
+**** =org-fc-refactor.el=
+Functions for refactoring collections of cards
+in case the card format changes.
+**** =org-fc-org.el=
+Functions for interacting with org-mode files, mostly for hiding /
+showing parts of them during review.
+**** =org-fc-assert.el=
+Helper functions for writing unit-tests for functions.
+**** =org-fc-type-<name>.el=
+Implementations of flashcard types, for more details, see the "Card
+Types" section of this document.
+**** TODO =org-fc-audio.el=
+Functions for attaching audio files to flashcards and playing them.
+**** TODO =org-fc-benchmark.el=
+Benchmarks to detect performance regressions in the code.
+**** TODO =org-fc-indexer-lisp.el=
+Slow flashcard indexer written in EmacsLisp for use on systems where
+=awk= is not available.
+
+Not working at the moment.
+**** TODO Document core api of each file
+*** Coding Style
+Components are split into multiple smaller files,
+with each function prefixed by the files base-name.
+
+Public functions are named ~basename-functionname~,
+internal helper functions are named ~basename--functionname~.
+*** Testing
+Unit-testing is done using ~org-fc-assert-...~ macros
+defined in =org-fc-assert.el=.
+
+These assertions are placed right after the function definition
+and run when the file is loaded. If an assertion fails,
+an ~'org-fc-assertion-error~ is raised.
+
+**** TODO Integration Testing
+Integration testing is done by providing an input org file, a set of
+operations to be performed on it and an org file with the expected
+output.
+
+Tests are run by copying the input file to a temporary file, executing
+the operations on it, then comparing it to the expected output.
+
+Files for this live in the =fixtures/= folder.
+*** dash.el
+The code in this package uses [[https://github.com/magnars/dash.el#threading-macros][threading macros]] and list functions
+(often in their anaphoric form) from [[https://github.com/magnars/dash.el][magnars/dash.el]].
+
+Make sure to read that documentation before going reading / working on
+the source code.
+*** =awk=
+~find~ is used to generate a list of =.org= files in
+~org-fc-directories~, these are then passed to =awk= scripts
+to generate lists of cards and card-positions.
+
+Only files starting with ~[a-Z0-9_]~ and a ~.org~ extension are
+indexed to exclude temp / hidden files.
+This can be customized with the ~org-fc-find-name~ variable.
+
+[[https://www.gnu.org/software/gawk/][gawk]] is a programming language for processing / parsing text.
+
+Assuming the input org files are well formatted, they can be
+efficiently parsed using regexeps and a small number of state
+variables.
+
+=awk= scripts in this package come in three types:
+
+1. Indexing, for generating lists of cards / positions
+3. Filtering, e.g. for selecting only unsuspended cards due now
+2. Aggregation, for generating statistics from these lists
+
+- =awk/indexer_cards.awk= :: list all card headings
+- =awk/indexer_positions.awk= :: list all card positions
+- =awk/filter_due.awk= :: select only unsuspended cards due right now
+- =awk/stats_cards.awk= :: stats over cards
+- =awk/stats_positions.awk= :: stats over positions
+- =awk/stats_reviews.awk= :: stats over the reviews tsv file
+
+These scripts use the =gawk= version of =awk= which should be
+available on any modern Linux / UNIX distribution.
+
+Configurable tags and properties can be passed to the indexer scripts as
+variables. If a tag or property is not passed to the script,
+a default value is used.
+
+*** Format
+Output is generated in *tab separated* form and *does not* include a
+header with column names. For the indexing scripts, the first two
+columns are the filename and the ID of the heading.
+
+The ~org-fc-tsv-parse~ function can be used to parse a tsv
+string into a plist, given a list of headers with optional type
+specifications.
+
+=0= (false) and =1= (true) are used for boolean values (e.g. for the
+"suspended" column).
+
+Dates are converted to ISO-8601 format, no timezone, minute-precision
+(e.g. =2019-10-09T16:49=).
+
+Unlike the format used by org mode, timestamps in ISO-8601 format can
+be compared lexicographically.
+
+Processing script output *tab separated* key-value pairs with no header.
diff --git a/awk/files.awk b/awk/files.awk
new file mode 100644
index 0000000..6926408
--- /dev/null
+++ b/awk/files.awk
@@ -0,0 +1,18 @@
+BEGIN {
+ FS="|";
+}
+
+BEGINFILE {
+ has_card = 0;
+}
+
+# Flashcard headings
+/^\*+ .*:fc:.*$/ {
+ has_card = 1;
+}
+
+ENDFILE {
+ if (has_card == 1) {
+ print FILENAME;
+ }
+}
diff --git a/awk/filter_due.awk b/awk/filter_due.awk
new file mode 100644
index 0000000..b85fdfe
--- /dev/null
+++ b/awk/filter_due.awk
@@ -0,0 +1,8 @@
+BEGIN {
+ FS="\t";
+ now = strftime("%FT%T", systime(), 1);
+}
+
+$4 == "0" && $9 < now {
+ print $0
+}
diff --git a/awk/index_cards.awk b/awk/index_cards.awk
new file mode 100644
index 0000000..27c50b9
--- /dev/null
+++ b/awk/index_cards.awk
@@ -0,0 +1,46 @@
+BEGIN {
+ FS="|";
+
+ fc_tag = ":" or_default(fc_tag, "fc") ":";
+ suspended_tag = ":" or_default(suspended_tag, "suspended") ":";
+ review_data_drawer = ":" or_default(review_data_drawer, "REVIEW_DATA") ":";
+ type_property = or_default(type_property, "FC_TYPE");
+ created_property = or_default(created_property, "FC_CREATED");
+}
+
+## Heading Parsing
+
+/^\*+[ \t]+.*$/ {
+ # tag re based on org-tag-re
+ match($0, /^\*+[ \t]+.*[ \t]+(:([a-zA-Z0-9_@#%]+:)+)$/, a)
+ tags = a[1]
+ id = "none";
+
+ if (tags ~ fc_tag) {
+ in_card = 1;
+ suspended = (tags ~ suspended_tag);
+ } else {
+ in_card = 0;
+ }
+ next
+}
+
+## Property parsing
+
+in_card && /:PROPERTIES:/ {
+ in_properties = 1;
+ delete properties;
+}
+
+in_properties && match($0, /^[ \t]*:([a-zA-Z0-9_]+):[ \t]*(.+)$/, a) {
+ properties[a[1]] = trim_surrounding(a[2]);
+}
+
+in_properties && /:END:/ {
+ id = properties["ID"];
+ type = properties[type_property];
+ created = properties[created_property];
+ print FILENAME "\t" id "\t" type "\t" suspended "\t" created;
+ in_properties = 0;
+ in_card = 0;
+}
diff --git a/awk/index_positions.awk b/awk/index_positions.awk
new file mode 100644
index 0000000..36e9cbe
--- /dev/null
+++ b/awk/index_positions.awk
@@ -0,0 +1,69 @@
+BEGIN {
+ FS="|";
+
+ fc_tag = ":" or_default(fc_tag, "fc") ":";
+ suspended_tag = ":" or_default(suspended_tag, "suspended") ":";
+ review_data_drawer = ":" or_default(review_data_drawer, "REVIEW_DATA") ":";
+ type_property = or_default(type_property, "FC_TYPE");
+ created_property = or_default(created_property, "FC_CREATED");
+}
+
+## Heading Parsing
+
+/^\*+[ \t]+.*$/ {
+ # tag re based on org-tag-re
+ match($0, /^\*+[ \t]+.*[ \t]+(:([a-zA-Z0-9_@#%]+:)+)$/, a)
+ tags = a[1]
+
+ id = "none";
+
+ if (tags ~ fc_tag) {
+ in_card = 1;
+ suspended = (tags ~ suspended_tag);
+ } else {
+ in_card = 0;
+ }
+ next
+}
+
+## Property parsing
+
+in_card && /:PROPERTIES:/ {
+ in_properties = 1;
+ delete properties;
+}
+
+in_properties && match($0, /^[ \t]*:([a-zA-Z0-9_]+):[ \t]*(.+)$/, a) {
+ properties[a[1]] = trim_surrounding(a[2]);
+}
+
+in_properties && /:END:/ {
+ in_properties = 0;
+}
+
+## Review data parsing
+
+in_card && $0 ~ review_data_drawer {
+ in_data = 1;
+}
+
+in_data && /:END:/ {
+ in_data = 0;
+}
+
+in_data && /^\|.*\|$/ {
+ # Make sure we're inside a data block,
+ # check NF to skip the |--+--| table separator
+ # match on $2 to skip the table header
+ if (in_data == 1 && NF == 7 && $2 !~ "position") {
+ id = properties["ID"];
+ type = properties[type_property];
+
+ position = trim($2);
+ ease = trim($3);
+ box = trim($4);
+ interval = trim($5);
+ due = trim_surrounding($6);
+ print FILENAME "\t" id "\t" type "\t" suspended "\t" position "\t" ease "\t" box "\t" interval "\t" due;
+ }
+}
diff --git a/awk/stats_cards.awk b/awk/stats_cards.awk
new file mode 100644
index 0000000..3f29338
--- /dev/null
+++ b/awk/stats_cards.awk
@@ -0,0 +1,47 @@
+BEGIN {
+ FS="\t";
+ total = 0;
+ suspended = 0;
+
+ t_day = time_days_ago(1);
+ t_week = time_days_ago(7);
+ t_month = time_days_ago(30);
+
+ created["day"] = 0;
+ created["week"] = 0;
+ created["month"] = 0;
+}
+
+{
+ total += 1;
+
+ type = $3;
+ by_type[type] += 1;
+
+ if ($4 == "1") {
+ suspended++;
+ }
+
+ if ($5 > t_day) {
+ created["day"]++;
+ }
+
+ if ($5 > t_week) {
+ created["week"]++;
+ }
+
+ if ($5 > t_month) {
+ created["month"]++;
+ }
+}
+
+END {
+ print "total" "\t" total;
+ print "suspended" "\t" suspended;
+ print "created-day" "\t" created["day"];
+ print "created-week" "\t" created["week"];
+ print "created-month" "\t" created["month"];
+ for (var in by_type) {
+ print "type-" var "\t" by_type[var];
+ }
+}
diff --git a/awk/stats_positions.awk b/awk/stats_positions.awk
new file mode 100644
index 0000000..d38d2cd
--- /dev/null
+++ b/awk/stats_positions.awk
@@ -0,0 +1,40 @@
+BEGIN {
+ FS="\t";
+ total = 0;
+ suspended = 0;
+ ease = 0;
+ interval = 0;
+ box = 0;
+ due = 0;
+ now = strftime("%FT%T", systime(), 1);
+}
+
+{
+ total += 1;
+
+ type = $3;
+ by_type[type] += 1;
+
+ ease += $6;
+ box += $7;
+ interval += $8;
+
+ if ($4 == "1") {
+ suspended += 1;
+ }
+ if ($4 == "0" && $9 < now) {
+ due += 1;
+ }
+}
+
+END {
+ print "total" "\t" total;
+ print "suspended" "\t" suspended;
+ print "due" "\t" due;
+ for (var in by_type) {
+ print "type-" var "\t" by_type[var];
+ }
+ print "avg-ease" "\t" ease / NR;
+ print "avg-box" "\t" box / NR;
+ print "avg-interval" "\t" interval / NR;
+}
diff --git a/awk/stats_reviews.awk b/awk/stats_reviews.awk
new file mode 100644
index 0000000..bacafb2
--- /dev/null
+++ b/awk/stats_reviews.awk
@@ -0,0 +1,53 @@
+BEGIN {
+ FS="\t"
+ t_day = time_days_ago(1);
+ t_week = time_days_ago(7);
+ t_month = time_days_ago(30);
+}
+
+{
+ date = $1;
+ file = $2;
+ id = $3;
+ position = $4;
+ ease = $5;
+ box = $6;
+ interval = $7;
+ rating = $8;
+
+ if (box >= 2) {
+ if (date > t_day) {
+ ratings_day[rating]++;
+ n_day++;
+ }
+
+ if (date > t_week) {
+ ratings_week[rating]++;
+ n_week++;
+ }
+
+ if (date > t_month) {
+ ratings_month[rating]++;
+ n_month++;
+ }
+
+ ratings_all[rating]++;
+ n_all++;
+ }
+}
+
+END {
+ report(ratings_all, n_all);
+ report(ratings_month, n_month);
+ report(ratings_week, n_week);
+ report(ratings_day, n_day);
+}
+
+function report(values, n) {
+ if (n == 0) {
+ print 0 "\t" 0 "\t" 0 "\t" 0 "\t" 0;
+ } else {
+ print n "\t" values["again"] / n "\t" values["hard"] / n "\t" values["good"] /n "\t" values["easy"] / n;
+
+ }
+}
diff --git a/awk/utils.awk b/awk/utils.awk
new file mode 100644
index 0000000..00045de
--- /dev/null
+++ b/awk/utils.awk
@@ -0,0 +1,23 @@
+## Helper functions
+
+# Remove all whitespace in str
+function trim(str) {
+ gsub(/[ \t]/, "", str);
+ return str;
+}
+
+# Remove all whitespace around str
+function trim_surrounding(str) {
+ gsub(/^[ \t]*/, "", str);
+ gsub(/[ \t]*$/, "", str);
+ return str;
+}
+
+# Time n days before the current time
+function time_days_ago(n) {
+ return strftime("%FT%T", systime() - 24 * 60 * 60 * n, 1);
+}
+
+function or_default(var, def) {
+ return var ? var : def;
+}
diff --git a/demo.org b/demo.org
new file mode 100644
index 0000000..0d071af
--- /dev/null
+++ b/demo.org
@@ -0,0 +1,70 @@
+#+TITLE: Org-Flashcards Demo File
+
+* Normal Card :fc:
+:PROPERTIES:
+:ID: 9f80ab65-dbff-41b3-902f-0e8e177debbe
+:FC_CREATED: [2019-01-03 Fri 17:47]
+:FC_TYPE: normal
+:END:
+:REVIEW_DATA:
+| position | ease | box | interval | due |
+|----------+------+-----+----------+---------------------|
+| front | 2.50 | 4 | 13.69 | 2020-01-25T06:47:55 |
+:END:
+Front of the card
+** Back
+:PROPERTIES:
+:ID: 1a9a5308-119c-4398-a715-da3b87d1c7e1
+:END:
+Back of the card
+* Compact Double Card :fc:
+:PROPERTIES:
+:ID: d3e290c2-a7f0-4d10-9a0a-6c1ecec3c29e
+:FC_CREATED: [2019-01-03 Fri 17:47]
+:FC_TYPE: double
+:END:
+:REVIEW_DATA:
+| position | ease | box | interval | due |
+|----------+------+-----+----------+---------------------|
+| front | 2.50 | 4 | 14.38 | 2020-01-25T23:25:30 |
+| back | 2.50 | 4 | 14.95 | 2020-01-26T13:02:32 |
+:END:
+For cards without a "Back" heading, the headings main text is
+considered as the back side.
+* Cloze Deletion :fc:
+:PROPERTIES:
+:ID: 2ffc8b34-b2b5-4472-9295-714b5422679d
+:FC_CREATED: [2019-01-03 Fri 17:47]
+:FC_TYPE: cloze
+:FC_CLOZE_MAX: 1
+:FC_CLOZE_TYPE: deletion
+:END:
+:REVIEW_DATA:
+| position | ease | box | interval | due |
+|----------+------+-----+----------+---------------------|
+| 0 | 2.50 | 3 | 6.00 | 2019-01-17T12:32:41 |
+| 1 | 2.50 | 4 | 13.91 | 2020-01-25T12:14:46 |
+:END:
+A {{cloze deletion}@0} can have multiple {{holes}@1}.
+* Cloze Enumeration :fc:
+:PROPERTIES:
+:FC_CREATED: [2019-01-03 Fri 17:48]
+:FC_TYPE: cloze
+:ID: 5eac5801-0ef5-4957-a818-e3f9f08a7d59
+:FC_CLOZE_MAX: 3
+:FC_CLOZE_TYPE: enumeration
+:END:
+:REVIEW_DATA:
+| position | ease | box | interval | due |
+|----------+------+-----+----------+---------------------|
+| 0 | 2.50 | 2 | 1.00 | 2019-01-12T12:32:37 |
+| 1 | 2.50 | 3 | 6.00 | 2019-01-17T12:32:39 |
+| 2 | 2.50 | 2 | 1.00 | 2019-01-12T12:32:31 |
+| 3 | 2.50 | 2 | 1.00 | 2019-01-12T12:32:48 |
+:END:
+Enumerations are useful for
+
+- {{Learning}@0}
+- {{Lists}@1}
+- {{of}@2}
+- {{items}@3}
diff --git a/org-fc-awk.el b/org-fc-awk.el
new file mode 100644
index 0000000..d579fab
--- /dev/null
+++ b/org-fc-awk.el
@@ -0,0 +1,190 @@
+;;; Shell helper functions
+
+(defvar org-fc-awk--find-name
+ "[a-Z0-9_]*.org"
+ "-name argument passed to `find' when searching for org files")
+
+(defun org-fc-awk--find (paths)
+ "Generate shell code to search PATHS for org files."
+ (format
+ "find %s -name \"%s\""
+ (mapconcat 'identity paths " ")
+ org-fc-awk--find-name))
+
+(defun org-fc-awk--indexer-variables ()
+ "Variables to pass to indexer scripts"
+ `(("fc_tag" . ,org-fc-flashcard-tag)
+ ("suspended_tag" . ,org-fc-suspended-tag)
+ ("type_property" . ,org-fc-type-property)
+ ("created_property" . ,org-fc-created-property)
+ ("review_data_drawer" . ,org-fc-review-data-drawer)))
+
+(cl-defun org-fc-awk--command (file &optional &key variables utils input)
+ "Generate the shell command for calling awk on FILE with (key
+. value) pairs VARIABLES. If UTILS is set to a non-nil value,
+the shared util file is included, too. If INPUT is set to a
+string, use that file (absolute path) as input."
+ (concat "awk "
+ ;; TODO: quote strings
+ (mapconcat
+ (lambda (kv) (format "-v %s=%s" (car kv) (cdr kv)))
+ variables
+ " ")
+ " "
+ (if utils
+ (concat "-f "
+ (expand-file-name "awk/utils.awk" org-fc-source-path) " "))
+ (concat "-f " (expand-file-name file org-fc-source-path))
+ " " input))
+
+(defun org-fc-awk--pipe (&rest commands)
+ "Combine COMMANDS with shell pipes."
+ (mapconcat 'identity commands " | "))
+
+(defun org-fc-awk--xargs (command)
+ "Generate the shell command for calling COMMAND with xargs."
+ (concat "xargs -n 2500 -P 4 " command))
+
+;;; Parsing Results
+;;;; Key-Value
+
+(defun org-fc-awk--key-value-parse (input)
+ "Parse a string of newline separated key-value entries,
+each separated by a tab, into a keyword-number plist."
+ (mapcan
+ (lambda (kv)
+ (let ((kv (split-string kv "\t")))
+ (list
+ (intern (concat ":" (car kv)))
+ (string-to-number (cadr kv)))))
+ (split-string input "\n" t)))
+
+;;;; TSV
+
+(defun org-fc-tsv--parse-date (date)
+ "Parse an ISO8601 date to an Emacs time."
+ (parse-iso8601-time-string (concat date ":00")))
+
+(defun org-fc-tsv--parse-element (header element)
+ "Parse an ELEMENT of a row given a single HEADER element."
+ (if (listp header)
+ (pcase (cdr header)
+ ('string element)
+ ('date (org-fc-tsv--parse-date element))
+ ('number (string-to-number element))
+ ('symbol (intern element))
+ ('keyword (intern (concat ":" element)))
+ ('bool (string= element "1")))
+ element))
+
+(defun org-fc-tsv--parse-row (headers elements)
+ "Convert two lists of HEADERS and ELEMENTS into a plist,
+parsing each element with its header specification."
+ (if (null headers)
+ '()
+ (let ((header (first headers)))
+ (assert (not (null elements)))
+ `(,(if (listp header) (car header) header)
+ ,(org-fc-tsv--parse-element header (first elements))
+ .
+ ,(org-fc-tsv--parse-row (rest headers) (rest elements))))))
+
+(defun org-fc-tsv-parse (headers input)
+ "Parse a tsv INPUT into a plist, give a list of HEADERS."
+ (let* ((lines (split-string input "\n" t)))
+ (--map (org-fc-tsv--parse-row
+ headers
+ (split-string it "\t")) lines)))
+;;;; TSV Headers
+
+(defvar org-fc-awk-card-headers
+ '(:path :id (:type . symbol) (:suspended . bool) (:created . date))
+ "Headers of the card indexer")
+
+(defvar org-fc-awk-position-headers
+ '(:path
+ :id
+ (:type . symbol)
+ (:suspended . bool)
+ :position
+ (:ease . number)
+ (:box . box)
+ (:interval . interval)
+ (:due . date))
+ "Headers of the position indexer")
+
+(defvar org-fc-awk-review-stats-headers
+ '((:reviews . number) (:again . number) (:hard . number) (:good . number) (:easy . number))
+ "Headers of the review stat aggregator")
+
+;;; AWK wrapper functions
+
+(cl-defun org-fc-awk-cards (&optional (paths org-fc-directories))
+ "List all cards in PATHS."
+ (org-fc-tsv-parse
+ org-fc-awk-card-headers
+ (shell-command-to-string
+ (org-fc-awk--pipe
+ (org-fc-awk--find paths)
+ (org-fl-awk--xargs
+ (org-fc-awk--command
+ "awk/index_cards.awk"
+ :utils t
+ :variables (org-fc-awk--indexer-variables)))))))
+
+(cl-defun org-fc-awk-stats-cards (&optional (paths org-fc-directories))
+ "Statistics for all cards in PATHS."
+ (org-fc-awk--key-value-parse
+ (shell-command-to-string
+ (org-fc-awk--pipe
+ (org-fc-awk--find paths)
+ (org-fc-awk--xargs
+ (org-fc-awk--command
+ "awk/index_cards.awk"
+ :utils t
+ :variables (org-fc-awk--indexer-variables)))
+ (org-fc-awk--command "awk/stats_cards.awk" :utils t)))))
+
+;; TODO: Optimize card order for review
+(defun org-fc-awk-due-positions-for-paths (paths)
+ "Generate a list of due positions cards in randomized order."
+ (org-fc-tsv-parse
+ org-fc-awk-position-headers
+ (shell-command-to-string
+ (org-fc-awk--pipe
+ (org-fc-awk--find paths)
+ (org-fc-awk--xargs
+ (org-fc-awk--command
+ "awk/index_positions.awk"
+ :utils t
+ :variables (org-fc-awk--indexer-variables)))
+ (org-fc-awk--command "awk/filter_due.awk")
+ "shuf"))))
+
+(cl-defun org-fc-awk-stats-positions (&optional (paths org-fc-directories))
+ "Statistics for all positions in PATHS."
+ (org-fc-awk--key-value-parse
+ (shell-command-to-string
+ (org-fc-awk--pipe
+ (org-fc-awk--find paths)
+ (org-fc-awk--xargs
+ (org-fc-awk--command
+ "awk/index_positions.awk"
+ :utils t
+ :variables (org-fc-awk--indexer-variables)))
+ (org-fc-awk--command "awk/stats_positions.awk")))))
+
+(defun org-fc-awk-stats-reviews ()
+ "Statistics for all card reviews."
+ (let ((res (org-fc-tsv-parse
+ org-fc-awk-review-stats-headers
+ (shell-command-to-string
+ (org-fc-awk--command
+ "awk/stats_reviews.awk"
+ :utils t
+ :input org-fc-review-history-file)))))
+ `(:all ,(first res) :month ,(second res) :week ,(third res) :day ,(fourth res))))
+
+;;; Exports
+
+(provide 'org-fc-awk)
diff --git a/org-fc-dashboard.el b/org-fc-dashboard.el
new file mode 100644
index 0000000..05a0bdc
--- /dev/null
+++ b/org-fc-dashboard.el
@@ -0,0 +1,138 @@
+(require 'org-fc-review)
+(require 'org-fc-awk)
+
+;;; Configuration
+
+(defcustom org-fc-dashboard-bar-chart-width 400
+ "Width of the svg generated to display review statistics."
+ :type 'integer
+ :group 'org-fc)
+(defcustom org-fc-dashboard-bar-chart-height 20
+ "Height of the svg generated to display review statistics."
+ :type 'integer
+ :group 'org-fc)
+
+(defcustom org-fc-dashboard-buffer-name "*org-fc Main*"
+ "Name of the buffer to use for displaying the dashboard view."
+ :type 'string
+ :group 'org-fc)
+
+;;; Bar-Chart Generation
+
+(defun org-fc-dashboard-bar-chart (stat)
+ "Generate a svg bar-chart for the plist STAT"
+ (let* ((width org-fc-dashboard-bar-chart-width)
+ (height org-fc-dashboard-bar-chart-height)
+ (values
+ `((,(or (plist-get stat :again) 0.0) . "red")
+ (,(or (plist-get stat :hard) 0.0) . "yellow")
+ (,(or (plist-get stat :good) 0.0) . "green")
+ (,(or (plist-get stat :easy) 0.0) . "darkgreen")))
+ (svg (svg-create width height)))
+ (do ((values values (cdr values))
+ (pos 0 (+ pos (* width (caar values)))))
+ ((null values) '())
+ (svg-rectangle svg pos 0 (* width (caar values)) height :fill (cdar values)))
+ (svg-image svg)))
+
+(defun org-fc-dashboard-percent-right (stats)
+ (format " %5.2f | %5.2f | %5.2f | %5.2f"
+ (or (* 100 (plist-get stats :again)) 0.0)
+ (or (* 100 (plist-get stats :hard)) 0.0)
+ (or (* 100 (plist-get stats :good)) 0.0)
+ (or (* 100 (plist-get stats :easy)) 0.0)))
+
+;;; Main View
+
+;; Based on `mu4e-main-view-real'
+(defun org-fc-dashboard-view (_ignore-auto _noconfirm)
+ (let* ((buf (get-buffer-create org-fc-dashboard-buffer-name))
+ (inhibit-read-only t)
+ (cards-stats (org-fc-awk-stats-cards))
+ (positions-stats (org-fc-awk-stats-positions))
+ (reviews-stats (org-fc-awk-stats-reviews)))
+ (with-current-buffer buf
+ (erase-buffer)
+ (insert
+ (propertize "Flashcards\n\n" 'face 'org-level-1))
+
+ (insert
+ (propertize " Card Statistics\n\n" 'face 'org-level-1))
+
+ (insert (format " New: %d (day) %d (week) %d (month) \n"
+ (plist-get cards-stats :created-day)
+ (plist-get cards-stats :created-week)
+ (plist-get cards-stats :created-month)))
+
+ (insert "\n")
+ (insert (format
+ " %6d Cards, %d suspended\n"
+ (plist-get cards-stats :total)
+ (plist-get cards-stats :suspended)))
+ (dolist (position '((:type-normal . "Normal")
+ (:type-double . "Double")
+ (:type-text-input . "Text Input")
+ (:type-cloze . "Cloze")))
+ (insert
+ (format " %6d %s\n"
+ (plist-get cards-stats (car position))
+ (cdr position))))
+
+ (insert "\n")
+ (insert
+ (propertize " Position Statistics\n\n" 'face 'org-level-1))
+
+ (insert (format " %6d Due Now\n\n" (plist-get positions-stats :due)))
+
+ (dolist (position '((:avg-ease . "Avg. Ease")
+ (:avg-box . "Avg. Box")
+ (:avg-interval . "Avg. Interval (days)")))
+ (insert
+ (format " %6.2f %s\n"
+ (plist-get positions-stats (car position))
+ (cdr position))))
+
+ (insert "\n")
+
+ (insert
+ (propertize " Review Statistics\n\n" 'face 'org-level-1))
+
+ (dolist (scope '((:day . "Day")
+ (:week . "Week")
+ (:month . "Month")
+ (:all . "All")))
+ (when-let (stat (plist-get reviews-stats (car scope)))
+ (when (plusp (plist-get stat :reviews))
+ (insert (propertize (format " %s (%d)\n" (cdr scope) (plist-get stat :reviews)) 'face 'org-level-1))
+ (insert " ")
+ (insert-image (org-fc-dashboard-bar-chart stat))
+ (insert (org-fc-dashboard-percent-right stat))
+ (insert "\n\n"))))
+
+ (insert "\n")
+ (insert
+ (propertize " [r] Review\n" 'face 'org-level-1))
+ (insert
+ (propertize " [q] Quit\n" 'face 'org-level-1)))))
+
+(defvar org-fc-dashboard-mode-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map (kbd "r") 'org-fc-review-all)
+ (define-key map (kbd "q") 'quit-window)
+ map))
+
+(define-derived-mode org-fc-dashboard-mode special-mode "org-fc main"
+ "Major mode providing an overview of the flashcard system"
+ (set (make-local-variable 'revert-buffer-function) #'org-fc-dashboard-view)
+ (setq-local cursor-type nil))
+
+(defun org-fc-dashboard ()
+ (interactive)
+ (org-fc-dashboard-view nil nil)
+ (switch-to-buffer org-fc-dashboard-buffer-name)
+ (goto-char (point-min))
+ (org-fc-dashboard-mode))
+
+;;; Exports
+
+(provide 'org-fc-dashboard)
diff --git a/org-fc-overlay.el b/org-fc-overlay.el
new file mode 100644
index 0000000..4fdee9f
--- /dev/null
+++ b/org-fc-overlay.el
@@ -0,0 +1,165 @@
+(require 'outline)
+
+;;; Finding Positions in the Buffer
+
+(defun org-fc-overlay--point-at-end-of-previous ()
+ "Value of point at the end of the previous line.
+Returns nil if there is no previous line."
+ (save-excursion
+ (beginning-of-line)
+ (if (bobp)
+ nil
+ (progn (backward-char)
+ (point)))))
+
+(defun org-fc-overlay--point-after-title ()
+ "Value of point at the first line after the title keyword.
+Returns nil if there is no title keyword."
+ (save-excursion
+ (goto-char (point-min))
+ (when (re-search-forward (rx bol "#+TITLE:") nil t)
+ (forward-line 1)
+ (beginning-of-line)
+ (point))))
+
+;;; Showing / Hiding Regions
+
+(defun org-fc-show-all ()
+ "Remove all org-fc overlays in the current buffer."
+ (interactive)
+ (remove-overlays (point-min) (point-max) 'category 'org-fc-hidden)
+ (remove-overlays (point-min) (point-max) 'category 'org-fc-visible))
+
+;; Based on `outline-flag-region'
+(defun org-fc-hide-region (from to &optional text)
+ "Hide region, optionally replacing it with TEXT."
+ ;; (remove-overlays from to 'category 'org-fc-hidden)
+ (let ((o (make-overlay from to nil 'front-advance)))
+ (overlay-put o 'display-original (overlay-get o 'display))
+ (overlay-put o 'category 'org-fc-hidden)
+ (overlay-put o 'evaporate t)
+
+ (if (stringp text)
+ (progn
+ (overlay-put o 'invisible nil)
+ (overlay-put o 'face 'default)
+ (overlay-put o 'display text))
+ (overlay-put o 'invisible t))
+ o))
+
+(defun org-fc-overlay-region (from to)
+ "Wrap region in an overlay for later hiding"
+ ;; (remove-overlays from to 'category 'org-fc-hidden)
+ (let ((o (make-overlay from to)))
+ (overlay-put o 'evaporate t)
+ (overlay-put o 'invisible nil)
+ (overlay-put o 'category 'org-fc-visible)
+ o))
+
+(defun org-fc-hide-overlay (o)
+ "Hide the overlay O."
+ (overlay-put o 'category 'org-fc-hidden)
+ (overlay-put o 'invisible t)
+ (overlay-put o 'display ""))
+
+;;;; Hiding Drawers
+
+(defun org-fc-hide-drawers ()
+ "Hide all drawers after point."
+ (save-excursion
+ (while (re-search-forward org-drawer-regexp nil t)
+ (let ((start (1- (match-beginning 0)))
+ (end))
+ (if (re-search-forward ":END:" nil t)
+ (setq end (point))
+ (error "No :END: found for drawer"))
+ (org-fc-hide-region start end)))))
+
+;;;; Hiding Headings
+
+(defun org-fc-hide-subheadings-if (test)
+ "TEST is a function taking no arguments. TEST will be called for each
+of the immediate subheadings of the current headline, with the point
+on the relevant subheading. TEST should return nil if the subheading is
+to be revealed, non-nil if it is to be hidden.
+Returns a list containing the position of each immediate subheading of
+the current topic."
+ (let ((entry-level (org-current-level))
+ (sections nil))
+ (org-show-subtree)
+ (save-excursion
+ (org-map-entries
+ (lambda ()
+ (when (and (not (outline-invisible-p))
+ (> (org-current-level) entry-level))
+ (when (or (/= (org-current-level) (1+ entry-level))
+ (funcall test))
+ (outline-hide-subtree))
+ (push (point) sections)))
+ t 'tree))
+ (reverse sections)))
+
+(defun org-fc-hide-subheading (name)
+ "Hide all subheadings matching NAME."
+ (org-fc-hide-subheadings-if
+ (lambda () (string= (org-get-heading t) name))))
+
+(defun org-fc-hide-all-subheadings-except (heading-list)
+ "Hide all subheadings except HEADING-LIST."
+ (org-fc-hide-subheadings-if
+ (lambda () (not (member (org-get-heading t) heading-list)))))
+
+;;;; Hiding Headline Contents
+
+(defun org-fc-hide-content (&optional text)
+ "Hide the main text of a heading *before* the first subheading."
+ (let (start end)
+ (save-excursion
+ (org-back-to-heading)
+ (forward-line)
+ (setq start (point)))
+ (save-excursion
+ (outline-next-heading)
+ (setq end (point)))
+ (org-fc-hide-region start end text)))
+
+(defun org-fc-hide-heading (&optional text)
+ "Hide the title of the headline at point"
+ (save-excursion
+ (beginning-of-line)
+ (if (looking-at org-complex-heading-regexp)
+ (org-fc-hide-region (match-beginning 4) (match-end 4) (or text "..."))
+ (error "Point is not on a heading"))))
+
+;;;; Narrowing Outline Trees
+
+(defun org-fc-narrow-tree ()
+ (interactive)
+ (save-excursion
+ (org-fc-goto-entry-heading)
+ (let* ((end (org-fc-overlay--point-at-end-of-previous))
+ (tags (org-get-tags nil t))
+ (notitle (member "notitle" tags))
+ (noheading (member "noheading" tags))
+ (el (org-element-at-point))
+ (current-end (org-element-property :contents-end el)))
+ (if noheading
+ (org-fc-hide-heading))
+ (while (org-up-heading-safe)
+ (let ((start (point-at-eol))
+ (end_ (org-fc-overlay--point-at-end-of-previous)))
+ (if (< start end)
+ (org-fc-hide-region end start))
+ (setq end end_)))
+ (let ((at (org-fc-overlay--point-after-title))
+ (eop (org-fc-overlay--point-at-end-of-previous)))
+ ;; Don't hide anything if the heading is at the beginning of the buffer
+ (if eop
+ (if (and at (not notitle))
+ (org-fc-hide-region at (org-fc-overlay--point-at-end-of-previous))
+ (org-fc-hide-region (point-min) (org-fc-overlay--point-at-end-of-previous)))))
+ (org-fc-hide-region current-end (point-max)))))
+
+;;; Exports
+
+(provide 'org-fc-overlay)
diff --git a/org-fc-review.el b/org-fc-review.el
new file mode 100644
index 0000000..64b72d1
--- /dev/null
+++ b/org-fc-review.el
@@ -0,0 +1,288 @@
+
+;;; Configuration
+
+(defcustom org-fc-review-data-drawer "REVIEW_DATA"
+ "Name of the drawer used to store review data."
+ :type 'string
+ :group 'org-fc)
+
+;;; Session Management
+
+(defclass org-fc-review-session ()
+ ((current-item :initform nil)
+ (ratings :initform nil)
+ (cards :initform nil)))
+
+(defun org-fc-session-cards-pending-p (session)
+ (not (null (oref session cards))))
+
+(defun org-fc-session-pop-next-card (session)
+ (let ((card (pop (oref session cards))))
+ (setf (oref session current-item) card)
+ card))
+
+(defun org-fc-session-add-rating (session rating)
+ (push rating (oref session ratings)))
+
+(defun org-fc-session-stats-string (session)
+ (with-slots (ratings) session
+ (let ((len (length ratings)))
+ (if (plusp len)
+ (format "%.2f again, %.2f hard, %.2f good, %.2f easy"
+ (/ (* 100.0 (count 'again ratings)) len)
+ (/ (* 100.0 (count 'hard ratings)) len)
+ (/ (* 100.0 (count 'good ratings)) len)
+ (/ (* 100.0 (count 'easy ratings)) len))
+ "No ratings yet"))))
+
+(defvar org-fc-review--current-session nil
+ "Current review session.")
+
+;;; Helper Functions
+
+(defun org-fc-review-next-time (next-interval)
+ "Generate an org-mode timestamp NEXT-INTERVAL days from now"
+ (let ((seconds (* next-interval 60 60 24))
+ (now (time-to-seconds)))
+ (format-time-string
+ org-fc-timestamp-format
+ (seconds-to-time (+ now seconds))
+ "UTC0")))
+
+(defun org-fc-id-goto (id file)
+ "File-scoped variant of `org-id-goto'."
+ (let ((position (org-id-find-id-in-file id file)))
+ (if position
+ (goto-char (cdr position))
+ (error "ID %s not found in %s" id file))))
+
+;;; Reviewing Cards
+
+(defun org-fc-review--context (context)
+ (let* ((session (make-instance 'org-fc-review-session))
+ (cards (org-fc-due-positions context)))
+ (if org-fc-review--current-session
+ (message "Flashcards are already being reviewed")
+ (if (null cards)
+ (message "No cards due right now")
+ (progn
+ (setq org-fc-review--current-session session)
+ (setf (oref session cards) cards)
+ (org-fc-review-next-card))))))
+
+(defun org-fc-review-buffer ()
+ (interactive)
+ (org-fc-review--context 'buffer))
+
+(defun org-fc-review-all ()
+ (interactive)
+ (org-fc-review--context 'all))
+
+(defun org-fc-review-next-card ()
+ "Review the next card of the current session"
+ (if (org-fc-session-cards-pending-p org-fc-review--current-session)
+ (let* ((card (org-fc-session-pop-next-card org-fc-review--current-session))
+ (path (plist-get card :path))
+ (id (plist-get card :id))
+ (type (plist-get card :type))
+ (position (plist-get card :position)))
+ ;; TODO: org-id-goto already jumps to the file
+ ;; Check if buffer was already open,
+ ;; set flag in session (kill buffer?)
+ (with-current-buffer (find-file path)
+ (goto-char (point-min))
+ (org-fc-id-goto id path)
+ (org-fc-show-all)
+ (org-fc-narrow-tree)
+ (org-fc-hide-drawers)
+ (org-fc-show-latex)
+ (outline-hide-subtree)
+ (funcall (org-fc-type-setup-fn type) position)))
+ (progn
+ (message "Review Done")
+ (setq org-fc-review--current-session nil)
+ (org-fc-show-all))))
+
+(defhydra org-fc-review-rate-hydra ()
+ "
+%(length (oref org-fc-review--current-session cards)) cards remaining
+%s(org-fc-session-stats-string org-fc-review--current-session)
+
+"
+ ("a" (org-fc-review-rate-card 'again) "Rate as again" :exit t)
+ ("h" (org-fc-review-rate-card 'hard) "Rate as hard" :exit t)
+ ("g" (org-fc-review-rate-card 'good) "Rate as good" :exit t)
+ ("e" (org-fc-review-rate-card 'easy) "Rate as easy" :exit t)
+ ("q" org-fc-review-quit "Quit" :exit t))
+
+(defhydra org-fc-review-flip-hydra ()
+ "
+%(length (oref org-fc-review--current-session cards)) cards remaining
+%s(org-fc-session-stats-string org-fc-review--current-session)
+
+"
+ ("RET" org-fc-review-flip "Flip" :exit t)
+ ;; Neo-Layout ergonomics
+ ("n" org-fc-review-flip "Flip" :exit t)
+ ("q" org-fc-review-quit "Quit" :exit t))
+
+(defmacro org-fc-review-with-current-item (var &rest body)
+ "Helper macro for functions that work with the current item of
+a review session."
+ (declare (indent defun))
+ `(if org-fc-review--current-session
+ (-if-let (,var (oref org-fc-review--current-session current-item))
+ (if (string= (plist-get ,var :id) (org-id-get))
+ (progn ,@body)
+ (message "Flashcard ID mismatch"))
+ (message "No flashcard review is in progress"))))
+
+(defun org-fc-review-flip ()
+ "Flip the current flashcard"
+ (interactive)
+ (org-fc-review-with-current-item card
+ (let ((type (plist-get card :type)))
+ (funcall (org-fc-type-flip-fn type)))))
+
+;; TODO: Remove -card suffix
+(defun org-fc-review-rate-card (rating)
+ "Rate the card at point if it has the same id as the current
+ card of the review session."
+ (interactive)
+ (org-fc-review-with-current-item card
+ (let ((path (plist-get card :path))
+ (id (plist-get card :id))
+ (position (plist-get card :position)))
+ (org-fc-session-add-rating org-fc-review--current-session rating)
+ (org-fc-review-update-data path id position rating)
+ (save-buffer)
+ ;; TODO: Conditional kill
+ (kill-buffer)
+ (org-fc-review-next-card))))
+
+(defun org-fc-review-update-data (path id position rating)
+ (save-excursion
+ (org-fc-goto-entry-heading)
+ (let* ((data (org-fc-get-review-data))
+ (current (assoc position data #'string=)))
+ (unless current
+ (error "No review data found for this position"))
+ (let ((ease (string-to-number (second current)))
+ (box (string-to-number (third current)))
+ (interval (string-to-number (fourth current))))
+ (org-fc-review-history-add
+ (list
+ (org-fc-timestamp-now)
+ path
+ id
+ position
+ (format "%.2f" ease)
+ (format "%d" box)
+ (format "%.2f" interval)
+ (symbol-name rating)))
+ (destructuring-bind (next-ease next-box next-interval)
+ (org-fc-sm2-next-parameters ease box interval rating)
+ (setcdr
+ current
+ (list (format "%.2f" next-ease)
+ (number-to-string next-box)
+ (format "%.2f" next-interval)
+ (org-fc-review-next-time next-interval)))
+ (org-fc-set-review-data data))))))
+
+(defun org-fc-review-quit ()
+ "Quit the review, remove all overlays from the buffer."
+ (interactive)
+ (setq org-fc-review--current-session nil)
+ (org-fc-show-all))
+
+;;; Writing Review History
+
+(defun org-fc-review-history-add (elements)
+ "Add ELEMENTS to the history csv file."
+ (unless (and (boundp 'org-fc-demo-mode) org-fc-demo-mode)
+ (append-to-file
+ (concat
+ (mapconcat #'identity elements "\t")
+ "\n")
+ nil
+ org-fc-review-history-file)))
+
+;;; Reading / Writing Review Data
+
+;; Based on `org-log-beginning'
+(defun org-fc-review-data-position (&optional create)
+ "Return (BEGINNING . END) points of the review data drawer.
+When optional argument CREATE is non-nil, the function creates a
+drawer, if necessary. Returned position ignores narrowing.
+
+BEGINNING is the start of the first line inside the drawer,
+END is the start of the line with :END: on it."
+ (org-with-wide-buffer
+ (org-end-of-meta-data)
+ (let ((regexp (concat "^[ \t]*:" (regexp-quote org-fc-review-data-drawer) ":[ \t]*$"))
+ (end (if (org-at-heading-p) (point)
+ (save-excursion (outline-next-heading) (point))))
+ (case-fold-search t))
+ (catch 'exit
+ ;; Try to find existing drawer.
+ (while (re-search-forward regexp end t)
+ (let ((element (org-element-at-point)))
+ (when (eq (org-element-type element) 'drawer)
+ (throw 'exit
+ (cons (org-element-property :contents-begin element)
+ (org-element-property :contents-end element))))))
+ ;; No drawer found. Create one, if permitted.
+ (when create
+ (unless (bolp) (insert "\n"))
+ (let ((beg (point)))
+ (insert ":" org-fc-review-data-drawer ":\n:END:\n")
+ (org-indent-region beg (point)))
+ (cons
+ (line-beginning-position 0)
+ (line-beginning-position 0)))))))
+
+
+(defun org-fc-get-review-data ()
+ (let ((position (org-fc-review-data-position nil)))
+ (if position
+ (save-excursion
+ (goto-char (car position))
+ (cddr (org-table-to-lisp))))))
+
+(defun org-fc-set-review-data (data)
+ (save-excursion
+ (let ((position (org-fc-review-data-position t)))
+ (kill-region (car position) (cdr position))
+ (goto-char (car position))
+ (insert "| position | ease | box | interval | due |\n")
+ (insert "|-|-|-|-|-|\n")
+ (loop for datum in data do
+ (insert
+ "| "
+ (mapconcat (lambda (x) (format "%s" x)) datum " | ")
+ " |\n"))
+ (org-table-align))))
+
+(defun org-fc-review-data-default (position)
+ (list position org-fc-sm2-initial-ease 0 0
+ (org-fc-timestamp-now)))
+
+(defun org-fc-review-data-update (positions)
+ "Update review data to POSITIONS.
+If a doesn't exist already, it is initialized with default
+values. Entries in the table not contained in POSITIONS are
+removed."
+ (unless (and (boundp 'org-fc-demo-mode) org-fc-demo-mode)
+ (let ((old-data (org-fc-get-review-data)))
+ (org-fc-set-review-data
+ (mapcar
+ (lambda (pos)
+ (or
+ (assoc pos old-data #'string=)
+ (org-fc-review-data-default pos)))
+ positions)))))
+
+;;; Exports
+
+(provide 'org-fc-review)
diff --git a/org-fc-sm2.el b/org-fc-sm2.el
new file mode 100644
index 0000000..8200537
--- /dev/null
+++ b/org-fc-sm2.el
@@ -0,0 +1,82 @@
+;;; Configuration
+
+(defcustom org-fc-sm2-changes
+ '((again . -0.3)
+ (hard . -0.15)
+ (good . 0.0)
+ (easy . 0.15))
+ "Changes to a cards ease depending on its rating"
+ :type 'list
+ :group 'org-fc)
+
+(defcustom org-fc-sm2-fixed-intervals
+ '(0.0 0.01 1.0 6.0)
+ "Hard-coded intervals for the first few card boxes, values are in days"
+ :type 'list
+ :group 'org-fc)
+
+(defcustom org-fc-sm2-ease-min 1.3 "Lower bound for a cards ease"
+ :type 'float
+ :group 'org-fc)
+(defcustom org-fc-sm2-ease-initial 2.5 "Initial ease"
+ :type 'float
+ :group 'org-fc)
+(defcustom org-fc-sm2-ease-max 5.0 "Upper bound for a cards ease"
+ :type 'float
+ :group 'org-fc)
+
+(defcustom org-fc-sm2-fuzz-min 0.9
+ "Lower bound for random interval fuzz factor"
+ :type 'float
+ :group 'org-fc)
+(defcustom org-fc-sm2-fuzz-max 1.1
+ "Upper bound for random interval fuzz factor"
+ :type 'float
+ :group 'org-fc)
+
+(defun org-fc-sm2-fuzz (interval)
+ "Multiply INTERVAL by a random factor between
+`org-fc-sm2-fuzz-min' and `org-fc-sm2-fuzz-max'"
+ (*
+ interval
+ (+ org-fc-sm2-fuzz-min
+ (cl-random (- org-fc-sm2-fuzz-max org-fc-sm2-fuzz-min)))))
+
+;;; Parameter Calculation
+
+(defun org-fc-sm2-next-box (box rating)
+ "Calculate the next box of a card, based on the review rating."
+ (cond
+ ;; If a card is rated easy, skip the learning phase
+ ((and (eq box 0) (eq rating 'easy)) 2)
+ ;; If the review failed, go back to box 0
+ ((eq rating 'again) 0)
+ ;; Otherwise, move forward one box
+ (t (1+ box))))
+
+(defun org-fc-sm2-next-ease (ease box rating)
+ "Calculate the next ease of a card, based on the review rating."
+ (if (< box 2)
+ ease
+ (min
+ (max
+ (+ ease (alist-get rating org-fc-sm2-changes))
+ org-fc-sm2-ease-min)
+ org-fc-sm2-ease-max)))
+
+(defun org-fc-sm2--next-interval (interval next-box next-ease)
+ "Calculate the next interval of a card."
+ (if (< next-box (length org-fc-sm2-fixed-intervals))
+ (nth next-box org-fc-sm2-fixed-intervals)
+ (org-fc-sm2-fuzz (* next-ease interval))))
+
+(defun org-fc-sm2-next-parameters (ease box interval rating)
+ "Calculate the next parameters of a card, based on the review rating."
+ (let* ((next-ease (org-fc-sm2-next-ease ease box rating))
+ (next-box (org-fc-sm2-next-box box rating))
+ (next-interval (org-fc-sm2--next-interval interval next-box next-ease)))
+ (list next-ease next-box next-interval)))
+
+;;; Exports
+
+(provide 'org-fc-sm2)
diff --git a/org-fc-type-cloze.el b/org-fc-type-cloze.el
new file mode 100644
index 0000000..235c0c7
--- /dev/null
+++ b/org-fc-type-cloze.el
@@ -0,0 +1,163 @@
+(defvar org-fc-type-cloze-max-hole-property "FC_CLOZE_MAX")
+(defvar org-fc-type-cloze-type-property "FC_CLOZE_TYPE")
+
+(defvar org-fc-type-cloze-types
+ '(deletion enumeration context))
+
+(defvar org-fc-type-cloze--overlays '())
+
+(defvar org-fc-type-cloze-context 1
+ "Number of surrounding cards to show for 'context' type cards")
+
+(defvar org-fc-type-cloze-hole-re
+ (rx
+ (seq
+ "{{"
+ (group-n 1 (* (or (seq "$" (+ (not (any "$"))) "$")
+ (not (any "}"))))) "}"
+ (? (seq "{" (group-n 2 (* (or
+ (seq "$" (not (any "$")) "$")
+ (not (any "}"))))) "}"))
+ (? "@" (group-n 3 (+ digit)))
+ "}"))
+ "Regexp for a cloze hole without an id.")
+
+(defvar org-fc-type-cloze-id-hole-re
+ (rx
+ (seq
+ "{{"
+ (group-n 1 (* (or (seq "$" (+ (not (any "$"))) "$")
+ (not (any "}"))))) "}"
+ (? (seq "{" (group-n 2 (* (or
+ (seq "$" (not (any "$")) "$")
+ (not (any "}"))))) "}"))
+ (seq "@" (group-n 3 (+ digit)))
+ "}"))
+ "Regexp for a cloze hole with an id.")
+
+(defun org-fc-type-cloze-max-hole-id ()
+ (let ((max-id (org-entry-get (point) org-fc-type-cloze-max-hole-property)))
+ (if max-id
+ (string-to-number max-id)
+ -1)))
+
+(defun org-fc-type-cloze-hole (deletion)
+ "Generate the string used to mark the hole left by DELETION"
+ (format "[%s...]" (or (plist-get deletion :hint) "")))
+
+;; NOTE: The way parts of the hole are hidden / revealed is probably
+;; unnecessarily complicated. I couldn't get latex / org text emphasis
+;; to work otherwise. If the hole has no hint, we can't use any
+;; properties of match 2.
+(defun org-fc-type-cloze--overlay-current ()
+ "Generate a list of overlays to display the hole currently
+ being reviewed."
+ (if (match-beginning 2)
+ (list
+ :before-text
+ (org-fc-hide-region hole-beg (match-beginning 1))
+ :text
+ (org-fc-hide-region (match-beginning 1) (match-end 1))
+ :separator
+ (org-fc-hide-region (match-end 1) (match-beginning 2) "[...")
+ :hint
+ (org-fc-overlay-region (match-beginning 2) (match-end 2))
+ :after-hint
+ (org-fc-hide-region (match-end 2) hole-end "]"))
+ (list
+ :before-text
+ (org-fc-hide-region hole-beg (match-beginning 1))
+ :text
+ (org-fc-hide-region (match-beginning 1) (match-end 1))
+ :hint
+ (org-fc-hide-region (match-end 1) hole-end "[...]"))))
+
+(defun org-fc-type-cloze-hide-holes (hole type)
+ (save-excursion
+ (org-fc-goto-entry-heading)
+ (let* ((el (org-element-at-point))
+ (end (org-element-property :contents-end el))
+ (overlays nil))
+ (while (re-search-forward org-fc-type-cloze-id-hole-re end t)
+ (let ((text (match-string 1))
+ (hint (match-string 2))
+ (id (string-to-number (match-string 3)))
+ (hole-beg (match-beginning 0))
+ (hole-end (match-end 0)))
+ (if (= hole id)
+ (setq overlays (org-fc-type-cloze--overlay-current))
+ (if (and (eq type 'enumeration) overlays)
+ (org-fc-hide-region hole-beg hole-end "...")
+ (progn
+ (org-fc-hide-region hole-beg (match-beginning 1))
+ (org-fc-hide-region (match-end 1) hole-end))))))
+ overlays)))
+
+(defun org-fc-type-cloze-flip ()
+ (-when-let (overlays org-fc-type-cloze--overlays)
+ (if (plist-member overlays :separator)
+ (org-fc-hide-overlay (plist-get overlays :separator)))
+ (if (plist-member overlays :after-hint)
+ (org-fc-hide-overlay (plist-get overlays :after-hint)))
+ (org-fc-hide-overlay (plist-get overlays :hint))
+ (delete-overlay (plist-get overlays :text)))
+ (org-fc-review-rate-hydra/body))
+
+(defun org-fc-type-cloze-setup (position)
+ (let ((hole (string-to-number position))
+ (cloze-type (intern (org-entry-get (point) org-fc-type-cloze-type-property))))
+ (org-show-subtree)
+ (setq
+ org-fc-type-cloze--overlays
+ (org-fc-type-cloze-hide-holes hole cloze-type)))
+ (org-fc-review-flip-hydra/body))
+
+(defun org-fc-type-cloze-read-type ()
+ (intern
+ (completing-read
+ "Cloze Type: "
+ org-fc-type-cloze-types)))
+
+(defun org-fc-type-cloze-init (type)
+ "Initialize the current heading for use as a cloze card of subtype TYPE.
+Processes all holes in the card text."
+ (interactive (list (org-fc-type-cloze-read-type)))
+ (unless (member type org-fc-type-cloze-types)
+ (error "Invalid cloze card type: %s" type))
+ (org-fc--init-card "cloze")
+ (org-fc-type-cloze-update)
+ (org-set-property
+ org-fc-type-cloze-type-property
+ (format "%s" type)))
+
+(defun org-fc-type-cloze-update ()
+ "Update the review data & deletions of the current heading."
+ (let* ((el (org-element-at-point))
+ (end (org-element-property :contents-end el))
+ (hole-id (1+ (org-fc-type-cloze-max-hole-id)))
+ ids)
+ (save-excursion
+ (while (re-search-forward org-fc-type-cloze-hole-re end t)
+ (let ((id (match-string 3))
+ (hole-end (match-end 0)))
+ (unless id
+ (setq id hole-id)
+ (incf hole-id 1)
+ (let ((id-str (number-to-string id)))
+ (incf end (+ 1 (length id-str)))
+ (goto-char hole-end)
+ (backward-char)
+ (insert "@" id-str)))
+ (push (format "%s" id) ids))))
+ (org-set-property
+ org-fc-type-cloze-max-hole-property
+ (format "%s" (1- hole-id)))
+ (org-fc-review-data-update (reverse ids))))
+
+(org-fc-register-type
+ 'cloze
+ 'org-fc-type-cloze-setup
+ 'org-fc-type-cloze-flip
+ 'org-fc-type-cloze-update)
+
+(provide 'org-fc-type-cloze)
diff --git a/org-fc-type-double.el b/org-fc-type-double.el
new file mode 100644
index 0000000..cea35dd
--- /dev/null
+++ b/org-fc-type-double.el
@@ -0,0 +1,41 @@
+(defvar org-fc-type-double-hole-re
+ (rx "{{" (group (* (not (any "}")))) "}}"))
+
+(defvar org-fc-type-double--overlay '())
+
+(defun org-fc-type-double-init ()
+ (interactive)
+ (org-fc--init-card "double")
+ (org-fc-review-data-update '("front" "back")))
+
+(defun org-fc-type-double-setup (position)
+ (pcase position
+ ("front" (org-fc-type-normal-setup position))
+ ("back" (org-fc-type-double-setup-back))
+ (_ (error "Invalid double position %s" position))))
+
+(defun org-fc-type-double-setup-back ()
+ (org-show-subtree)
+ (if (org-fc-has-back-heading-p)
+ (setq org-fc-type-double--overlay (org-fc-hide-content "[...]\n"))
+ (setq org-fc-type-double--overlay (org-fc-hide-heading "[...]")))
+ (org-fc-review-flip-hydra/body))
+
+(defun org-fc-type-double-flip ()
+ (message "double flip")
+ (pp org-fc-type-double--overlay)
+ (if org-fc-type-double--overlay
+ (delete-overlay org-fc-type-double--overlay))
+ (org-show-subtree)
+ (org-fc-review-rate-hydra/body))
+
+;; No-op
+(defun org-fc-type-double-update ())
+
+(org-fc-register-type
+ 'double
+ 'org-fc-type-double-setup
+ 'org-fc-type-double-flip
+ 'org-fc-type-double-update)
+
+(provide 'org-fc-type-double)
diff --git a/org-fc-type-normal.el b/org-fc-type-normal.el
new file mode 100644
index 0000000..dd12bf9
--- /dev/null
+++ b/org-fc-type-normal.el
@@ -0,0 +1,34 @@
+(defun org-fc-type-normal-init ()
+ (interactive)
+ (org-fc--init-card "normal")
+ (org-fc-review-data-update '("front")))
+
+(defvar org-fc-type-normal--hidden '())
+
+(defun org-fc-type-normal-setup (_position)
+ (interactive)
+ (if (org-fc-has-back-heading-p)
+ (progn
+ (org-show-subtree)
+ (setq org-fc-type-normal--hidden (org-fc-hide-subheading "Back"))))
+ (org-fc-review-flip-hydra/body))
+
+(defun org-fc-type-normal-flip ()
+ (interactive)
+ (save-excursion
+ (org-show-subtree)
+ (dolist (pos org-fc-type-normal--hidden)
+ (goto-char pos)
+ (org-show-subtree)))
+ (org-fc-review-rate-hydra/body))
+
+;; No-op
+(defun org-fc-type-normal-update ())
+
+(org-fc-register-type
+ 'normal
+ 'org-fc-type-normal-setup
+ 'org-fc-type-normal-flip
+ 'org-fc-type-normal-update)
+
+(provide 'org-fc-type-normal)
diff --git a/org-fc-type-text-input.el b/org-fc-type-text-input.el
new file mode 100644
index 0000000..3fc923f
--- /dev/null
+++ b/org-fc-type-text-input.el
@@ -0,0 +1,30 @@
+(defun org-fc-type-text-input-init ()
+ (interactive)
+ (org-fc--init-card "text-input")
+ (org-fc-review-data-update '("front")))
+
+(defun org-fc-type-text-input-review (_position)
+ (org-show-subtree)
+ (let ((answer (org-entry-get (point) "ANSWER"))
+ (user-answer (read-string "Answer: ")))
+ (goto-char (point-max))
+ ;; Overlays need to be of at least size 1 to be visible
+ (let ((ovl (make-overlay (- (point) 1) (point))))
+ (overlay-put ovl 'category 'org-fc-additional-text-overlay)
+ (overlay-put ovl 'priority 9999)
+ (overlay-put ovl 'face 'default)
+ (overlay-put ovl 'display
+ (concat "\n\n\nExpected: " answer
+ "\nGot: " user-answer)))))
+
+;; No-op
+(defun org-fc-type-text-input-update ())
+
+;; TODO: Implement real handler
+(org-fc-register-type
+ 'text-input
+ 'org-fc-type-normal-setup
+ 'org-fc-type-normal-flip
+ 'org-fc-type-normal-update)
+
+(provide 'org-fc-type-text-input)
diff --git a/org-fc.el b/org-fc.el
new file mode 100644
index 0000000..6ce0814
--- /dev/null
+++ b/org-fc.el
@@ -0,0 +1,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-flashcards/"
+ "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)