diff options
author | Leon Rische <leon.rische@me.com> | 2020-01-11 15:24:56 +0100 |
---|---|---|
committer | Leon Rische <leon.rische@me.com> | 2020-01-11 15:24:56 +0100 |
commit | 1c7838eb972ac365e648fc231620cb5f18a07788 (patch) | |
tree | d0a339cec50b9ecb468f2c7d380e3d3c37e85927 |
Initial commit
-rw-r--r-- | README.org | 464 | ||||
-rw-r--r-- | awk/files.awk | 18 | ||||
-rw-r--r-- | awk/filter_due.awk | 8 | ||||
-rw-r--r-- | awk/index_cards.awk | 46 | ||||
-rw-r--r-- | awk/index_positions.awk | 69 | ||||
-rw-r--r-- | awk/stats_cards.awk | 47 | ||||
-rw-r--r-- | awk/stats_positions.awk | 40 | ||||
-rw-r--r-- | awk/stats_reviews.awk | 53 | ||||
-rw-r--r-- | awk/utils.awk | 23 | ||||
-rw-r--r-- | demo.org | 70 | ||||
-rw-r--r-- | org-fc-awk.el | 190 | ||||
-rw-r--r-- | org-fc-dashboard.el | 138 | ||||
-rw-r--r-- | org-fc-overlay.el | 165 | ||||
-rw-r--r-- | org-fc-review.el | 288 | ||||
-rw-r--r-- | org-fc-sm2.el | 82 | ||||
-rw-r--r-- | org-fc-type-cloze.el | 163 | ||||
-rw-r--r-- | org-fc-type-double.el | 41 | ||||
-rw-r--r-- | org-fc-type-normal.el | 34 | ||||
-rw-r--r-- | org-fc-type-text-input.el | 30 | ||||
-rw-r--r-- | org-fc.el | 306 |
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) |