├── public └── .gitkeep ├── .gitignore ├── db ├── reload-functions.sh ├── tables.sql ├── api.sql └── functions.sql ├── views ├── home.erb ├── layout.erb └── card.erb ├── web.rb ├── LICENSE └── README.md /public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | public/* 2 | !public/.gitkeep 3 | -------------------------------------------------------------------------------- /db/reload-functions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # RELOAD SQL FUNCTIONS WITHOUT LOSING DATA 3 | 4 | # Assumes you have already created database & tables: 5 | # createuser srs 6 | # createdb -O srs srs 7 | # psql -U srs -d srs -f tables.sql 8 | 9 | psql -U srs -d srs -c "drop schema srs cascade" 10 | psql -U srs -d srs -c "create schema srs" 11 | psql -U srs -d srs -c "set search_path = srs,public" 12 | psql -U srs -d srs -f functions.sql 13 | psql -U srs -d srs -f api.sql 14 | 15 | psql -U srs -d srs 16 | -------------------------------------------------------------------------------- /views/home.erb: -------------------------------------------------------------------------------- 1 |
2 | <% if @decks.size == 0 %> 3 |

All done.

4 | <% else %> 5 | 13 | <% end %> 14 |
15 | 16 |
17 |

add

18 |
19 | 22 | 25 | 28 | 29 |
30 |
31 | 36 | -------------------------------------------------------------------------------- /web.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'pg' 3 | require 'json' 4 | 5 | # SQL query-builder: 6 | # a('decks') = "select ok, js from srs.decks()" 7 | # a('review', 9, 'good') = "select ok, js from srs.review($1,$2)", [9, 'good'] 8 | # returns boolean ok, and parsed JSON 9 | class DBAPI 10 | def initialize 11 | @db = PG::Connection.new(dbname: 'srs', user: 'srs') 12 | end 13 | def a(func, *params) 14 | qs = '(%s)' % (1..params.size).map {|i| "$#{i}"}.join(',') 15 | sql = "select ok, js from srs.#{func}#{qs}" 16 | r = @db.exec_params(sql, params)[0] 17 | [ 18 | (r['ok'] == 't'), 19 | JSON.parse(r['js'], symbolize_names: true) 20 | ] 21 | end 22 | end 23 | API = DBAPI.new 24 | 25 | get '/' do 26 | ok, @decks = API.a('decks') 27 | erb :home 28 | end 29 | 30 | post '/' do 31 | API.a('add', params[:deck], params[:front], params[:back]) 32 | redirect to('/') 33 | end 34 | 35 | get '/next' do 36 | ok, @card = API.a('next', String(params[:deck])) 37 | redirect to('/') unless ok 38 | erb :card 39 | end 40 | 41 | post '/card/:id/edit' do 42 | API.a('edit', params[:id], params[:deck], params[:front], params[:back]) 43 | redirect to('/next?deck=%s' % params[:deck]) 44 | end 45 | 46 | post '/card/:id/review' do 47 | ok, c = API.a('review', params[:id], params[:rating]) 48 | redirect to('/next?deck=%s' % c[:deck]) 49 | end 50 | 51 | -------------------------------------------------------------------------------- /db/tables.sql: -------------------------------------------------------------------------------- 1 | create type public.state as enum ('new', 'learning', 'review', 'relearning'); 2 | create type public.rating as enum ('again', 'hard', 'good', 'easy'); 3 | 4 | create table public.cards ( 5 | id integer primary key generated by default as identity, 6 | deck varchar(10) not null default 'in', 7 | front text not null unique, -- content (question) 8 | back text not null, -- content (answer) 9 | state state not null default 'new', -- state of the card (new, learning, review, relearning) 10 | due timestamptz(0) not null default now() - interval '1 minute', -- when card next due for review 11 | scheduled_days integer not null default 0, -- days until card is due (interval until next scheduled) 12 | elapsed_days integer not null default 0, -- days since card was last reviewed 13 | last_review timestamptz(0), -- most recent review date/time 14 | reps integer not null default 0, -- times the card has been reviewed 15 | lapses integer not null default 0, -- times the card was forgotten or remembered incorrectly 16 | stability numeric not null default 0, -- how well the information is retained 17 | difficulty numeric not null default 0 -- inherent difficulty of the card content 18 | ); 19 | create index cardeck on cards(deck); 20 | create index cardue on cards(due); 21 | 22 | -- all functions in schema srs, so that you can ... 23 | -- drop schema srs cascade; create schema srs; 24 | -- ... to replace all functions, but not lose table data. 25 | create schema srs; 26 | set search_path = srs, public; 27 | 28 | -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SRS 7 | 28 | 29 | 30 | 31 | 32 |
SRS
33 |
34 | 35 | <%= yield %> 36 | 37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | License for Free Spaced Repetition Scheduler (FSRS) algorithm: 2 | 3 | MIT License 4 | 5 | Copyright (c) 2024 Open Spaced Repetition 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | 13 | ---------- 14 | 15 | "UNLICENSE" LICENSE FOR THE REST: 16 | 17 | This is free and unencumbered software released into the public domain. 18 | 19 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. 20 | 21 | In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | 25 | For more information, please refer to 26 | 27 | -------------------------------------------------------------------------------- /db/api.sql: -------------------------------------------------------------------------------- 1 | -- API FUNCTIONS: USE JUST THESE 2 | 3 | 4 | 5 | -- list decks with count of cards due 6 | -- use this to call next(deck) 7 | --| [{deck, count int}] 8 | create function srs.decks( 9 | out ok boolean, out js json) as $$ 10 | begin 11 | ok = true; 12 | js = coalesce((select json_agg(r) from ( 13 | select deck, count(id) 14 | from cards 15 | where due < now() 16 | or due is null 17 | group by deck 18 | order by deck 19 | ) r), '[]'); 20 | end; 21 | $$ language plpgsql; 22 | 23 | 24 | 25 | -- add a new card. deck name is whatever you want, new or existing 26 | -- front and back are HTML, so if if you need < & >, escape it yourself 27 | --| {id int} || {error} 28 | create function srs.add(_deck text, _front text, _back text, 29 | out ok boolean, out js json) as $$ 30 | declare 31 | err text; 32 | begin 33 | ok = true; 34 | with nu as ( 35 | insert into cards (deck, front, back) 36 | values ($1, $2, $3) 37 | returning id 38 | ) select row_to_json(nu.*) into js from nu; 39 | exception 40 | when others then get stacked diagnostics err = message_text; 41 | js = json_build_object('error', err); 42 | ok = false; 43 | end; 44 | $$ language plpgsql; 45 | 46 | 47 | 48 | -- get next card due in this deck, or ok=false if none due 49 | --| {id int, deck, front, back} || {error} 50 | create function srs.next(_deck text, 51 | out ok boolean, out js json) as $$ 52 | begin 53 | ok = true; 54 | js = row_to_json(r) from ( 55 | select id, deck, front, back 56 | from cards 57 | where deck = $1 58 | and due < now() 59 | or due is null 60 | order by due nulls first limit 1 61 | ) r; 62 | if js is null then 63 | ok = false; 64 | js = json_build_object('error', 'not found'); 65 | end if; 66 | end; 67 | $$ language plpgsql; 68 | 69 | 70 | 71 | -- edit a card's deck, front, or back content 72 | --| {} || {error} 73 | create function srs.edit(_id integer, _deck text, _front text, _back text, 74 | out ok boolean, out js json) as $$ 75 | declare 76 | err text; 77 | begin 78 | update cards 79 | set deck = $2, front = $3, back = $4 80 | where id = $1; 81 | ok = true; 82 | js = '{}'; 83 | exception 84 | when others then get stacked diagnostics err = message_text; 85 | js = json_build_object('error', err); 86 | ok = false; 87 | end; 88 | $$ language plpgsql; 89 | 90 | 91 | 92 | -- rate card as 'again', 'hard', 'good', or 'easy' 93 | -- returns deck of this card to be used in next(deck) 94 | --| {deck} || {error} 95 | create function srs.review(_cardid integer, rating, 96 | out ok boolean, out js json) as $$ 97 | declare 98 | err text; 99 | begin 100 | perform card_review($1, $2); 101 | ok = true; 102 | js = row_to_json(r) from ( 103 | select deck from cards where id = $1 104 | ) r; 105 | exception 106 | when others then get stacked diagnostics err = message_text; 107 | js = json_build_object('error', err); 108 | ok = false; 109 | end; 110 | $$ language plpgsql; 111 | 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spaced Repetition in PostgreSQL functions 2 | 3 | 4 | ## origin 5 | 6 | [Free Spaced Repetition Scheduler (v5)](https://github.com/open-spaced-repetition) 7 | 8 | 9 | 10 | ## this PostgreSQL version 11 | 12 | Written by [Derek Sivers](https://sive.rs/) in September 2024. 13 | I'm happy to discuss it, so [email me](https://sive.rs/contact) if you want, especially if you find errors. 14 | Take it and adapt it for you or others to use, since this implementation is meant for my own personal use, suited for just me. 15 | I gave my parts the "unlicense" so you don't need to credit me. 16 | 17 | 18 | 19 | ## install 20 | 21 | ``` 22 | cd db/ 23 | createuser srs 24 | createdb -O srs srs 25 | psql -U srs -d srs -f tables.sql 26 | sh reload-functions.sh 27 | ``` 28 | 29 | 30 | 31 | ## API (use only this) 32 | 33 | | API function | parameters | purpose | 34 | |--------------|-------|---------| 35 | | **add** | deck, front, back | create a new card with this content | 36 | | **edit** | cards.id, deck, front, back | update this card's content | 37 | | **decks** | | list how many cards due in each deck | 38 | | **next** | deck | get next due card from this deck | 39 | | **review** | cards.id, rating | after quizzing, rate card as again, hard, good, or easy | 40 | 41 | Every API function returns "ok" boolean and "js" JSON. 42 | If "ok" is false, "js" will be {"error": "explanation"} 43 | 44 | 45 | 46 | ## usage 47 | 48 | ```sql 49 | select add('pets', 'My first dog?', 'Charlie'); 50 | select add('pets', 'My last dog?', 'Snoopy'); 51 | select add('places', 'Capital of Azerbaijan?', 'Bakoo'); 52 | 53 | select edit(3, 'places', 'Capital of Azerbaijan?', 'Baku'); 54 | 55 | select decks(); 56 | 57 | select next('pets'); 58 | select review(1, 'hard'); 59 | 60 | select next('pets'); 61 | select review(2, 'easy'); 62 | ``` 63 | 64 | Front and Back content is interpreted as HTML, so put media files into public/ directory then use HTML to present them: 65 | 66 | ```sql 67 | select add('places', 68 | 'How to say this place? ', 69 | 'Paris '); 70 | ``` 71 | 72 | 73 | 74 | ## functions.sql (behind the scenes) 75 | 76 | The Free Spaced Repetition Scheduler is in functions.sql, 77 | copied from [algorithm.ts](https://github.com/open-spaced-repetition/ts-fsrs/blob/main/src/fsrs/algorithm.ts) 78 | and [basic\_scheduler.ts](https://github.com/open-spaced-repetition/ts-fsrs/blob/main/src/fsrs/impl/basic_scheduler.ts) 79 | 80 | Thank you to [the wonderful people](https://github.com/orgs/open-spaced-repetition/people) who work so hard on this. 81 | 82 | 83 | 84 | ## omitted 85 | 86 | I don't need fuzzing, undo/rollback, log analysis optimization, or differentiation between basic versus long term scheduling, so I didn't add those. 87 | I've used Anki every day for 15+ years, so I know what I do and don't need. 88 | Simplification was my mission. 89 | 90 | 91 | 92 | ## web, using Ruby 93 | 94 | ``` 95 | gem install pg 96 | gem install sinatra 97 | ruby web.rb 98 | ``` 99 | 100 | HTML in the views/ directory. 101 | 102 | / webroot in public/ is for cards' media files. 103 | 104 | 105 | 106 | ## TODO 107 | 108 | 1. media play button smarter 109 | 2. test against FSRS to make sure I got scheduler right 110 | 111 | -------------------------------------------------------------------------------- /views/card.erb: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 |
9 | <%= @card[:front] %> 10 |
11 | 12 |
13 | 14 |
15 | 16 |
17 | <%= @card[:back] %> 18 |
19 | 20 |
21 |
22 | 23 | 24 | 25 | 26 |
27 | 28 |
29 | 30 |
31 |
32 | 35 | 38 | 41 | 42 |
43 |
44 | 45 | 133 | 134 | -------------------------------------------------------------------------------- /db/functions.sql: -------------------------------------------------------------------------------- 1 | -- PRIVATE FUNCTIONS, all to serve the final one: card_review 2 | -- Don't use these. They use each other. Instead, see api.sql 3 | 4 | -- DECAY: CONSTANT: set to -0.5 5 | create function decay() returns numeric as $$ 6 | select -0.5; 7 | $$ language sql immutable; 8 | 9 | -- FACTOR: CONSTANT: set to 19/81 (0.23456790123456790123) 10 | create function factor() returns numeric as $$ 11 | select 19::numeric / 81; 12 | $$ language sql immutable; 13 | 14 | -- WEIGHTS: w(0) returns first, w(18) returns last 15 | create function w(int) returns numeric as $$ 16 | select (array[0.4072, 1.1829, 3.1262, 15.4722, 7.2102, 0.5316, 17 | 1.0651, 0.0234, 1.616, 0.1544, 1.0824, 1.9813, 0.0953, 18 | 0.2975, 2.2042, 0.2407, 2.9466, 0.5034, 0.6567])[$1 + 1]; 19 | -- PostgreSQL array indices start at 1, so retrieve by adding 1 to param 20 | $$ language sql immutable; 21 | 22 | -- CONSTANT / default 23 | create function request_retention() returns numeric as $$ 24 | select 0.9; 25 | $$ language sql immutable; 26 | 27 | -- CONSTANT / default 28 | create function maximum_interval() returns integer as $$ 29 | select 36500; 30 | $$ language sql immutable; 31 | 32 | -- convert grade/rating ('again','hard','good','easy') into integer (1,2,3,4) 33 | create function gradenum(g rating) returns integer as $$ 34 | select array_position(enum_range(null::rating), $1::rating); 35 | $$ language sql immutable; 36 | 37 | -- bind/"clamp" value $1 to be $2 minimum, $3 maximum, so $2 <= $1 <= $3 38 | -- returns updated $1 within these limits 39 | create function clamp(numeric, numeric, numeric) returns numeric as $$ 40 | select least(greatest($1, $2), $3); 41 | $$ language sql immutable; 42 | 43 | -- https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm 44 | -- LaTeX formula: 45 | -- I(r,s) = (r^{\frac{1}{DECAY}} - 1) / FACTOR \times s 46 | -- not multiplied by s here, but that's done where it's called 47 | -- param request_retention 0