├── .gitignore ├── LICENCE.md ├── README.md ├── bin └── srs ├── lib ├── srs.rb └── srs │ ├── cli.rb │ ├── cli │ ├── cat.rb │ ├── do-exercise.rb │ ├── get-field.rb │ ├── help.rb │ ├── init.rb │ ├── insert-into.rb │ ├── next-due.rb │ ├── next-new.rb │ ├── queue.rb │ ├── reschedule.rb │ └── schedule.rb │ ├── models │ └── SimpleFlashcard.rb │ ├── schedulers │ └── SuperMemo2.rb │ ├── version.rb │ └── workspace.rb └── srs.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | # Aruba tmp dir 2 | tmp 3 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Daniel P. Wright 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | srs 2 | === 3 | 4 | A Spaced Repetition System is a study tool which works by spacing out exercises 5 | so as to learn in the most efficient manner possible. Further information can 6 | be found at the following Wikipedia pages: 7 | 8 | * [Spacing Effect][1] 9 | * [Forgetting Curve][2] 10 | * [Spaced Repetition][3] 11 | 12 | `srs` is a command-line based implementation of the spaced repetition system. It 13 | is designed to be highly extensible and to promote the sharing of data for study 14 | by others. 15 | 16 | Installation 17 | ------------ 18 | 19 | `srs` is distributed as a Gem. Make sure you have Ruby and RubyGems installed, 20 | and then type: 21 | 22 | $ gem install srs 23 | 24 | Usage 25 | ----- 26 | 27 | This first release of `srs` is an _alpha_ release -- it is functionally 28 | complete, but the user interface is in its very early stages, documentation is 29 | lacking, and there may be bugs. Since elements in the workspace format may 30 | change, it is not recommended to use this version for actual practice. Treat it 31 | as a "sneak preview". Version 0.2 is intended to be much closer to a final, 32 | usable system, so please watch the project for updates on when that version is 33 | released. 34 | 35 | With that in mind, read on... 36 | 37 | ### Initialising a workspace 38 | 39 | The first thing you will want to do once you've installed `srs` is to initialise 40 | a _workspace_. This is where all the data required for one set of material you 41 | want to study reside. It is generally a good idea to group related items 42 | together -- for example, I have a workspace for Japanese vocabulary, another for 43 | kanji, and another for poetry and quotations which I'd like to remember. 44 | 45 | How you think the things you want to learn should be distributed is a 46 | personal choice, and you should consider for yourself what will work best for 47 | you. For example, some people might prefer to put the Japanese vocabulary and 48 | the kanji together in one workspace -- that is fine. Merging or splitting 49 | workspaces at a later stage is relatively easy, so do experiment! 50 | 51 | To initialise a workspace, create a directory and run the following command 52 | inside it: 53 | 54 | $ srs init 55 | 56 | ### Adding an exercise 57 | 58 | In `srs`, a single item of practice or revision is called an _exercise_. These 59 | can be anything -- a flashcard-style question-and-answer, or a more interactive 60 | form of practice. What a particular exercise entails depends entirely on what 61 | it is you want to practice, and for that reason `srs` introduces the concept of 62 | _models_. 63 | 64 | A model is a Ruby class which defines how an exercise is performed. `srs` comes 65 | packaged with the most basic kind of model, a flashcard, which is distributed 66 | under the name `SimpleFlashcard`. You can create your own models, but for now 67 | we'll make use of the `SimpleFlashcard` model to get something up and running 68 | quickly. 69 | 70 | A `SimpleFlashcard` exercise comes in two parts: 71 | 72 | * The _data_, which usually contains the actual thing you want to test 73 | * The _exercise specification_, which determines how to use that data. 74 | 75 | This separation allows you to use the same data for multiple exercises. In this 76 | example, we're going to create a "Production" and a "Recognition" card for the 77 | Japanese word, 勉強, which means "study". 78 | 79 | The first thing we need to do is create the DataFile. `SimpleFlashcard` 80 | currently expects its data to consist of a series of key-value pairs, separated 81 | by a colon. Currently multi-line fields are not supported, though this will 82 | change in a future version. Run the following from inside the workspace 83 | directory you created (The ^D at the end signifies pressing Control-D to send 84 | the end-of-file marker to `srs`): 85 | 86 | $ srs insert-into data 87 | Word: 勉強 88 | Pronunciation (Hiragana): べんきょう 89 | Pronunciation (Romaji): Benkyou 90 | Meaning: Study 91 | ^Dc13d1e790ef5e8ced8c96a37a6d014f08ddcb3af 92 | 93 | You should see the output after pressing ^D as above, 94 | `c13d1e790ef5e8ced8c96a37a6d014f08ddcb3af`. The string itself may be different, 95 | but it will be a long string of hexadecimal digits. The `insert-into` command 96 | reads data in from STDIN and outputs an ID which can be used by other `srs` 97 | commands to access that data. 98 | 99 | We now have data containing four fields related to the word. We can combine 100 | these fields in a variety of ways to generate a number of exercises. Here we'll 101 | generate two; one to produce the English meaning when shown the word and the 102 | pronunciation; the other to produce the Japanese word when shown the English. 103 | Input the following, substituting the value passed into the _Data_ field with 104 | whatever was output from the previous command: 105 | 106 | $ srs insert-into exercises 107 | Data: c13d1e790ef5e8ced8c96a37a6d014f08ddcb3af 108 | Model: SimpleFlashcard 109 | 110 | [Word] 111 | [Pronunciation (Hiragana)] 112 | --- 113 | [Meaning] 114 | ^D884bd92624411f5bb42ff9abdf84c3e09ba00cab 115 | 116 | Note the blank line between the set of key-value pairs and the text below. 117 | `SimpleFlashcard` expects a series of headers, followed by a blank line, 118 | followed by some metadata. The metadata is in two parts: the question, which is 119 | everything before the "---" string, and the answer, which is everything that 120 | comes after it. Any words within square brackets are substituted with the value 121 | of their corresponding field in the data. 122 | 123 | As with the previous command, this command outputs an ID once it has completed. 124 | Remember this; you will need it later. Let's add the second exercise: 125 | 126 | $ srs insert-into exercises 127 | Data: c13d1e790ef5e8ced8c96a37a6d014f08ddcb3af 128 | Model: SimpleFlashcard 129 | 130 | [Meaning] 131 | --- 132 | [Word] 133 | ^Dd930b3fce3d2f988758c7088ea77d9075b8c82bf 134 | 135 | As you can see, this is just the same exercise, with the question and answer 136 | reversed. Also, we are ignoring pronunciation for this one. 137 | 138 | You will notice, neither of these exercises make use of the "Pronunciation (Romaji)" 139 | field. The truth is, I don't much like Romaji. But it is entirely reasonable 140 | to add fields you won't use as part of the exercises to the data; you may choose 141 | to create exercises which make use of that data later, or you may just want to 142 | look it up (for example, you could include the link to a URL where you 143 | discovered the information). 144 | 145 | ### Scheduling an exercise 146 | 147 | The next thing we must do is schedule the exercises we've just created. If we 148 | don't do this, they will never enter the `srs` scheduling system, and so they 149 | will simply sit there unasked! 150 | 151 | There have been a number of spaced repetition algorithms developed over the 152 | years, perhaps the most famous of which are the [Pimsleur Graduated Recall][4] 153 | and [SuperMemo 2][5] algorithms. As with models, `srs` allows you to define 154 | your own custom spacing algorithm by creating a _scheduler_. The base 155 | distribution comes with probably the most popular spacing algorithm 156 | pre-installed, SuperMemo 2. We'll use that one. 157 | 158 | Type the following, substituting the two ids with the ones returned when you 159 | inserted the two exercises: 160 | 161 | $ srs schedule -s SuperMemo2 884bd92624411f5bb42ff9abdf84c3e09ba00cab 162 | schedule/pending/20120708003132.386 163 | $ srs schedule -s SuperMemo2 d930b3fce3d2f988758c7088ea77d9075b8c82bf 164 | schedule/pending/20120708003149.754 165 | 166 | ### Doing some reps -- new exercises 167 | 168 | Now that you've scheduled some exercises, you're ready to do some reps. Let's 169 | ask `srs` what the next new exercise is which is available for learning: 170 | 171 | $ srs next-new 172 | 20120708003132.386 173 | 174 | The ID of the first exercise you scheduled above should be output. In order to 175 | actually test ourselves, we'll need the ID of the exercise we want to run. We 176 | can get this from the `Exercise` field stored in the schedule (as always, 177 | remembering to substitute the example ID below with your own): 178 | 179 | $ srs get-field exercise 20120708003132.386 180 | 884bd92624411f5bb42ff9abdf84c3e09ba00cab 181 | 182 | An exercise ID will be output, which we can feed straight into `do-exercise`: 183 | 184 | $ srs do-exercise a884bd92624411f5bb42ff9abdf84c3e09ba00cab 185 | 勉強 186 | べんきょう 187 | > 188 | 189 | At this point you are given a prompt. Let's enter the correct answer, "Study", 190 | and see what happens: 191 | 192 | > Study 193 | Correct. 194 | You scored: 1.0 195 | 196 | Scores in `srs` are normalised from 0-1, so 1.0 is a full score. Well done! We 197 | still need to enter this into the scheduler so that it knows when next to repeat 198 | the exercise. Enter the following to reschedule the exercise. The ID is the 199 | _schedule_ ID, not the one for the exercise: 200 | 201 | $ srs reschedule 20120708003132.386 1.0 202 | Exercise rescheduled for 2012-07-09 00:00:00 +0900 203 | 204 | Excellent! We'll see this exercise again tomorrow. 205 | 206 | It's actually possible to wrap up most of the above in a single line. The 207 | following assumes you use a `bash` shell, though other shells may be similar: 208 | 209 | $ SCHEDULE=$(srs next-new); EXERCISE=$(srs get-field exercise $SCHEDULE); srs do-exercise $EXERCISE 210 | 211 | This time we'll try answering the question incorrectly: 212 | 213 | Study 214 | > 遊ぶ 215 | 勉強 216 | Was your answer: [h] Correct, [j] Close, [k] Wrong, or [l] Very Wrong? 217 | > l 218 | You scored: 0.0 219 | 220 | When you enter a wrong answer, the `SimpleFlashcard` doesn't attempt to judge 221 | for itself whether or not you were close to the right answer. Instead, it shows 222 | you the correct answer and lets you specify how close you thought you were. In 223 | this case, we were miles off, so we selected 'l', to fail the exercise 224 | completely. Now to reschedule the exercise: 225 | 226 | $ srs reschedule $SCHEDULE 0.0 227 | Exercise rescheduled for 2012-07-09 00:00:00 +0900 228 | Exercise failed; marked for repetition 229 | 230 | Since we failed the exercise, the scheduler has marked it for repetition. This 231 | means that once we've finished all our scheduled reps for the day, we will be 232 | presented with this exercise (and any other failed exercises), to try again until 233 | we have managed to pass them. Note that only the first attempt affects the 234 | interval; subsequent repetitions are simply practice. 235 | 236 | ### Practice makes perfect! Repeating exercises 237 | 238 | For the most part, you're going to be practicing exercises you've already done 239 | once. The flow for this is very similar to the above, except that instead of 240 | `next-new` we use the `next-due` command. 241 | 242 | Before we can use this command, however, we need to update the srs queue: 243 | 244 | $ srs queue 245 | 246 | This command tells `srs` to look through the schedules and determine which 247 | exercises are due for practice. We can now use `next-due` similarly to the 248 | way we practised new exercises in the previous section: 249 | 250 | $ SCHEDULE=$(srs next-due); EXERCISE=$(srs get-field exercise $SCHEDULE); srs do-exercise $EXERCISE 251 | Study 252 | > 勉強 253 | Correct. 254 | You scored: 1.0 255 | 256 | $ srs reschedule $SCHEDULE 1.0 257 | Exercise rescheduled for 2012-07-09 00:00:00 +0900 258 | 259 | In this case, since the exercise had already been scheduled and was simply a 260 | repetition of a failed exercise, the date matched that which was output 261 | previously. 262 | 263 | Finally, we can confirm that there are no more exercises left to practice: 264 | 265 | $ srs queue 266 | $ srs next-due 267 | 268 | Contributing 269 | ------------ 270 | 271 | `srs` is in very early stages and as such there is a _lot_ of work still to do 272 | on it. Contributions are welcome! 273 | 274 | To contribute, fork the project on github and send me a pull request, or email 275 | me a patch. Please bear the following in mind when making contributions: 276 | 277 | * Try and keep individual commits small and self-contained. If I feel like 278 | there is too much going on in a single commit, I may ask you to split it up 279 | into multiple commits. 280 | * Please write clear, descriptive commit messages. These should be formatted 281 | with a title of `<=` 50 characters, and body text wrapped at 72 characters. 282 | I am quite particular about this. 283 | * I come from a pretty heavy C++ background. Ruby style corrections and 284 | improvements are very much appreciated! Please be nice about it. 285 | 286 | Copyright 287 | --------- 288 | 289 | Copyright (c) 2012 Daniel P. Wright. 290 | 291 | This software is released under the Simplified BSD Licence. See LICENCE.md for 292 | further details. 293 | 294 | [1]: http://en.wikipedia.org/wiki/Spacing_effect 295 | [2]: http://en.wikipedia.org/wiki/Forgetting_curve 296 | [3]: http://en.wikipedia.org/wiki/Spaced_repetition 297 | [4]: http://en.wikipedia.org/wiki/Graduated_interval_recall 298 | [5]: http://www.supermemo.com/english/ol/sm2.htm 299 | -------------------------------------------------------------------------------- /bin/srs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | begin 4 | require 'srs/cli' 5 | rescue LoadError 6 | require 'rubygems' 7 | require 'srs/cli' 8 | end 9 | 10 | exit SRS::CLI.run!(*ARGV) 11 | 12 | -------------------------------------------------------------------------------- /lib/srs.rb: -------------------------------------------------------------------------------- 1 | require 'srs/workspace' 2 | require 'srs/version' 3 | -------------------------------------------------------------------------------- /lib/srs/cli.rb: -------------------------------------------------------------------------------- 1 | require 'srs/cli/init' 2 | require 'srs/cli/help' 3 | require 'srs/cli/insert-into' 4 | require 'srs/cli/schedule' 5 | require 'srs/cli/do-exercise' 6 | require 'srs/cli/reschedule' 7 | require 'srs/cli/queue' 8 | require 'srs/cli/next-due' 9 | require 'srs/cli/next-new' 10 | require 'srs/cli/get-field' 11 | require 'srs/cli/cat' 12 | 13 | module SRS 14 | class CLI 15 | class << self 16 | COMMANDS = { "init" => :Init, 17 | "insert-into" => :InsertInto, 18 | "schedule" => :Schedule, 19 | "do-exercise" => :DoExercise, 20 | "reschedule" => :Reschedule, 21 | "queue" => :Queue, 22 | "next-due" => :NextDue, 23 | "next-new" => :NextNew, 24 | "get-field" => :GetField, 25 | "cat" => :Cat, 26 | "help" => :Help }.freeze 27 | 28 | def cmd_to_symbol(command) 29 | return COMMANDS[command] 30 | end 31 | 32 | def run!(*arguments) 33 | command = cmd_to_symbol(arguments.shift) 34 | if command 35 | return SRS::CLI.const_get(command).new.run!(arguments) 36 | else 37 | return SRS::CLI::Help.new.run!([]) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | 44 | -------------------------------------------------------------------------------- /lib/srs/cli/cat.rb: -------------------------------------------------------------------------------- 1 | module SRS 2 | class CLI 3 | class Cat 4 | VALID_SECTIONS = ["data", "exercises"].freeze 5 | def run!(arguments) 6 | if not SRS::Workspace.initialised? then 7 | puts "Current directory is not an SRS Workspace" 8 | return 3 9 | end 10 | 11 | sha1 = arguments.shift 12 | sha1_start = sha1[0..1] 13 | sha1_rest = sha1[2..-1] 14 | 15 | VALID_SECTIONS.each do |section| 16 | datafile = "#{section}/#{sha1_start}/#{sha1_rest}" 17 | if File.exists?(datafile) then 18 | contents = File.open(datafile, "r"){ |file| file.read } 19 | puts contents 20 | 21 | return 0 22 | end 23 | end 24 | 25 | puts "No content with that ID exists" 26 | return 4 27 | end 28 | 29 | def help() 30 | puts <<-EOF 31 | srs cat 32 | 33 | Outputs the content matching 34 | EOF 35 | end 36 | end 37 | end 38 | end 39 | 40 | -------------------------------------------------------------------------------- /lib/srs/cli/do-exercise.rb: -------------------------------------------------------------------------------- 1 | module SRS 2 | class CLI 3 | class DoExercise 4 | def run!(arguments) 5 | if not SRS::Workspace.initialised? then 6 | puts "Current directory is not an SRS Workspace" 7 | return 3 8 | end 9 | 10 | sha1 = arguments.shift 11 | sha1_start = sha1[0..1] 12 | sha1_rest = sha1[2..-1] 13 | 14 | datafile = "exercises/#{sha1_start}/#{sha1_rest}" 15 | 16 | if not File.exists?(datafile) then 17 | puts "No content with that ID exists" 18 | return 4 19 | end 20 | 21 | headers = {} 22 | metadata = "" 23 | File.open(datafile, "r") do |file| 24 | while( line = file.gets() ) do 25 | if line.strip.empty? then 26 | break 27 | end 28 | 29 | key, *val = line.split(':').map{|e| e.strip} 30 | headers[key] = val.join(':') 31 | end 32 | metadata = file.read 33 | end 34 | 35 | runModel(sha1, headers, metadata) 36 | end 37 | 38 | def runModel(sha1, headers, metadata) 39 | if not headers.has_key?("Model") then 40 | puts "Exercise #{sha1} has no model!\n" 41 | return nil 42 | end 43 | 44 | modelclass = headers.delete("Model") 45 | 46 | begin 47 | require "./models/#{modelclass}" 48 | rescue LoadError 49 | begin 50 | require "srs/models/#{modelclass}" 51 | rescue LoadError 52 | puts "Couldn't find model #{modelclass}." 53 | return nil 54 | end 55 | end 56 | 57 | model = SRS::Models.const_get(modelclass.to_sym).new 58 | score = model.run(headers, metadata) 59 | 60 | return score 61 | end 62 | 63 | def help() 64 | puts <<-EOF 65 | srs do-exercise 66 | 67 | Runs the exercise defined in 68 | EOF 69 | end 70 | end 71 | end 72 | end 73 | 74 | -------------------------------------------------------------------------------- /lib/srs/cli/get-field.rb: -------------------------------------------------------------------------------- 1 | module SRS 2 | class CLI 3 | class GetField 4 | def run!(arguments) 5 | if not SRS::Workspace.initialised? then 6 | puts "Current directory is not an SRS Workspace" 7 | return 3 8 | end 9 | 10 | field = arguments.shift 11 | id = arguments.shift 12 | 13 | is_schedule = (id =~ /\d{14}\.\d{3}/) 14 | 15 | filename = "" 16 | if is_schedule then 17 | filename = "schedule/#{id}" 18 | filename = "schedule/pending/#{id}" if not File.exists?(filename) 19 | else 20 | filename = "exercises/#{id}" 21 | end 22 | 23 | if not File.exists?(filename) then 24 | puts "No content with that ID exists" 25 | return 4 26 | end 27 | 28 | File.open(filename, "r") do |file| 29 | while( line = file.gets() ) do 30 | if line.strip.empty? then 31 | break 32 | end 33 | 34 | key, *val = line.split(':').map{|e| e.strip} 35 | if key.casecmp(field) == 0 then 36 | puts val.join(':') 37 | return 0 38 | end 39 | end 40 | end 41 | 42 | puts "Content #{id} does not contain field \"#{field}\"." 43 | return 4 44 | end 45 | 46 | def help() 47 | puts <<-EOF 48 | srs get-field 49 | 50 | Returns the value of the field from content 51 | EOF 52 | end 53 | end 54 | end 55 | end 56 | 57 | -------------------------------------------------------------------------------- /lib/srs/cli/help.rb: -------------------------------------------------------------------------------- 1 | require 'srs/cli' 2 | 3 | module SRS 4 | class CLI 5 | class Help 6 | def run!(arguments) 7 | if arguments.empty? 8 | summary 9 | else 10 | command = SRS::CLI::cmd_to_symbol(arguments.first) 11 | if command 12 | SRS::CLI.const_get(command).new.help() 13 | else 14 | summary 15 | end 16 | end 17 | return 0 18 | end 19 | 20 | def help() 21 | end 22 | 23 | def summary() 24 | puts "Usage: srs [args]" 25 | puts 26 | puts "Available commands are:" 27 | puts " init Initialise an SRS workspace" 28 | puts " insert-into Insert data into the workspace" 29 | puts " schedule Schedule an exercise" 30 | puts " do-exercise Perform a rep on an exercise" 31 | puts " reschedule Update an exercise schedule based on score" 32 | puts " queue Queue due exercises" 33 | puts " next-due Retrieve the next due exercise from the queue" 34 | puts " next-new Retrieve the next available untested exercise" 35 | puts " get-field Retrieve a field by name from a schedule or exercise" 36 | puts " cat Output data contained within the workspace" 37 | puts 38 | puts "See 'srs help ' for more information on a specific command." 39 | end 40 | end 41 | end 42 | end 43 | 44 | 45 | -------------------------------------------------------------------------------- /lib/srs/cli/init.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | require 'srs/workspace' 3 | 4 | module SRS 5 | class CLI 6 | class Init 7 | def initialize 8 | @options = {} 9 | @opts = OptionParser.new do |o| 10 | o.banner = <<-EOF.gsub /^\s+/, "" 11 | srs init [options] [dirname] 12 | 13 | Initialises a workspace in directory [dirname]. A `.srs/` folder will be 14 | created containing the default configuration files. A skeleton directory 15 | structure may also be created (this is undecided as yet). 16 | 17 | If no [dirname] is passed, uses the current directory. 18 | EOF 19 | 20 | o.on('-f', '--force', 'Initialise workspace even if the directory is not empty') do 21 | @options[:force] = true 22 | end 23 | end 24 | end 25 | 26 | def run!(arguments) 27 | begin 28 | @opts.parse!(arguments) 29 | @options[:dir_name] = arguments.shift 30 | rescue OptionParser::InvalidOption => e 31 | @options[:invalid_argument] = e.message 32 | end 33 | 34 | if @options[:dir_name] == nil then 35 | @options[:dir_name] = "./" 36 | end 37 | 38 | begin 39 | SRS::Workspace.create(@options[:dir_name], @options[:force]) 40 | rescue SRS::Workspace::AlreadyInitialisedError => e 41 | puts "SRS is already initialised in #{@options[:dir_name]}." 42 | return 2 43 | rescue SRS::Workspace::FolderNotEmptyError => e 44 | puts "The current folder is not empty!" 45 | puts "Run 'srs init --force' to initialise in this folder anyway." 46 | return 1 47 | end 48 | 49 | 0 50 | end 51 | 52 | def help() 53 | puts @opts 54 | end 55 | end 56 | end 57 | end 58 | 59 | -------------------------------------------------------------------------------- /lib/srs/cli/insert-into.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require 'digest/sha1' 3 | 4 | module SRS 5 | class CLI 6 | class InsertInto 7 | VALID_SECTIONS = ["data", "exercises"].freeze 8 | def run!(arguments) 9 | if not SRS::Workspace.initialised? then 10 | puts "Current directory is not an SRS Workspace" 11 | return 3 12 | end 13 | 14 | section = arguments.shift() 15 | if section == nil or !VALID_SECTIONS.include?(section) then 16 | help() 17 | return 4 18 | end 19 | 20 | data = STDIN.read() 21 | sha1 = Digest::SHA1.hexdigest data 22 | sha1_start = sha1[0..1] 23 | sha1_rest = sha1[2..-1] 24 | datafile = "#{section}/#{sha1_start}/#{sha1_rest}" 25 | 26 | if not File.exists?(datafile) then 27 | FileUtils::mkdir_p("#{section}/#{sha1_start}") 28 | File.open(datafile, 'w') {|f| f.write(data)} 29 | end 30 | 31 | puts sha1 32 | 33 | return 0 34 | end 35 | 36 | def help() 37 | puts <<-EOF 38 | srs insert-into
39 | 40 | Reads the contents from stdin and inserts it into the appropriate section in the 41 | workspace.
can be one of "data", "exercise", or "schedule". Returns 42 | the id used to access that exercise. 43 | EOF 44 | end 45 | end 46 | end 47 | end 48 | 49 | -------------------------------------------------------------------------------- /lib/srs/cli/next-due.rb: -------------------------------------------------------------------------------- 1 | module SRS 2 | class CLI 3 | class NextDue 4 | def run!(arguments) 5 | if not SRS::Workspace.initialised? then 6 | puts "Current directory is not an SRS Workspace" 7 | return 3 8 | end 9 | 10 | ws = SRS::Workspace.new 11 | 12 | schedule = nil 13 | if File.exists? "#{ws.dotsrs}/QUEUED" then 14 | File.open("#{ws.dotsrs}/QUEUED", "r") do |queued_file| 15 | schedule = queued_file.gets 16 | end 17 | end 18 | 19 | if schedule == nil then 20 | if File.exists? "#{ws.dotsrs}/REPEAT" then 21 | File.open("#{ws.dotsrs}/REPEAT", "r") do |repeat_file| 22 | schedule = repeat_file.gets 23 | end 24 | end 25 | end 26 | 27 | if not schedule == nil then 28 | puts File.basename schedule 29 | end 30 | 31 | return 0 32 | end 33 | 34 | def help() 35 | puts <<-EOF 36 | srs next-due 37 | 38 | Prints out the id of the next due schedule. Prints nothing if nothing is due. 39 | EOF 40 | end 41 | end 42 | end 43 | end 44 | 45 | -------------------------------------------------------------------------------- /lib/srs/cli/next-new.rb: -------------------------------------------------------------------------------- 1 | module SRS 2 | class CLI 3 | class NextNew 4 | def run!(arguments) 5 | if not SRS::Workspace.initialised? then 6 | puts "Current directory is not an SRS Workspace" 7 | return 3 8 | end 9 | 10 | new_schedules = Dir["schedule/pending/*"].sort 11 | if not new_schedules.empty? 12 | puts File.basename new_schedules.first 13 | end 14 | 15 | return 0 16 | end 17 | 18 | def help() 19 | puts <<-EOF 20 | srs next-new 21 | 22 | Prints out the id of the next untested schedule. Prints nothing if there are no 23 | pending schedules. 24 | EOF 25 | end 26 | end 27 | end 28 | end 29 | 30 | -------------------------------------------------------------------------------- /lib/srs/cli/queue.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | module SRS 4 | class CLI 5 | class Queue 6 | def run!(arguments) 7 | if not SRS::Workspace.initialised? then 8 | puts "Current directory is not an SRS Workspace" 9 | return 3 10 | end 11 | 12 | queued = {} 13 | repeat = [] 14 | 15 | Dir["schedule/*"].each do |filename| 16 | next if File.directory?(filename) 17 | 18 | schedule = {} 19 | File.open(filename, "r") do |file| 20 | while( line = file.gets() ) do 21 | if line.strip.empty? then 22 | break 23 | end 24 | 25 | key, *val = line.split(':').map{|e| e.strip} 26 | schedule[key] = val.join(':') 27 | end 28 | 29 | if( schedule["Repeat"] == "true" ) then 30 | repeat << filename 31 | else 32 | due = DateTime.parse(schedule["Due"]) 33 | if( due < DateTime.now ) then 34 | queued[filename] = due 35 | end 36 | end 37 | end 38 | end 39 | 40 | ws = SRS::Workspace.new 41 | 42 | File.open("#{ws.dotsrs}/QUEUED", "w") do |queued_file| 43 | queued.sort_by{ |key, value| value } 44 | queued.each do |filename, due| 45 | queued_file.puts filename 46 | end 47 | end 48 | 49 | File.open("#{ws.dotsrs}/REPEAT", "w") do |repeat_file| 50 | repeat.each do |filename| 51 | repeat_file.puts filename 52 | end 53 | end 54 | 55 | return 0 56 | end 57 | 58 | def help() 59 | puts <<-EOF 60 | srs queue 61 | 62 | Queues exercises for review. 63 | EOF 64 | end 65 | end 66 | end 67 | end 68 | 69 | -------------------------------------------------------------------------------- /lib/srs/cli/reschedule.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | module SRS 4 | class CLI 5 | class Reschedule 6 | def run!(arguments) 7 | if not SRS::Workspace.initialised? then 8 | puts "Current directory is not an SRS Workspace" 9 | return 3 10 | end 11 | 12 | schedule_id = arguments.shift 13 | score = arguments.shift.to_f 14 | 15 | is_new = false; 16 | schedulefile = "schedule/#{schedule_id}" 17 | 18 | if not File.exists?(schedulefile) then 19 | schedulefile = "schedule/pending/#{schedule_id}" 20 | is_new = true 21 | if not File.exists?(schedulefile) then 22 | puts "No content with that ID exists" 23 | return 4 24 | end 25 | end 26 | 27 | headers = {} 28 | File.open(schedulefile, "r") do |file| 29 | while( line = file.gets() ) do 30 | if line.strip.empty? then 31 | break 32 | end 33 | 34 | key, *val = line.split(':').map{|e| e.strip} 35 | headers[key] = val.join(':') 36 | end 37 | end 38 | 39 | if not headers.has_key?("Scheduler") then 40 | puts "Schedule #{schedule_id} has no scheduler!\n" 41 | return 6 42 | end 43 | 44 | exercise = headers.delete("Exercise") 45 | schedulername = headers.delete("Scheduler") 46 | was_repeat = (headers["Repeat"] == "true") 47 | 48 | scheduler = getScheduler(schedulername) 49 | headersOut = is_new ? scheduler.first_rep(score) : scheduler.rep(score, headers) 50 | 51 | FileUtils.rm_rf( schedulefile ) 52 | 53 | fileOut = "schedule/#{schedule_id}" 54 | File.open(fileOut, "w") do |file| 55 | file.puts "Exercise: #{exercise}" 56 | file.puts "Scheduler: #{schedulername}" 57 | headersOut.each do |key, value| 58 | file.puts "#{key}: #{value.to_s}" 59 | end 60 | end 61 | 62 | if not was_repeat then 63 | puts "Exercise rescheduled for #{headersOut["Due"]}" 64 | else 65 | puts "Exercise passed; removed from repeats list" if not headersOut["Repeat"] 66 | end 67 | 68 | puts "Exercise failed; marked for repetition" if headersOut["Repeat"] 69 | 70 | return 0 71 | end 72 | 73 | def getScheduler(schedulername) 74 | begin 75 | require "./schedulers/#{schedulername}" 76 | rescue LoadError 77 | begin 78 | require "srs/schedulers/#{schedulername}" 79 | rescue LoadError 80 | puts "Couldn't find scheduler #{schedulername}." 81 | return nil 82 | end 83 | end 84 | 85 | SRS::Schedulers.const_get(schedulername.to_sym).new 86 | end 87 | 88 | def help() 89 | puts <<-EOF 90 | srs reschedule 91 | 92 | Rescedules the exercise being set by schedule id according to the score 93 | supplied. Makes use of the scheduler defined for that schedule. The score 94 | passed in will typically be that returned from do-exercise. 95 | EOF 96 | end 97 | end 98 | end 99 | end 100 | 101 | -------------------------------------------------------------------------------- /lib/srs/cli/schedule.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | module SRS 4 | class CLI 5 | class Schedule 6 | def initialize 7 | @options = {} 8 | @opts = OptionParser.new do |o| 9 | o.banner = <<-EOF.gsub /^\s+/, "" 10 | srs schedule [options] 11 | 12 | Schedules an exercise. 13 | EOF 14 | 15 | o.on('-s', '--scheduler SCHEDULER_NAME', 'Specifies which scheduler to use') do |s| 16 | @options[:scheduler] = s 17 | end 18 | end 19 | end 20 | 21 | def run!(arguments) 22 | if not SRS::Workspace.initialised? then 23 | puts "Current directory is not an SRS Workspace" 24 | return 3 25 | end 26 | 27 | begin 28 | @opts.parse!(arguments) 29 | @options[:exercise] = arguments.shift() 30 | rescue OptionParser::InvalidOption => e 31 | @options[:invalid_argument] = e.message 32 | end 33 | 34 | if @options[:exercise] == nil then 35 | help() 36 | return 4 37 | end 38 | 39 | if @options[:scheduler] == nil then 40 | puts "No scheduler specified." 41 | return 5 42 | end 43 | 44 | t = Time.now 45 | filename = "schedule/pending/#{t.strftime("%Y%m%d%H%M%S.%L")}" 46 | 47 | if File.exists?(filename) then 48 | puts "Cannot schedule two items within a millisecond. Try again." 49 | return 6 50 | end 51 | 52 | FileUtils::mkdir_p("schedule/pending") 53 | File.open(filename, 'w') do |f| 54 | f.puts "Exercise: #{@options[:exercise]}" 55 | f.puts "Scheduler: #{@options[:scheduler]}" 56 | end 57 | 58 | puts filename 59 | 60 | return 0 61 | end 62 | 63 | def help() 64 | puts @opts 65 | end 66 | end 67 | end 68 | end 69 | 70 | -------------------------------------------------------------------------------- /lib/srs/models/SimpleFlashcard.rb: -------------------------------------------------------------------------------- 1 | require "stringio" 2 | 3 | module SRS 4 | module Models 5 | class SimpleFlashcard 6 | def initialize() 7 | end 8 | 9 | def run(headers, metadata) 10 | data = headers.delete("Data") 11 | 12 | sha1_start = data[0..1] 13 | sha1_rest = data[2..-1] 14 | 15 | datafile = "data/#{sha1_start}/#{sha1_rest}" 16 | 17 | if not File.exists?(datafile) then 18 | puts "No content with that ID exists" 19 | return 4 20 | end 21 | 22 | self.load(datafile) 23 | 24 | score = 0.0 25 | StringIO.open(metadata) do |metadata| 26 | while( line = metadata.gets() ) do 27 | break if line.strip == "---" 28 | line.gsub!(/\[([^\]]+)\]/) { "#{@fields[$1]}" } 29 | puts line 30 | end 31 | answer = metadata.read.strip.gsub!(/\[([^\]]+)\]/) { "#{@fields[$1]}" } 32 | 33 | print "> " 34 | attempt = STDIN.gets().strip 35 | 36 | if( attempt == answer ) then 37 | puts "Correct." 38 | score = 1.0 39 | else 40 | puts answer 41 | 42 | for i in 0..0 43 | puts "Was your answer: [h] Correct, [j] Close, [k] Wrong, or [l] Very Wrong?" 44 | print "> " 45 | 46 | case STDIN.gets().strip 47 | when "h" 48 | score = 1.0 49 | when "j" 50 | score = 0.8 51 | when "k" 52 | score = 0.4 53 | when "l" 54 | score = 0.0 55 | else 56 | redo 57 | end 58 | end 59 | end 60 | end 61 | 62 | puts "You scored: " + score.to_s 63 | 64 | return score 65 | end 66 | 67 | def load(datafile) 68 | @fields = {} 69 | File.open(datafile, "r") do |file| 70 | while( line = file.gets() ) do 71 | if line.strip.empty? then 72 | break 73 | end 74 | 75 | keyval = line.split(':').map{|e| e.strip} 76 | key = keyval[0] 77 | val = keyval[1] 78 | 79 | @fields[key] = val 80 | end 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/srs/schedulers/SuperMemo2.rb: -------------------------------------------------------------------------------- 1 | # Based on the SuperMemo 2 algorithm as described here: 2 | # http://www.supermemo.com/english/ol/sm2.htm 3 | require 'date' 4 | 5 | module SRS 6 | module Schedulers 7 | class SuperMemo2 8 | DEFAULT_EF = 2.5 9 | MIN_EF = 1.3 10 | FIRST_INTERVAL = 1 11 | SECOND_INTERVAL = 6 12 | ITERATION_RESET_BOUNDARY = 3.0 / 5.0 13 | REPEAT_BOUNDARY = 4.0 / 5.0 14 | 15 | def initialize() 16 | end 17 | 18 | def first_rep(score) 19 | fields = { 20 | "Due" => (Date.today + FIRST_INTERVAL).to_time, 21 | "Repeat" => score < REPEAT_BOUNDARY ? true : false, 22 | "E-Factor" => adjust_efactor(DEFAULT_EF, score), 23 | "Interval" => FIRST_INTERVAL, 24 | "Iteration" => 1 25 | } 26 | 27 | return fields 28 | end 29 | 30 | def rep(score, fields) 31 | ef = fields["E-Factor"].to_f 32 | interval = fields["Interval"].to_i 33 | iteration = fields["Iteration"].to_i 34 | repeat = (fields["Repeat"] == "true") 35 | 36 | if not repeat then 37 | iteration = 0 if score < ITERATION_RESET_BOUNDARY 38 | case iteration 39 | when 0 40 | interval = FIRST_INTERVAL 41 | when 1 42 | interval = SECOND_INTERVAL 43 | else 44 | interval = adjust_interval(interval, ef) 45 | end 46 | 47 | ef = adjust_efactor(ef, score) 48 | end 49 | 50 | fields["Due"] = (Date.today + interval).to_time 51 | fields["Repeat"] = score < REPEAT_BOUNDARY ? true : false 52 | fields["E-Factor"] = ef 53 | fields["Interval"] = interval 54 | fields["Iteration"] = iteration + 1 55 | 56 | return fields 57 | end 58 | 59 | def adjust_efactor(ef, score) 60 | q = score * 5 61 | adjusted_efactor = ef + (0.1-(5.0 - q) * (0.08 + (5.0 - q) * 0.02)) 62 | 63 | adjusted_efactor < MIN_EF ? MIN_EF : adjusted_efactor 64 | end 65 | 66 | def adjust_interval(interval, ef) 67 | (interval * ef).round 68 | end 69 | end 70 | end 71 | end 72 | 73 | -------------------------------------------------------------------------------- /lib/srs/version.rb: -------------------------------------------------------------------------------- 1 | module SRS 2 | VERSION = "0.1.1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/srs/workspace.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | module SRS 4 | class Workspace 5 | class AlreadyInitialisedError < StandardError 6 | end 7 | class FolderNotEmptyError < StandardError 8 | end 9 | 10 | attr_reader :root, :dotsrs 11 | 12 | def initialize(dirname=".") 13 | if not SRS::Workspace.initialised?(dirname) then return nil end 14 | @root = dirname 15 | @dotsrs = File.join(dirname,'.srs') 16 | self 17 | end 18 | 19 | def self.create(dirname, force=false) 20 | dotsrs_dir = File.join(dirname,'.srs/') 21 | 22 | if( SRS::Workspace.initialised?(dirname) ) then 23 | raise AlreadyInitialisedError 24 | return nil 25 | end 26 | 27 | FileUtils.mkdir_p(dirname) 28 | 29 | if( !force ) then 30 | if( Dir.entries(dirname).length > 2 ) then 31 | raise FolderNotEmptyError 32 | return nil 33 | end 34 | end 35 | 36 | Dir.mkdir(dotsrs_dir) 37 | Dir.mkdir(File.join(dirname, "data")) 38 | 39 | return SRS::Workspace.new(dirname) 40 | end 41 | 42 | def self.initialised?(dirname=".") 43 | Dir.exists?(File.join(dirname,'.srs/')) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /srs.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("../lib/srs/version", __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'srs' 5 | s.version = SRS::VERSION 6 | s.date = '2011-07-07' 7 | s.authors = ["Daniel P. Wright"] 8 | s.email = 'dani@dpwright.com' 9 | s.homepage = 'https://github.com/dpwright/srs' 10 | s.license = 'Simplified BSD' 11 | 12 | s.summary = "A highly extensible command-line spaced repetition system" 13 | s.description = <<-EOF 14 | A Spaced Repetition System is a study tool which works by spacing out 15 | exercises so as to learn in the most efficient manner possible. 16 | 17 | srs is a command-line based implementation of the spaced repetition system. 18 | It is designed to be highly extensible and to promote the sharing of data 19 | for study by others. 20 | EOF 21 | 22 | s.files = `git ls-files`.split("\n").reject {|path| path =~ /\.gitignore$/ } 23 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 24 | s.rdoc_options = ["--charset=UTF-8"] 25 | s.require_path = "lib" 26 | end 27 | --------------------------------------------------------------------------------