18 |
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 |
27 |
28 |
29 |
30 |
31 |
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