├── .dockerignore
├── .gitlab-ci.yml
├── Dockerfile
├── LICENSE
├── README.org
├── cmd
├── leaf-server
│ └── main.go
└── leaf
│ └── main.go
├── deck.go
├── deck_manager.go
├── deck_manager_test.go
├── deck_test.go
├── ebisu.go
├── ebisu_test.go
├── fixtures
├── 200.png
├── hiragana.org
└── org-mode.org
├── go.mod
├── go.sum
├── rating.go
├── rating_test.go
├── review_session.go
├── review_session_test.go
├── screenshot.png
├── stats.go
├── stats_store.go
├── stats_store_test.go
├── stats_test.go
├── supermemo2.go
├── supermemo2_plus.go
├── supermemo2_plus_custom.go
├── supermemo2_plus_custom_test.go
├── supermemo2_plus_test.go
├── supermemo2_test.go
└── ui
├── server.go
├── server_test.go
├── state.go
├── state_test.go
├── static.go
├── static
├── .eslintignore
├── .eslintrc.js
├── babel.config.js
├── deck_list.js
├── index.html
├── main.css
├── main.js
├── package-lock.json
├── package.json
├── rater.js
├── review_session.js
├── stats_graph.js
├── stats_list.js
└── tests
│ ├── deck_list.test.js
│ ├── review_sessions.test.js
│ └── stats_list.test.js
└── tui.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | /ui/static/
2 |
3 | .git*
4 | Dockerfile
5 | .gitlab-ci.yml
6 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | stages:
2 | - test
3 | - release
4 |
5 | gotest:
6 | stage: test
7 | image: golang:1.14
8 | before_script:
9 | - curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.19.1
10 | script:
11 | - go test ./...
12 | - golangci-lint run
13 |
14 | npmtest:
15 | stage: test
16 | image: node:current
17 | before_script:
18 | - cd ui/static
19 | - npm install
20 | script:
21 | - npm test
22 | - npx eslint .
23 |
24 | build:
25 | stage: release
26 | image: docker:stable
27 | variables:
28 | BUILD_IMAGE_NAME: "$CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA"
29 | LATEST_REF_IMAGE: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:latest
30 | services:
31 | - docker:stable-dind
32 | before_script:
33 | - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
34 | script:
35 | - >-
36 | docker build
37 | --tag "$LATEST_REF_IMAGE"
38 | --tag "$BUILD_IMAGE_NAME" .
39 | - docker push "$BUILD_IMAGE_NAME"
40 | - docker push "$LATEST_REF_IMAGE"
41 | only:
42 | - master
43 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:alpine AS builder
2 |
3 | ENV GO111MODULE=on
4 |
5 | WORKDIR /app
6 |
7 | COPY . .
8 |
9 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
10 | go build -trimpath -tags netgo -ldflags '-extldflags "-static" -s -w' -o /leaf-server ./cmd/leaf-server
11 |
12 | FROM scratch
13 |
14 | COPY --from=builder /leaf-server /leaf-server
15 |
16 | EXPOSE 8000
17 |
18 | ENTRYPOINT ["/leaf-server"]
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 ap4y
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.org:
--------------------------------------------------------------------------------
1 | * Leaf
2 |
3 | *Leaf* is a flashcard app that uses [[https://en.wikipedia.org/wiki/Spaced_repetition][spaced repetition]]
4 | algorithm. *Leaf* focuses on simplifying database management, ease of
5 | access and support for various spaced repetition curves (including
6 | custom).
7 |
8 | [[https://gitlab.com/ap4y/leaf/raw/master/screenshot.png]]
9 |
10 | ** Getting started
11 |
12 | *Leaf* is a [[https://golang.org/][golang]] application and you are going to need golang
13 | toolchain to compile the app.
14 |
15 | To install or update run:
16 |
17 | #+BEGIN_SRC shell
18 | go get -u github.com/ap4y/leaf/cmd/leaf
19 | #+END_SRC
20 |
21 | or
22 |
23 | #+BEGIN_SRC shell
24 | go get -u github.com/ap4y/leaf/cmd/leaf-server
25 | #+END_SRC
26 |
27 | Leaf provides 2 different versions:
28 |
29 | - ~leaf~ is a command line utility that provides review UI in the terminal
30 | - ~leaf-server~ is a web app that implements review UI along with
31 | additional features like stats viewer.
32 |
33 | Both utilities have following configuration options:
34 |
35 | - ~-decks .~ is a path to a folder with deck files.
36 | - ~-db leaf.db~ is a location of a stats DB that contains spaced
37 | repetition variables for your decks.
38 |
39 | For ~leaf-server~ you can also adjust address to start server on via ~-addr :8000~.
40 |
41 | Terminal CLI (~leaf~) has following commands:
42 |
43 | - ~review~ will initiate review session for a deck
44 | - ~stats~ will return stats snapshots for a deck
45 |
46 | Both commands expect deck name after the command name. Full example:
47 |
48 | #+BEGIN_SRC shell
49 | ./leaf -decks ./fixtures review Hiragana
50 | #+END_SRC
51 |
52 | ** Database management
53 |
54 | *Leaf* uses plain text files structured usin [[https://orgmode.org/manual/Headlines.html#Headlines][org-mode headlines]]. Consider following file:
55 |
56 | #+BEGIN_SRC org
57 | * Sample
58 | :PROPERTIES:
59 | :RATER: auto
60 | :ALGORITHM: sm2+c
61 | :PER_REVIEW: 20
62 | :SIDES: answer
63 | :END:
64 | ** Question 1
65 | Answer 1
66 | ** Question 2
67 | Answer 2
68 | #+END_SRC
69 |
70 | Such file will be parsed as a deck named _Sample_ and it will have 2
71 | cards. For a full deck example check [[https://gitlab.com/ap4y/leaf/raw/master/fixtures/hiragana.org][hiragana]] deck.
72 |
73 | You can use text formatting, images, links and code blocks in your deck
74 | files. Check [[https://gitlab.com/ap4y/leaf/raw/master/fixtures/org-mode.org][org-mode]] deck for an overview of supported options.
75 |
76 | Top header level property drawer is used to adjust review
77 | parameters. Following parameters are supported:
78 |
79 | - ~ALGORITHM~ is a spaced repetition algorithm to use. Default is
80 | ~sm2+c~. All possible values can be found [[https://gitlab.com/ap4y/leaf/blob/master/stats.go#L35-44][here]].
81 | - ~RATER~ defines which rating system will be used for
82 | reviews. Defaults to ~auto~, supported values: ~auto~ and ~self~.
83 | - ~PER_REVIEW~ is a maximum amount of cards per review session.
84 | - ~SIDES~ is an optional field that defines names of the card sides,
85 | used in the UI for placeholders.
86 |
87 | Spaced repetition variables are stored in a separate file in a binary
88 | database. You can edit deck files at any time and changes will be
89 | automatically reflected in the web app.
90 |
91 | ** Spaced repetition algorithms
92 |
93 | *Leaf* implements multiple spaced repetition algorithms and allows you
94 | to define new ones. Following algorithms are supported as of now:
95 |
96 | - [[https://www.supermemo.com/en/archives1990-2015/english/ol/sm2][supermemo2]]
97 | - [[http://www.blueraja.com/blog/477/a-better-spaced-repetition-learning-algorithm-sm2][supermemo2+]]
98 | - Custom curve for supermemo2+. I found it works better for me.
99 | - [[https://fasiha.github.io/ebisu.js/][ebisu]]
100 |
101 | You can find calculated intervals in corresponding test files. Check
102 | [[https://gitlab.com/ap4y/leaf/blob/master/stats.go#L9-19][SRSAlgorithm]] interface to define a new algorithm or curve.
103 |
104 | Please keep in mind that algorithm variables may not be compatible
105 | with each other and algorithm switching is not supported.
106 |
107 | ** Review rating
108 |
109 | All reviews are rated using ~[0..1]~ scale. Rating higher than ~0.6~
110 | will mark review as successful. You can use 2 different types of
111 | rating systems:
112 |
113 | - ~auto~ (default) is based on amount of mistakes made during review. For ~auto~
114 | rating is assigned using [[https://gitlab.com/ap4y/leaf/blob/master/rating.go#L45-47][HarshRater]] which implements steep curve and
115 | a single mistake will have score less than ~0.6~. Check [[https://gitlab.com/ap4y/leaf/blob/master/rating.go#L34-36][Rater]]
116 | interface to get understanding how to define a different rater
117 | curve.
118 |
119 | - ~self~ is a self assessment system. You have to assign score for
120 | each review and score will be converted to a rating as such: ~hard =
121 | 0.2~, ~good = 0.6~, ~easy = 1.0~, ~again~ will push card back into
122 | the review queue.
123 |
124 | To change rating system for a deck define org-mode property ~RATER~ in
125 | your deck file.
126 |
--------------------------------------------------------------------------------
/cmd/leaf-server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "log"
6 | "net/http"
7 |
8 | "github.com/ap4y/leaf"
9 | "github.com/ap4y/leaf/ui"
10 | )
11 |
12 | var (
13 | decks = flag.String("decks", ".", "deck files location")
14 | db = flag.String("db", "leaf.db", "stats database location")
15 | addr = flag.String("addr", ":8000", "addr for Web UI")
16 | devMode = flag.Bool("dev", false, "use local dev assets")
17 | )
18 |
19 | func main() {
20 | flag.Parse()
21 |
22 | db, err := leaf.OpenBoltStore(*db)
23 | if err != nil {
24 | log.Fatal("Failed to open stats DB: ", err)
25 | }
26 |
27 | defer db.Close()
28 |
29 | dm, err := leaf.NewDeckManager(*decks, db, leaf.OutputFormatHTML)
30 | if err != nil {
31 | log.Fatal("Failed to initialise deck manager: ", err)
32 | }
33 |
34 | srv := ui.NewServer(dm)
35 | handler := srv.Handler(*devMode)
36 | fs := http.FileServer(http.Dir(*decks))
37 | handler.Handle("/images/", http.StripPrefix("/images", fs))
38 |
39 | log.Println("Serving HTTP on", *addr)
40 | if err := http.ListenAndServe(*addr, handler); err != nil {
41 | log.Fatal("Failed to render: ", err)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/cmd/leaf/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "flag"
6 | "fmt"
7 | "log"
8 | "os"
9 | "text/tabwriter"
10 |
11 | "github.com/ap4y/leaf"
12 | "github.com/ap4y/leaf/ui"
13 | termbox "github.com/nsf/termbox-go"
14 | )
15 |
16 | var (
17 | decks = flag.String("decks", ".", "deck files location")
18 | db = flag.String("db", "leaf.db", "stats database location")
19 | )
20 |
21 | func main() {
22 | flag.Usage = func() {
23 | fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [args] [stats|review] [deck_name]\n", os.Args[0])
24 | fmt.Fprintf(flag.CommandLine.Output(), "Example: %s -decks ./fixtures review Hiragana\n", os.Args[0])
25 | fmt.Fprintln(flag.CommandLine.Output(), "Optional arguments:")
26 | flag.PrintDefaults()
27 | }
28 | flag.Parse()
29 |
30 | deckName := flag.Arg(1)
31 | if deckName == "" {
32 | log.Fatal("Missing deck name")
33 | }
34 |
35 | db, err := leaf.OpenBoltStore(*db)
36 | if err != nil {
37 | log.Fatal("Failed to open stats DB: ", err)
38 | }
39 |
40 | defer db.Close()
41 |
42 | dm, err := leaf.NewDeckManager(*decks, db, leaf.OutputFormatOrg)
43 | if err != nil {
44 | log.Fatal("Failed to initialise deck manager: ", err)
45 | }
46 |
47 | switch flag.Arg(0) {
48 | case "stats":
49 | stats, err := dm.DeckStats(deckName)
50 | if err != nil {
51 | log.Fatal("Failed to get card stats: ", err)
52 | }
53 |
54 | w := tabwriter.NewWriter(os.Stdout, 5, 5, 5, ' ', 0)
55 | fmt.Fprintln(w, "Card\tStats")
56 | for _, s := range stats {
57 | stat, err := json.Marshal(s)
58 | if err != nil {
59 | continue
60 | }
61 | fmt.Fprintf(w, "%s\t%s\n", s.Question, stat)
62 | }
63 | w.Flush()
64 | case "review":
65 | session, err := dm.ReviewSession(deckName)
66 | if err != nil {
67 | log.Fatal("Failed to create review session: ", err)
68 | }
69 |
70 | if err := termbox.Init(); err != nil {
71 | log.Fatal("Failed to initialise tui: ", err)
72 | }
73 | defer termbox.Close()
74 |
75 | u := ui.NewTUI(deckName)
76 |
77 | if err := u.Render(ui.NewSessionState(session)); err != nil {
78 | log.Fatal("Failed to render: ", err)
79 | }
80 | default:
81 | log.Fatal("unknown command")
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/deck.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "strconv"
7 | "strings"
8 | "time"
9 |
10 | "github.com/niklasfasching/go-org/org"
11 | )
12 |
13 | // OutputFormat defines output type produces during org file parsing.
14 | type OutputFormat int
15 |
16 | const (
17 | // OutputFormatOrg defines pretty printed org output.
18 | OutputFormatOrg OutputFormat = iota
19 | // OutputFormatHTML defines html output.
20 | OutputFormatHTML
21 |
22 | // DeckPropertyRater defines deck's org-mode property for the rater
23 | DeckPropertyRater = "RATER"
24 | // DeckPropertyAlgorithm defines deck's org-mode property for the
25 | // algorithm
26 | DeckPropertyAlgorithm = "ALGORITHM"
27 | // DeckPropertyPerReview defines deck's org-mode property for the
28 | // per review amount
29 | DeckPropertyPerReview = "PER_REVIEW"
30 | // DeckPropertySides defines deck's org-mode property for the side
31 | // names
32 | DeckPropertySides = "SIDES"
33 | )
34 |
35 | // Card represents a single card in a Deck. Each card may have
36 | // multiple sides (answers).
37 | type Card struct {
38 | Question string `json:"card"`
39 | RawQuestion string `json:"raw_card"`
40 | Sides []string `json:"-"`
41 | }
42 |
43 | // Answer returns combined space separated answer for all sides of the card.
44 | func (c Card) Answer() string {
45 | return strings.Join(c.Sides, " ")
46 | }
47 |
48 | // Deck represents a named collection of the cards to review.
49 | type Deck struct {
50 | Name string
51 | Cards []Card
52 | Algorithm SRS
53 | RatingType RatingType
54 | PerReview int
55 | Sides []string
56 |
57 | format OutputFormat
58 | modtime time.Time
59 | filename string
60 | }
61 |
62 | // OpenDeck loads deck from an org file. File format is:
63 | // * Deck Name
64 | // ** Question
65 | // side 1
66 | // side 2
67 | func OpenDeck(filename string, format OutputFormat) (*Deck, error) {
68 | f, err := os.Open(filename)
69 | if err != nil {
70 | return nil, fmt.Errorf("file: %s", err)
71 | }
72 |
73 | stat, err := f.Stat()
74 | if err != nil {
75 | return nil, fmt.Errorf("file: %s", err)
76 | }
77 |
78 | deck := &Deck{modtime: stat.ModTime(), filename: filename, format: format}
79 | if err := deck.load(f); err != nil {
80 | return nil, err
81 | }
82 |
83 | return deck, nil
84 | }
85 |
86 | // Reload compares ModTime on deck file and reloads cards if necessary.
87 | func (deck *Deck) Reload() error {
88 | stat, err := os.Stat(deck.filename)
89 | if err != nil {
90 | return fmt.Errorf("file: %s", err)
91 | }
92 |
93 | if deck.modtime.UnixNano() >= stat.ModTime().UnixNano() {
94 | return nil
95 | }
96 |
97 | f, err := os.Open(deck.filename)
98 | if err != nil {
99 | return fmt.Errorf("file: %s", err)
100 | }
101 |
102 | if err := deck.load(f); err != nil {
103 | return err
104 | }
105 |
106 | deck.modtime = stat.ModTime()
107 | return nil
108 | }
109 |
110 | func (deck *Deck) load(f *os.File) error {
111 | doc := org.New().Parse(f, "./")
112 | if len(doc.Nodes) == 0 {
113 | return fmt.Errorf("empty or invalid org-file")
114 | }
115 |
116 | root, ok := doc.Nodes[0].(org.Headline)
117 | if !ok {
118 | return fmt.Errorf("org-file doesn't start with a headline")
119 | }
120 | deck.Name = org.String(root.Title)
121 | deck.Cards = make([]Card, 0, len(root.Children))
122 | deck.Algorithm = SRSSupermemo2PlusCustom
123 | deck.RatingType = RatingTypeAuto
124 | deck.PerReview = 20
125 | if root.Properties != nil {
126 | if rater, success := root.Properties.Get(DeckPropertyRater); success {
127 | deck.RatingType = RatingType(rater)
128 | }
129 | if algo, success := root.Properties.Get(DeckPropertyAlgorithm); success {
130 | deck.Algorithm = SRS(algo)
131 | }
132 | if count, success := root.Properties.Get(DeckPropertyPerReview); success {
133 | if c, err := strconv.Atoi(count); err == nil {
134 | deck.PerReview = c
135 | }
136 | }
137 | if sides, success := root.Properties.Get(DeckPropertySides); success {
138 | deck.Sides = strings.Fields(sides)
139 | }
140 | }
141 |
142 | for _, node := range root.Children {
143 | headline, ok := node.(org.Headline)
144 | if !ok || len(headline.Children) == 0 {
145 | continue
146 | }
147 |
148 | var w org.Writer
149 | if deck.format == OutputFormatHTML {
150 | w = org.NewHTMLWriter()
151 | } else {
152 | w = org.NewOrgWriter()
153 | }
154 |
155 | org.WriteNodes(w, headline.Title...)
156 |
157 | var answers string
158 | if block, ok := headline.Children[0].(org.Block); ok && block.Name == "SRC" {
159 | org.WriteNodes(w, block)
160 | answers = strings.TrimSpace(org.String(headline.Children[1:]))
161 | } else {
162 | answers = strings.TrimSpace(org.String(headline.Children))
163 | }
164 |
165 | card := Card{w.String(), org.String(headline.Title), strings.Split(answers, "\n")}
166 | deck.Cards = append(deck.Cards, card)
167 | }
168 |
169 | return nil
170 | }
171 |
--------------------------------------------------------------------------------
/deck_manager.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "errors"
5 | "path/filepath"
6 | "sort"
7 | "time"
8 | )
9 |
10 | // ErrNotFound represents error returned for requests for non-existing deck.
11 | var ErrNotFound = errors.New("deck not found")
12 |
13 | // DeckStats stores overview stats for a Deck.
14 | type DeckStats struct {
15 | Name string `json:"name"`
16 | CardsReady int `json:"cards_ready"`
17 | NextReviewAt time.Time `json:"next_review_at"`
18 | }
19 |
20 | // DeckManager manages set of decks.
21 | type DeckManager struct {
22 | db StatsStore
23 | decks []*Deck
24 | }
25 |
26 | // NewDeckManager constructs a new DeckManager by reading all decks
27 | // from a given folder using provided store.
28 | func NewDeckManager(path string, db StatsStore, outFormat OutputFormat) (*DeckManager, error) {
29 | files, err := filepath.Glob(path + "/*.org")
30 | if err != nil {
31 | return nil, err
32 | }
33 |
34 | decks := make([]*Deck, 0, len(files))
35 | for _, file := range files {
36 | deck, err := OpenDeck(file, outFormat)
37 | if err != nil {
38 | return nil, err
39 | }
40 | decks = append(decks, deck)
41 | }
42 |
43 | return &DeckManager{db, decks}, nil
44 | }
45 |
46 | // ReviewDecks returns stats for available decks.
47 | func (dm DeckManager) ReviewDecks() ([]DeckStats, error) {
48 | result := make([]DeckStats, 0, len(dm.decks))
49 | for _, deck := range dm.decks {
50 | nextReviewAt, reviewDeck, err := dm.reviewDeck(deck, -1)
51 | if err != nil {
52 | return nil, err
53 | }
54 |
55 | result = append(result, DeckStats{deck.Name, len(reviewDeck), nextReviewAt})
56 | }
57 |
58 | return result, nil
59 | }
60 |
61 | // ReviewSession initiates a new ReviewSession for a given deck name.
62 | func (dm DeckManager) ReviewSession(deckName string) (*ReviewSession, error) {
63 | var deck *Deck
64 | for _, d := range dm.decks {
65 | if d.Name == deckName {
66 | deck = d
67 | break
68 | }
69 | }
70 |
71 | if deck == nil {
72 | return nil, ErrNotFound
73 | }
74 |
75 | _, cards, err := dm.reviewDeck(deck, deck.PerReview)
76 | if err != nil {
77 | return nil, err
78 | }
79 |
80 | return NewReviewSession(cards, deck.Sides, deck.RatingType, func(card *CardWithStats) error {
81 | return dm.db.SaveStats(deckName, card.Question, card.Stats)
82 | }), nil
83 | }
84 |
85 | // DeckStats returns card stats for a given deck name.
86 | func (dm DeckManager) DeckStats(deckName string) ([]CardWithStats, error) {
87 | var deck *Deck
88 | for _, d := range dm.decks {
89 | if d.Name == deckName {
90 | deck = d
91 | break
92 | }
93 | }
94 |
95 | if deck == nil {
96 | return nil, ErrNotFound
97 | }
98 |
99 | return dm.deckStats(deck)
100 | }
101 |
102 | func (dm DeckManager) deckStats(deck *Deck) ([]CardWithStats, error) {
103 | stats := make(map[string]*Stats)
104 | err := dm.db.RangeStats(deck.Name, deck.Algorithm, func(card string, s *Stats) bool {
105 | stats[card] = s
106 | return true
107 | })
108 | if err != nil {
109 | return nil, err
110 | }
111 |
112 | result := make([]CardWithStats, 0, len(deck.Cards))
113 | for _, card := range deck.Cards {
114 | if stats[card.Question] != nil {
115 | result = append(result, CardWithStats{card, stats[card.Question]})
116 | } else {
117 | result = append(result, CardWithStats{card, NewStats(deck.Algorithm)})
118 | }
119 | }
120 |
121 | return result, nil
122 | }
123 |
124 | func (dm DeckManager) reviewDeck(deck *Deck, total int) (nextReviewAt time.Time, cards []CardWithStats, err error) {
125 | if fErr := deck.Reload(); fErr != nil {
126 | err = fErr
127 | return
128 | }
129 |
130 | stats, sErr := dm.deckStats(deck)
131 | if sErr != nil {
132 | err = sErr
133 | return
134 | }
135 |
136 | sort.Slice(stats, func(i, j int) bool {
137 | return stats[j].SRSAlgorithm.Less(stats[i].Stats.SRSAlgorithm)
138 | })
139 |
140 | if len(stats) > 0 {
141 | nextReviewAt = stats[0].NextReviewAt()
142 | }
143 |
144 | cards = make([]CardWithStats, 0, len(stats))
145 | for _, s := range stats {
146 | if total > 0 && len(cards) == total {
147 | break
148 | }
149 |
150 | if !s.IsReady() {
151 | continue
152 | }
153 |
154 | cards = append(cards, s)
155 | }
156 |
157 | return
158 | }
159 |
--------------------------------------------------------------------------------
/deck_manager_test.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "io/ioutil"
5 | "os"
6 | "testing"
7 | "time"
8 |
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestDeckManager(t *testing.T) {
14 | tmpfile, err := ioutil.TempFile("", "leaf.db")
15 | require.NoError(t, err)
16 | defer os.Remove(tmpfile.Name())
17 |
18 | db, err := OpenBoltStore(tmpfile.Name())
19 | require.NoError(t, err)
20 |
21 | dm, err := NewDeckManager("./fixtures", db, OutputFormatOrg)
22 | require.NoError(t, err)
23 |
24 | t.Run("ReviewDecks", func(t *testing.T) {
25 | decks, err := dm.ReviewDecks()
26 | require.NoError(t, err)
27 | require.Len(t, decks, 2)
28 |
29 | deck := decks[0]
30 | assert.Equal(t, "Hiragana", deck.Name)
31 | assert.Equal(t, 46, deck.CardsReady)
32 | assert.InDelta(t, time.Since(deck.NextReviewAt), 0, float64(time.Minute))
33 | })
34 |
35 | t.Run("ReviewSession", func(t *testing.T) {
36 | session, err := dm.ReviewSession("Hiragana")
37 | require.NoError(t, err)
38 | assert.Equal(t, 20, session.Total())
39 | assert.Equal(t, []string{"for-tests"}, session.Sides())
40 |
41 | question := session.Next()
42 | require.NoError(t, session.Again())
43 | err = db.RangeStats("Hiragana", SRSSupermemo2PlusCustom, func(card string, s *Stats) bool {
44 | if card != question {
45 | return true
46 | }
47 |
48 | sm := s.SRSAlgorithm.(*Supermemo2PlusCustom)
49 | assert.InDelta(t, 0.45, sm.Difficulty, 0.01)
50 | assert.InDelta(t, 0.2, sm.Interval, 0.01)
51 | return false
52 | })
53 |
54 | require.NoError(t, err)
55 | })
56 |
57 | t.Run("DeckStats", func(t *testing.T) {
58 | stats, err := dm.DeckStats("Hiragana")
59 | require.NoError(t, err)
60 | assert.Len(t, stats, 46)
61 |
62 | s := stats[0]
63 | assert.NotEmpty(t, s.Question)
64 | sm := s.SRSAlgorithm.(*Supermemo2PlusCustom)
65 | assert.InDelta(t, 0.3, sm.Difficulty, 0.01)
66 | })
67 | }
68 |
--------------------------------------------------------------------------------
/deck_test.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "io/ioutil"
5 | "os"
6 | "testing"
7 | "time"
8 |
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestDeck(t *testing.T) {
14 | t.Run("OpenDeck", func(t *testing.T) {
15 | deck, err := OpenDeck("./fixtures/hiragana.org", OutputFormatOrg)
16 | require.NoError(t, err)
17 | assert.Equal(t, "Hiragana", deck.Name)
18 | assert.Equal(t, RatingTypeAuto, deck.RatingType)
19 | assert.Equal(t, SRSSupermemo2PlusCustom, string(deck.Algorithm))
20 | assert.Equal(t, 20, deck.PerReview)
21 | assert.Equal(t, []string{"for-tests"}, deck.Sides)
22 | require.Len(t, deck.Cards, 46)
23 |
24 | cards := deck.Cards
25 | assert.Equal(t, "あ", cards[0].Question)
26 | assert.Equal(t, "a", cards[0].Answer())
27 | })
28 |
29 | t.Run("OpenRichDeck", func(t *testing.T) {
30 | deck, err := OpenDeck("./fixtures/org-mode.org", OutputFormatHTML)
31 | require.NoError(t, err)
32 | assert.Equal(t, "Org-mode", deck.Name)
33 | assert.Equal(t, RatingTypeSelf, deck.RatingType)
34 | assert.Equal(t, SRSEbisu, string(deck.Algorithm))
35 | assert.Equal(t, 40, deck.PerReview)
36 | assert.Equal(t, []string{"org-sample", "something-else"}, deck.Sides)
37 | require.Len(t, deck.Cards, 10)
38 |
39 | cards := deck.Cards
40 | assert.Equal(t, "emphasis", cards[0].Question)
41 | assert.Equal(t, "/emphasis/", cards[0].RawQuestion)
42 | assert.Equal(t, "/emphasis/ side2", cards[0].Answer())
43 | assert.Equal(
44 | t,
45 | "Code sample
\n
\n
\nconst foo = "test"\n
\n
\n
\n",
46 | cards[9].Question,
47 | )
48 | assert.Equal(t, "Code sample", cards[9].RawQuestion)
49 | assert.Equal(t, "const foo = \"test\"", cards[9].Answer())
50 | })
51 |
52 | t.Run("Reload", func(t *testing.T) {
53 | deckfile, err := ioutil.TempFile("", "deck.org")
54 | require.NoError(t, err)
55 | defer os.Remove(deckfile.Name())
56 |
57 | _, err = deckfile.Write([]byte("* Test\n** foo\nbar\n"))
58 | require.NoError(t, err)
59 | require.NoError(t, deckfile.Sync())
60 |
61 | deck, err := OpenDeck(deckfile.Name(), OutputFormatOrg)
62 | require.NoError(t, err)
63 | require.Len(t, deck.Cards, 1)
64 |
65 | require.NoError(t, deck.Reload())
66 | require.Len(t, deck.Cards, 1)
67 |
68 | time.Sleep(time.Second)
69 | _, err = deckfile.Write([]byte("** bar\nbaz\n"))
70 | require.NoError(t, err)
71 | require.NoError(t, deckfile.Close())
72 |
73 | require.NoError(t, deck.Reload())
74 | require.Len(t, deck.Cards, 2)
75 | })
76 | }
77 |
--------------------------------------------------------------------------------
/ebisu.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "encoding/json"
5 | "math"
6 | "time"
7 | )
8 |
9 | type model struct {
10 | Alpha float64
11 | Beta float64
12 | T float64
13 | }
14 |
15 | // Ebisu implements ebisu SSR algorithm.
16 | type Ebisu struct {
17 | LastReviewedAt time.Time
18 | Alpha float64
19 | Beta float64
20 | Interval float64
21 | Historical []IntervalSnapshot
22 | }
23 |
24 | // NewEbisu consturcts a new Ebisu instance.
25 | func NewEbisu() *Ebisu {
26 | return &Ebisu{time.Now().Add(-24 * time.Hour), 3, 3, 24, make([]IntervalSnapshot, 0)}
27 | }
28 |
29 | // NextReviewAt returns next review timestamp for a card.
30 | func (eb *Ebisu) NextReviewAt() time.Time {
31 | return eb.LastReviewedAt.Add(time.Duration(eb.Interval) * time.Hour)
32 | }
33 |
34 | // Less defines card order for the review.
35 | func (eb *Ebisu) Less(other SRSAlgorithm) bool {
36 | return eb.predictRecall() > other.(*Ebisu).predictRecall()
37 | }
38 |
39 | // Advance advances supermemo state for a card.
40 | func (eb *Ebisu) Advance(rating float64) (interval float64) {
41 | model := &model{eb.Alpha, eb.Beta, eb.Interval}
42 | elapsed := float64(time.Since(eb.LastReviewedAt)) / float64(time.Hour)
43 | proposed := updateRecall(model, rating >= ratingSuccess, float64(elapsed), true, eb.Interval)
44 |
45 | eb.Historical = append(
46 | eb.Historical,
47 | IntervalSnapshot{time.Now().Unix(), eb.Interval, 0},
48 | )
49 | eb.Alpha = proposed.Alpha
50 | eb.Beta = proposed.Beta
51 | eb.Interval = proposed.T
52 | eb.LastReviewedAt = time.Now()
53 | return eb.Interval
54 | }
55 |
56 | // MarshalJSON implements json.Marshaller for Ebisu
57 | func (eb *Ebisu) MarshalJSON() ([]byte, error) {
58 | return json.Marshal(&struct {
59 | LastReviewedAt time.Time
60 | Alpha float64
61 | Beta float64
62 | Interval float64
63 | Historical []IntervalSnapshot
64 | }{eb.LastReviewedAt, eb.Alpha, eb.Beta, eb.Interval, eb.Historical})
65 | }
66 |
67 | // UnmarshalJSON implements json.Unmarshaller for Ebisu
68 | func (eb *Ebisu) UnmarshalJSON(b []byte) error {
69 | payload := &struct {
70 | LastReviewedAt time.Time
71 | Alpha float64
72 | Beta float64
73 | Interval float64
74 | Historical []IntervalSnapshot
75 | }{}
76 |
77 | if err := json.Unmarshal(b, payload); err != nil {
78 | return err
79 | }
80 |
81 | eb.LastReviewedAt = payload.LastReviewedAt
82 | eb.Alpha = payload.Alpha
83 | eb.Beta = payload.Beta
84 | eb.Interval = payload.Interval
85 | eb.Historical = payload.Historical
86 | return nil
87 | }
88 |
89 | func (eb *Ebisu) predictRecall() float64 {
90 | tnow := float64(time.Since(eb.LastReviewedAt)) / float64(time.Hour)
91 | dt := tnow / eb.Interval
92 | ret := betaln(eb.Alpha+dt, eb.Beta) - betaln(eb.Alpha, eb.Beta)
93 | return math.Exp(ret)
94 | }
95 |
96 | func rebalanceModel(prior *model, result bool, tnow float64, proposed *model) *model {
97 | if proposed.Alpha > 2*proposed.Beta || proposed.Beta > 2*proposed.Alpha {
98 | roughHalflife := modelToPercentileDecay(proposed, 0.5)
99 | return updateRecall(prior, result, tnow, false, roughHalflife)
100 | }
101 |
102 | return proposed
103 | }
104 |
105 | func updateRecall(prior *model, result bool, tnow float64, rebalance bool, tback float64) *model {
106 | dt := tnow / prior.T
107 | et := tnow / tback
108 |
109 | var sig2, mean float64
110 | if result {
111 | if tback == prior.T {
112 | proposed := &model{prior.Alpha + dt, prior.Beta, prior.T}
113 | if rebalance {
114 | return rebalanceModel(prior, result, tnow, proposed)
115 | }
116 |
117 | return proposed
118 | }
119 |
120 | logDenominator := betaln(prior.Alpha+dt, prior.Beta)
121 | logmean := betaln(prior.Alpha+dt/et*(1+et), prior.Beta) - logDenominator
122 | logm2 := betaln(prior.Alpha+dt/et*(2+et), prior.Beta) - logDenominator
123 | mean = math.Exp(logmean)
124 | sig2 = subexp(logm2, 2*logmean)
125 | } else {
126 | logDenominator := logsumexp(
127 | [2]float64{betaln(prior.Alpha, prior.Beta), betaln(prior.Alpha+dt, prior.Beta)},
128 | [2]float64{1, -1},
129 | )
130 | mean = subexp(
131 | betaln(prior.Alpha+dt/et, prior.Beta)-logDenominator,
132 | betaln(prior.Alpha+(dt/et)*(et+1), prior.Beta)-logDenominator,
133 | )
134 | m2 := subexp(
135 | betaln(prior.Alpha+2*dt/et, prior.Beta)-logDenominator,
136 | betaln(prior.Alpha+dt/et*(et+2), prior.Beta)-logDenominator,
137 | )
138 |
139 | if m2 <= 0 {
140 | panic("invalid second moment found")
141 | }
142 |
143 | sig2 = m2 - math.Pow(mean, 2)
144 | }
145 |
146 | if mean <= 0 {
147 | panic("invalid mean found")
148 | }
149 | if sig2 <= 0 {
150 | panic("invalid variance found")
151 | }
152 |
153 | newAlpha, newBeta := meanVarToBeta(mean, sig2)
154 | proposed := &model{newAlpha, newBeta, tback}
155 | if rebalance {
156 | return rebalanceModel(prior, result, tnow, proposed)
157 | }
158 |
159 | return proposed
160 | }
161 |
162 | func modelToPercentileDecay(model *model, percentile float64) float64 {
163 | if percentile < 0 || percentile > 1 {
164 | panic("percentiles must be between (0, 1) exclusive")
165 | }
166 | alpha := model.Alpha
167 | beta := model.Beta
168 | t0 := model.T
169 |
170 | logBab := betaln(alpha, beta)
171 | logPercentile := math.Log(percentile)
172 | f := func(lndelta float64) float64 {
173 | logMean := betaln(alpha+math.Exp(lndelta), beta) - logBab
174 | return logMean - logPercentile
175 | }
176 |
177 | bracketWidth := 1.0
178 | blow := -bracketWidth / 2.0
179 | bhigh := bracketWidth / 2.0
180 | flow := f(blow)
181 | fhigh := f(bhigh)
182 | for {
183 | if flow < 0 || fhigh < 0 {
184 | break
185 | }
186 |
187 | // Move the bracket up.
188 | blow = bhigh
189 | flow = fhigh
190 | bhigh += bracketWidth
191 | fhigh = f(bhigh)
192 | }
193 |
194 | for {
195 | if flow > 0 || fhigh > 0 {
196 | break
197 | }
198 |
199 | // Move the bracket down.
200 | bhigh = blow
201 | fhigh = flow
202 | blow -= bracketWidth
203 | flow = f(blow)
204 | }
205 |
206 | if !(flow > 0 && fhigh < 0) {
207 | panic("failed to bracket")
208 | }
209 |
210 | return (math.Exp(blow) + math.Exp(bhigh)) / 2 * t0
211 | }
212 |
213 | func meanVarToBeta(mean, v float64) (float64, float64) {
214 | tmp := mean*(1-mean)/v - 1
215 | return mean * tmp, (1 - mean) * tmp
216 | }
217 |
218 | func subexp(x, y float64) float64 {
219 | maxval := math.Max(x, y)
220 | return math.Exp(maxval) * (math.Exp(x-maxval) - math.Exp(y-maxval))
221 | }
222 |
223 | func logsumexp(a, b [2]float64) float64 {
224 | aMax := math.Max(a[0], a[1])
225 | sum := b[0] * math.Exp(a[0]-aMax)
226 | sum += b[1] * math.Exp(a[1]-aMax)
227 | return math.Log(sum) + aMax
228 | }
229 |
230 | // betaln returns natural logarithm of the Beta function.
231 | func betaln(a, b float64) float64 {
232 | // B(x,y) = Γ(x)Γ(y) / Γ(x+y)
233 | // Therefore log(B(x,y)) = log(Γ(x)) + log(Γ(y)) - log(Γ(x+y))
234 | la, _ := math.Lgamma(a)
235 | lb, _ := math.Lgamma(b)
236 | lab, _ := math.Lgamma(a + b)
237 | return la + lb - lab
238 | }
239 |
--------------------------------------------------------------------------------
/ebisu_test.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "fmt"
5 | "sort"
6 | "testing"
7 | "time"
8 |
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestEbisu(t *testing.T) {
13 | cases := []struct {
14 | model [3]float64
15 | op [2]float64
16 | post [3]float64
17 | }{
18 | {[3]float64{3.3, 4.4, 1.0}, [2]float64{0.0, 0.1}, [3]float64{3.0, 5.49, 1.0}},
19 | {[3]float64{3.3, 4.4, 1.0}, [2]float64{1.0, 0.1}, [3]float64{3.4, 4.4, 1.0}},
20 | {[3]float64{3.3, 4.4, 1.0}, [2]float64{0.0, 1.0}, [3]float64{3.3, 5.4, 1.0}},
21 | {[3]float64{3.3, 4.4, 1.0}, [2]float64{1.0, 1.0}, [3]float64{4.3, 4.4, 1.0}},
22 | {[3]float64{34.4, 3.4, 1.0}, [2]float64{0.0, 1.0}, [3]float64{3.1, 4.64, 8.33}},
23 | {[3]float64{34.4, 3.4, 1.0}, [2]float64{1.0, 1.0}, [3]float64{3.47, 3.5, 8.33}},
24 | {[3]float64{34.4, 3.4, 1.0}, [2]float64{0.0, 5.5}, [3]float64{3.29, 4.59, 8.33}},
25 | {[3]float64{34.4, 3.4, 1.0}, [2]float64{1.0, 5.5}, [3]float64{3.98, 3.48, 8.33}},
26 | {[3]float64{34.4, 3.4, 1.0}, [2]float64{0.0, 50.0}, [3]float64{3.55, 3.91, 8.33}},
27 | {[3]float64{34.4, 3.4, 1.0}, [2]float64{1.0, 50.0}, [3]float64{2.89, 3.53, 22.64}},
28 | }
29 |
30 | for idx, tc := range cases {
31 | t.Run(fmt.Sprintf("Advance %d", idx), func(t *testing.T) {
32 | eb := &Ebisu{
33 | time.Now().Add(toHourDuration(-1 * tc.op[1])),
34 | tc.model[0],
35 | tc.model[1],
36 | tc.model[2],
37 | make([]IntervalSnapshot, 0),
38 | }
39 | eb.Advance(tc.op[0])
40 | assert.InDelta(t, tc.post[0], eb.Alpha, 0.01)
41 | assert.InDelta(t, tc.post[1], eb.Beta, 0.01)
42 | assert.InDelta(t, tc.post[2], eb.Interval, 0.01)
43 | })
44 | }
45 | }
46 |
47 | func TestEbisueNextReviewAt(t *testing.T) {
48 | srs := NewEbisu()
49 | assert.InDelta(t, time.Since(srs.NextReviewAt()), time.Duration(0), float64(time.Minute))
50 | interval := srs.Advance(1)
51 | assert.InDelta(t, time.Duration(interval)*time.Hour, time.Until(srs.NextReviewAt()), float64(time.Minute))
52 | }
53 |
54 | func TestEbisuRecord(t *testing.T) {
55 | results := [][]float64{
56 | {1.0, 1.0, 1.0, 0.41, 0.41, 0.41, 0.41, 0.41, 0.41},
57 | {1.0, 1.0, 3.06, 3.06, 3.06, 3.06, 3.06, 9.39, 9.39},
58 | {1.0, 1.0, 3.06, 3.06, 3.06, 3.06, 3.06, 9.39, 9.39},
59 | }
60 | for idx, rating := range []float64{0.5, 0.6, 1.0} {
61 | t.Run(fmt.Sprintf("%f", rating), func(t *testing.T) {
62 | srs := NewEbisu()
63 | intervals := []float64{}
64 | for i := 0; i < 9; i++ {
65 | interval := srs.Advance(rating)
66 | intervals = append(intervals, interval/24)
67 |
68 | curInterval := srs.Interval * float64(time.Hour)
69 | srs.LastReviewedAt = time.Now().Add(-time.Duration(curInterval))
70 | }
71 |
72 | assert.InDeltaSlice(t, results[idx], intervals, 0.01)
73 | })
74 | }
75 |
76 | t.Run("sequence", func(t *testing.T) {
77 | srs := NewEbisu()
78 | intervals := []float64{}
79 | for _, rating := range []float64{1, 1, 1, 0.5, 1, 1, 1} {
80 | interval := srs.Advance(rating)
81 | intervals = append(intervals, interval/24)
82 |
83 | curInterval := srs.Interval * float64(time.Hour)
84 | srs.LastReviewedAt = time.Now().Add(-time.Duration(curInterval))
85 | }
86 |
87 | assert.InDeltaSlice(t, []float64{1, 1, 3.06, 1.27, 1.27, 1.27, 3.89}, intervals, 0.01)
88 | })
89 | }
90 |
91 | func TestEbisuPredictRecall(t *testing.T) {
92 | eb := &Ebisu{LastReviewedAt: time.Now().Add(-1 * time.Hour), Alpha: 4, Beta: 4, Interval: 24}
93 | assert.InDelta(t, 0.96, eb.predictRecall(), 0.01)
94 |
95 | eb = &Ebisu{LastReviewedAt: time.Now().Add(-1 * time.Hour), Alpha: 2, Beta: 4, Interval: 24}
96 | assert.InDelta(t, 0.94, eb.predictRecall(), 0.01)
97 | }
98 |
99 | func TestEbisuLess(t *testing.T) {
100 | eb1 := &Ebisu{LastReviewedAt: time.Now().Add(-1 * time.Hour), Alpha: 4, Beta: 4, Interval: 24}
101 | eb2 := &Ebisu{LastReviewedAt: time.Now().Add(-1 * time.Hour), Alpha: 2, Beta: 4, Interval: 24}
102 |
103 | slice := []SRSAlgorithm{eb1, eb2}
104 | sort.Slice(slice, func(i, j int) bool { return slice[j].Less(slice[i]) })
105 | assert.Equal(t, []SRSAlgorithm{eb2, eb1}, slice)
106 | }
107 |
108 | func TestBetaln(t *testing.T) {
109 | assert.InDelta(t, -70.97, betaln(99, 30.25), 0.01)
110 | assert.InDelta(t, -28.23, betaln(13, 35), 0.01)
111 | assert.InDelta(t, -84.94, betaln(47.25, 80.5), 0.01)
112 | assert.InDelta(t, -59.9, betaln(79.75, 26.25), 0.01)
113 | assert.InDelta(t, -137.26, betaln(999, 30.25), 0.01)
114 | assert.InDelta(t, -224.85, betaln(99, 300.25), 0.01)
115 | }
116 |
117 | func toHourDuration(hours float64) time.Duration {
118 | return time.Duration(hours * float64(time.Hour))
119 | }
120 |
--------------------------------------------------------------------------------
/fixtures/200.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ap4y/leaf/f32db495286d62b5ebe80769ab8432f1a05a7711/fixtures/200.png
--------------------------------------------------------------------------------
/fixtures/hiragana.org:
--------------------------------------------------------------------------------
1 | * Hiragana
2 | :PROPERTIES:
3 | :SIDES: for-tests
4 | :END:
5 | ** あ
6 | a
7 | ** い
8 | i
9 | ** う
10 | u
11 | ** え
12 | e
13 | ** お
14 | o
15 | ** か
16 | ka
17 | ** き
18 | ki
19 | ** く
20 | ku
21 | ** け
22 | ke
23 | ** こ
24 | ko
25 | ** さ
26 | sa
27 | ** し
28 | shi
29 | ** す
30 | su
31 | ** せ
32 | se
33 | ** そ
34 | so
35 | ** た
36 | ta
37 | ** ち
38 | chi
39 | ** つ
40 | tsu
41 | ** て
42 | te
43 | ** と
44 | to
45 | ** な
46 | na
47 | ** に
48 | ni
49 | ** ぬ
50 | nu
51 | ** ね
52 | ne
53 | ** の
54 | no
55 | ** は
56 | ha
57 | ** ひ
58 | hi
59 | ** ふ
60 | fu
61 | ** へ
62 | he
63 | ** ほ
64 | ho
65 | ** ま
66 | ma
67 | ** み
68 | mi
69 | ** む
70 | mu
71 | ** め
72 | me
73 | ** も
74 | mo
75 | ** や
76 | ya
77 | ** ゆ
78 | yu
79 | ** よ
80 | yo
81 | ** ら
82 | ra
83 | ** り
84 | ri
85 | ** る
86 | ru
87 | ** れ
88 | re
89 | ** ろ
90 | ro
91 | ** わ
92 | wa
93 | ** を
94 | wo
95 | ** ん
96 | n
97 |
--------------------------------------------------------------------------------
/fixtures/org-mode.org:
--------------------------------------------------------------------------------
1 | * Org-mode
2 | :PROPERTIES:
3 | :RATER: self
4 | :ALGORITHM: ebisu
5 | :PER_REVIEW: 40
6 | :SIDES: org-sample something-else
7 | :END:
8 | ** /emphasis/
9 | /emphasis/
10 | side2
11 | ** _underlined_
12 | _underlined_
13 | ** *bold*
14 | *bold*
15 | ** =verbatim=
16 | =verbatim=
17 | ** ~code~
18 | ~code~
19 | ** +strikethrough+
20 | +strikethrough+
21 | ** [[https://example.com][example.com]]
22 | [[https://example.com][example.com]]
23 | ** [[https://placekitten.com/200/200#.png]]
24 | [[https://placekitten.com/200/200#.png]]
25 | ** [[/images/200.png]]
26 | [[/images/200.png]]
27 | ** Code sample
28 | #+BEGIN_SRC javascript
29 | const foo = "test"
30 | #+END_SRC
31 | const foo = "test"
32 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/ap4y/leaf
2 |
3 | go 1.14
4 |
5 | require (
6 | github.com/boltdb/bolt v1.3.1 // indirect
7 | github.com/mattn/go-runewidth v0.0.4
8 | github.com/niklasfasching/go-org v0.1.4
9 | github.com/nsf/termbox-go v0.0.0-20190104133558-0938b5187e61
10 | github.com/stretchr/testify v1.3.0
11 | go.etcd.io/bbolt v1.3.0
12 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect
13 | )
14 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
2 | github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
6 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
7 | github.com/niklasfasching/go-org v0.1.4 h1:nEuzptQcLsKZYPjxs/+0m+9RqMvcK+34lGDCtcQOwGQ=
8 | github.com/niklasfasching/go-org v0.1.4/go.mod h1:AsLD6X7djzRIz4/RFZu8vwRL0VGjUvGZCCH1Nz0VdrU=
9 | github.com/nsf/termbox-go v0.0.0-20190104133558-0938b5187e61 h1:pEzZYac/uQ4cgaN1Q/UYZg+ZtCSWz2HQ3rvl8MeN9MA=
10 | github.com/nsf/termbox-go v0.0.0-20190104133558-0938b5187e61/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
13 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
14 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
15 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
16 | go.etcd.io/bbolt v1.3.0 h1:oY10fI923Q5pVCVt1GBTZMn8LHo5M+RCInFpeMnV4QI=
17 | go.etcd.io/bbolt v1.3.0/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
18 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
19 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
20 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
21 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
22 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
23 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
24 |
--------------------------------------------------------------------------------
/rating.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import "math"
4 |
5 | const ratingSuccess = 0.6
6 |
7 | // RatingType defines types of review rating options.
8 | type RatingType string
9 |
10 | const (
11 | // RatingTypeAuto defines auto rated review option.
12 | RatingTypeAuto RatingType = "auto"
13 | // RatingTypeSelf defines self rated review option.
14 | RatingTypeSelf RatingType = "self"
15 | )
16 |
17 | // ReviewScore defines grade for review attempts. Rater uses scores to
18 | // calculate rating in range from [0, 1].
19 | type ReviewScore int
20 |
21 | const (
22 | // ReviewScoreAgain defines "again" score.
23 | ReviewScoreAgain ReviewScore = iota
24 | // ReviewScoreHard defines "hard" score.
25 | ReviewScoreHard
26 | // ReviewScoreGood defines "good" score.
27 | ReviewScoreGood
28 | // ReviewScoreEasy defines "easy" score.
29 | ReviewScoreEasy
30 | )
31 |
32 | // Rater rates review attempt based on amount of mistakes. Rating
33 | // should be within [0, 1] range.
34 | type Rater interface {
35 | Rate(question string, score ReviewScore) float64
36 | }
37 |
38 | type harshRater struct {
39 | mistakes map[string]int
40 | }
41 |
42 | // HarshRater returns miss count based Rater. Miss counter will
43 | // increase for each "again" score. Rating declines really fast and
44 | // even 1 mistake results in 0.59 rating.
45 | func HarshRater() Rater {
46 | return &harshRater{make(map[string]int)}
47 | }
48 |
49 | func (rater harshRater) Rate(question string, score ReviewScore) float64 {
50 | if score == ReviewScoreAgain {
51 | rater.mistakes[question]++
52 | return 0
53 | }
54 |
55 | mistakes := rater.mistakes[question]
56 | if mistakes == 0 {
57 | return 1
58 | }
59 |
60 | return math.Max(0, 0.79-float64(mistakes)/5)
61 | }
62 |
63 | type tableRater struct {
64 | }
65 |
66 | // TableRater returns Rater implementation with following the conversion table:
67 | // again => 0
68 | // hard => 0.2
69 | // good => 0.6
70 | // easy => 1.0
71 | func TableRater() Rater {
72 | return &tableRater{}
73 | }
74 |
75 | func (rater tableRater) Rate(question string, score ReviewScore) float64 {
76 | switch score {
77 | case ReviewScoreHard:
78 | return 0.2
79 | case ReviewScoreGood:
80 | return 0.6
81 | case ReviewScoreEasy:
82 | return 1.0
83 | default:
84 | return 0
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/rating_test.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestHarshRater(t *testing.T) {
11 | rater := HarshRater()
12 |
13 | assert.InDelta(t, 1.0, rater.Rate("foo", ReviewScoreEasy), 0.01)
14 |
15 | tcs := []float64{0.59, 0.39, 0.19, 0, 0}
16 | for _, tc := range tcs {
17 | t.Run(fmt.Sprintf("%f", tc), func(t *testing.T) {
18 | rater.Rate("foo", ReviewScoreAgain)
19 | assert.InDelta(t, tc, rater.Rate("foo", ReviewScoreEasy), 0.01)
20 | })
21 | }
22 | }
23 |
24 | func TestTableRater(t *testing.T) {
25 | rater := TableRater()
26 |
27 | assert.InDelta(t, 0, rater.Rate("foo", ReviewScoreAgain), 0.01)
28 | assert.InDelta(t, 0.2, rater.Rate("foo", ReviewScoreHard), 0.01)
29 | assert.InDelta(t, 0.6, rater.Rate("foo", ReviewScoreGood), 0.01)
30 | assert.InDelta(t, 1.0, rater.Rate("foo", ReviewScoreEasy), 0.01)
31 | }
32 |
--------------------------------------------------------------------------------
/review_session.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "errors"
5 | "time"
6 | )
7 |
8 | // StatsSaveFunc persists stats updates.
9 | type StatsSaveFunc func(card *CardWithStats) error
10 |
11 | // ReviewSession contains parameters for a Deck review sessions.
12 | type ReviewSession struct {
13 | statsSaver StatsSaveFunc
14 | cards []CardWithStats
15 | queue []string
16 | sides []string
17 | startedAt time.Time
18 | ratingType RatingType
19 | }
20 |
21 | // NewReviewSession constructs a new ReviewSession for a given set of cards.
22 | // Rating calculation will be performed using provided rater.
23 | // Provided StatsSaveFunc will be used for stats updates post review.
24 | func NewReviewSession(cards []CardWithStats, sides []string, rt RatingType, statsSaver StatsSaveFunc) *ReviewSession {
25 | queue := make([]string, len(cards))
26 | for idx, card := range cards {
27 | queue[idx] = card.Question
28 | }
29 |
30 | return &ReviewSession{statsSaver, cards, queue, sides, time.Now(), rt}
31 | }
32 |
33 | // Sides returns side names from the reviewed deck.
34 | func (s *ReviewSession) Sides() []string {
35 | return s.sides
36 | }
37 |
38 | // StartedAt returns start time of the review session.
39 | func (s *ReviewSession) StartedAt() time.Time {
40 | return s.startedAt
41 | }
42 |
43 | // RatingType returns type of rating to be used for the review session.
44 | func (s *ReviewSession) RatingType() RatingType {
45 | return s.ratingType
46 | }
47 |
48 | // Total returns amount of cards in the session.
49 | func (s *ReviewSession) Total() int {
50 | return len(s.cards)
51 | }
52 |
53 | // Left returns amount of cards left to review.
54 | func (s *ReviewSession) Left() int {
55 | return len(s.queue)
56 | }
57 |
58 | // Next returns current card to review. Same card will be return until
59 | // review is attempted via Answer call.
60 | func (s *ReviewSession) Next() string {
61 | if len(s.queue) == 0 {
62 | return ""
63 | }
64 |
65 | return s.queue[0]
66 | }
67 |
68 | // CorrectAnswer returns correct answer for a current reviewed card.
69 | func (s *ReviewSession) CorrectAnswer() string {
70 | card := s.currentCard()
71 | if card == nil {
72 | return ""
73 | }
74 |
75 | return card.Answer()
76 | }
77 |
78 | // Again re-queues current card back for review.
79 | func (s *ReviewSession) Again() error {
80 | card := s.currentCard()
81 | if card == nil {
82 | return errors.New("no cards in queue")
83 | }
84 |
85 | s.queue = s.queue[1:]
86 | s.queue = append(s.queue, card.Question)
87 | return nil
88 | }
89 |
90 | // Rate assign rating to a current card and removes it from the queue if rating > 0.
91 | func (s *ReviewSession) Rate(rating float64) error {
92 | card := s.currentCard()
93 | if card == nil {
94 | return errors.New("no cards in queue")
95 | }
96 |
97 | s.queue = s.queue[1:]
98 | card.Advance(rating)
99 | return s.statsSaver(card)
100 | }
101 |
102 | func (s *ReviewSession) currentCard() *CardWithStats {
103 | question := s.Next()
104 | for _, c := range s.cards {
105 | if c.Question == question {
106 | return &c
107 | }
108 | }
109 |
110 | return nil
111 | }
112 |
--------------------------------------------------------------------------------
/review_session_test.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestReviewSession(t *testing.T) {
11 | cards := []CardWithStats{
12 | {Card{"foo", "foo", []string{"bar"}}, NewStats(SRSSupermemo2PlusCustom)},
13 | {Card{"bar", "foo", []string{"baz"}}, NewStats(SRSSupermemo2PlusCustom)},
14 | }
15 |
16 | stats := make(map[string]*Stats)
17 | s := NewReviewSession(cards, []string{"answer"}, RatingTypeAuto, func(card *CardWithStats) error {
18 | stats[card.Question] = card.Stats
19 | return nil
20 | })
21 |
22 | t.Run("Sides", func(t *testing.T) {
23 | assert.Equal(t, []string{"answer"}, s.Sides())
24 | })
25 |
26 | t.Run("StartedAt", func(t *testing.T) {
27 | assert.NotNil(t, s.StartedAt())
28 | })
29 |
30 | t.Run("RatingType", func(t *testing.T) {
31 | assert.Equal(t, RatingTypeAuto, s.RatingType())
32 | })
33 |
34 | t.Run("Total", func(t *testing.T) {
35 | assert.Equal(t, 2, s.Total())
36 | })
37 |
38 | t.Run("Left", func(t *testing.T) {
39 | assert.Equal(t, 2, s.Left())
40 | })
41 |
42 | t.Run("Next", func(t *testing.T) {
43 | assert.Equal(t, "foo", s.Next())
44 | })
45 |
46 | t.Run("CorrectAnswer", func(t *testing.T) {
47 | assert.Equal(t, "bar", s.CorrectAnswer())
48 | })
49 |
50 | t.Run("Rate - incorrect", func(t *testing.T) {
51 | require.NoError(t, s.Again())
52 | assert.Equal(t, 2, s.Left())
53 | assert.Equal(t, "bar", s.Next())
54 | })
55 |
56 | t.Run("Rate - correct", func(t *testing.T) {
57 | require.NoError(t, s.Rate(1))
58 | assert.Equal(t, 1, s.Left())
59 | assert.Equal(t, "foo", s.Next())
60 | })
61 |
62 | t.Run("Rate - multiple incorrect", func(t *testing.T) {
63 | for i := 0; i < 4; i++ {
64 | require.NoError(t, s.Again())
65 | }
66 | assert.Equal(t, 1, s.Left())
67 | })
68 |
69 | t.Run("Answer - finish session", func(t *testing.T) {
70 | require.NoError(t, s.Rate(0))
71 | assert.Equal(t, 0, s.Left())
72 | })
73 |
74 | fooStats := stats["foo"].SRSAlgorithm.(*Supermemo2PlusCustom)
75 | assert.InDelta(t, 0.52, fooStats.Difficulty, 0.01)
76 | assert.InDelta(t, 0.2, fooStats.Interval, 0.01)
77 |
78 | barStats := stats["bar"].SRSAlgorithm.(*Supermemo2PlusCustom)
79 | assert.InDelta(t, 0.28, barStats.Difficulty, 0.01)
80 | assert.InDelta(t, 0.37, barStats.Interval, 0.01)
81 | }
82 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ap4y/leaf/f32db495286d62b5ebe80769ab8432f1a05a7711/screenshot.png
--------------------------------------------------------------------------------
/stats.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 | )
7 |
8 | // SRSAlgorithm calculates review intervals
9 | type SRSAlgorithm interface {
10 | json.Marshaler
11 | json.Unmarshaler
12 |
13 | // Advance advances supermemo state for a card.
14 | Advance(rating float64) (interval float64)
15 | // NextReviewAt returns next review timestamp for a card.
16 | NextReviewAt() time.Time
17 | // Less defines card order for the review.
18 | Less(other SRSAlgorithm) bool
19 | }
20 |
21 | // Stats store SM2+ parameters for a Card.
22 | type Stats struct {
23 | SRSAlgorithm
24 | }
25 |
26 | // CardWithStats joins Stats to a Card
27 | type CardWithStats struct {
28 | Card
29 | *Stats
30 | }
31 |
32 | // SRS defines supported spaced-repetiton algorithms.
33 | type SRS string
34 |
35 | const (
36 | // SRSSupermemo2 represents Supermemo2 algorithm
37 | SRSSupermemo2 SRS = "sm2"
38 | // SRSSupermemo2Plus represents Supermemo2Plus algorithm
39 | SRSSupermemo2Plus = "sm2+"
40 | // SRSSupermemo2PlusCustom represents Supermemo2PlusCustom algorithm
41 | SRSSupermemo2PlusCustom = "sm2+c"
42 | // SRSEbisu represents Ebisu algorithm
43 | SRSEbisu = "ebisu"
44 | )
45 |
46 | // NewStats returns a new Stats initialized with provided algorithm
47 | // with default values. Supported values: sm2, sm2+, sm2+c. If smAlgo
48 | // is missing or unknown will default to Supermemo2PlusCustom.
49 | func NewStats(srs SRS) *Stats {
50 | var sm SRSAlgorithm
51 | switch srs {
52 | case SRSSupermemo2:
53 | sm = NewSupermemo2()
54 | case SRSSupermemo2Plus:
55 | sm = NewSupermemo2Plus()
56 | case SRSEbisu:
57 | sm = NewEbisu()
58 | default:
59 | sm = NewSupermemo2PlusCustom()
60 | }
61 | return &Stats{sm}
62 | }
63 |
64 | // IsReady signals whether card is read for review.
65 | func (s Stats) IsReady() bool {
66 | return s.NextReviewAt().Before(time.Now())
67 | }
68 |
--------------------------------------------------------------------------------
/stats_store.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 |
8 | bolt "go.etcd.io/bbolt"
9 | )
10 |
11 | // StatsStore defines storage interface that is used for storing review stats.
12 | type StatsStore interface {
13 | io.Closer
14 | // RangeStats iterates over all stats in a Store. DB records will be boxed to provide algoritm.
15 | RangeStats(deck string, srs SRS, rangeFunc func(card string, stats *Stats) bool) error
16 | // SaveStats saves stats for a card.
17 | SaveStats(deck string, card string, stats *Stats) error
18 | }
19 |
20 | type boltStore struct {
21 | bolt *bolt.DB
22 | }
23 |
24 | // OpenBoltStore returns a new StatsStore implemented on top of BoltDB.
25 | func OpenBoltStore(filename string) (StatsStore, error) {
26 | db, err := bolt.Open(filename, 0600, nil)
27 | if err != nil {
28 | return nil, fmt.Errorf("db: %s", db)
29 | }
30 |
31 | return &boltStore{db}, nil
32 | }
33 |
34 | func (db *boltStore) RangeStats(
35 | deck string,
36 | srs SRS,
37 | rangeFunc func(card string, stats *Stats) bool,
38 | ) error {
39 | return db.bolt.Update(func(tx *bolt.Tx) error {
40 | b, err := tx.CreateBucketIfNotExists([]byte(deck))
41 | if err != nil {
42 | return err
43 | }
44 |
45 | return b.ForEach(func(card, stats []byte) error {
46 | s := NewStats(srs)
47 | if err := json.Unmarshal(stats, s); err != nil {
48 | return fmt.Errorf("json: %s", err)
49 | }
50 |
51 | if !rangeFunc(string(card), s) {
52 | return nil
53 | }
54 |
55 | return nil
56 | })
57 | })
58 | }
59 |
60 | func (db *boltStore) SaveStats(deck string, card string, stats *Stats) error {
61 | return db.bolt.Update(func(tx *bolt.Tx) error {
62 | b, err := tx.CreateBucketIfNotExists([]byte(deck))
63 | if err != nil {
64 | return err
65 | }
66 |
67 | data, err := json.Marshal(stats)
68 | if err != nil {
69 | return fmt.Errorf("json: %s", err)
70 | }
71 |
72 | return b.Put([]byte(card), data)
73 | })
74 | }
75 |
76 | func (db *boltStore) Close() error {
77 | return db.bolt.Close()
78 | }
79 |
--------------------------------------------------------------------------------
/stats_store_test.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "io/ioutil"
5 | "os"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestStatsDB(t *testing.T) {
13 | tmpfile, err := ioutil.TempFile("", "leaf.db")
14 | require.NoError(t, err)
15 | defer os.Remove(tmpfile.Name())
16 |
17 | db, err := OpenBoltStore(tmpfile.Name())
18 | require.NoError(t, err)
19 |
20 | s1 := Stats{&Supermemo2PlusCustom{Supermemo2Plus{Difficulty: 1}}}
21 | require.NoError(t, db.SaveStats("deck1", "foo", &s1))
22 |
23 | s2 := Stats{&Supermemo2PlusCustom{Supermemo2Plus{Difficulty: 2}}}
24 | require.NoError(t, db.SaveStats("deck1", "bar", &s2))
25 |
26 | s3 := Stats{&Supermemo2PlusCustom{Supermemo2Plus{Difficulty: 3}}}
27 | require.NoError(t, db.SaveStats("deck2", "foo", &s3))
28 |
29 | cards := []string{}
30 | stats := []Stats{}
31 | err = db.RangeStats("deck1", SRSSupermemo2PlusCustom, func(card string, s *Stats) bool {
32 | cards = append(cards, card)
33 | stats = append(stats, *s)
34 | return true
35 | })
36 | require.NoError(t, err)
37 |
38 | assert.Equal(t, []string{"bar", "foo"}, cards)
39 | assert.Equal(t, []Stats{s2, s1}, stats)
40 | }
41 |
--------------------------------------------------------------------------------
/stats_test.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestIsReady(t *testing.T) {
11 | s := &Stats{&Supermemo2Plus{LastReviewedAt: time.Now(), Interval: 1}}
12 | assert.False(t, s.IsReady())
13 |
14 | s = &Stats{&Supermemo2Plus{LastReviewedAt: time.Now().Add(-24 * time.Hour), Interval: 1}}
15 | assert.True(t, s.IsReady())
16 |
17 | s = NewStats(SRSSupermemo2Plus)
18 | assert.True(t, s.IsReady())
19 | s.Advance(5)
20 | assert.False(t, s.IsReady())
21 | }
22 |
--------------------------------------------------------------------------------
/supermemo2.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "encoding/json"
5 | "math"
6 | "time"
7 | )
8 |
9 | // Supermemo2 calculates review intervals using SM2 algorithm
10 | type Supermemo2 struct {
11 | LastReviewedAt time.Time
12 | Interval float64
13 | Easiness float64
14 | Correct int
15 | Total int
16 | Historical []IntervalSnapshot
17 | }
18 |
19 | // NewSupermemo2 returns a new Supermemo2 instance
20 | func NewSupermemo2() *Supermemo2 {
21 | return &Supermemo2{
22 | LastReviewedAt: time.Now(),
23 | Interval: 0,
24 | Easiness: 2.5,
25 | Correct: 0,
26 | Total: 0,
27 | }
28 | }
29 |
30 | // NextReviewAt returns next review timestamp for a card.
31 | func (sm *Supermemo2) NextReviewAt() time.Time {
32 | return sm.LastReviewedAt.Add(time.Duration(24*sm.Interval) * time.Hour)
33 | }
34 |
35 | // Less defines card order for the review.
36 | func (sm *Supermemo2) Less(other SRSAlgorithm) bool {
37 | return sm.Interval > other.(*Supermemo2).Interval
38 | }
39 |
40 | // Advance advances supermemo state for a card.
41 | func (sm *Supermemo2) Advance(rating float64) float64 {
42 | sm.Total++
43 | sm.LastReviewedAt = time.Now()
44 |
45 | sm.Easiness += 0.1 - (1-rating)*(0.4+(1-rating)*0.5)
46 | sm.Easiness = math.Max(sm.Easiness, 1.3)
47 |
48 | interval := 1.0
49 | if rating >= ratingSuccess {
50 | if sm.Total == 2 {
51 | interval = 6
52 | } else if sm.Total > 2 {
53 | interval = math.Round(sm.Interval * sm.Easiness)
54 | }
55 | sm.Correct++
56 | } else {
57 | sm.Correct = 0
58 | }
59 |
60 | if sm.Historical == nil {
61 | sm.Historical = make([]IntervalSnapshot, 0)
62 | }
63 | sm.Historical = append(
64 | sm.Historical,
65 | IntervalSnapshot{time.Now().Unix(), sm.Interval, sm.Easiness},
66 | )
67 |
68 | sm.Interval = interval
69 | return interval
70 | }
71 |
72 | // MarshalJSON implements json.Marshaller for Supermemo2
73 | func (sm *Supermemo2) MarshalJSON() ([]byte, error) {
74 | return json.Marshal(&struct {
75 | LastReviewedAt time.Time
76 | Interval float64
77 | Easiness float64
78 | Correct int
79 | Total int
80 | Historical []IntervalSnapshot
81 | }{sm.LastReviewedAt, sm.Interval, sm.Easiness, sm.Correct, sm.Total, sm.Historical})
82 | }
83 |
84 | // UnmarshalJSON implements json.Unmarshaller for Supermemo2
85 | func (sm *Supermemo2) UnmarshalJSON(b []byte) error {
86 | payload := &struct {
87 | LastReviewedAt time.Time
88 | Interval float64
89 | Easiness float64
90 | Correct int
91 | Total int
92 | Historical []IntervalSnapshot
93 | }{}
94 |
95 | if err := json.Unmarshal(b, payload); err != nil {
96 | return err
97 | }
98 |
99 | sm.LastReviewedAt = payload.LastReviewedAt
100 | sm.Easiness = payload.Easiness
101 | sm.Interval = payload.Interval
102 | sm.Correct = payload.Correct
103 | sm.Total = payload.Total
104 | sm.Historical = payload.Historical
105 | return nil
106 | }
107 |
--------------------------------------------------------------------------------
/supermemo2_plus.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "encoding/json"
5 | "math"
6 | "time"
7 | )
8 |
9 | // IntervalSnapshot records historical changes of the Interval.
10 | type IntervalSnapshot struct {
11 | Timestamp int64 `json:"ts"`
12 | Interval float64 `json:"interval"`
13 | Factor float64 `json:"factor"`
14 | }
15 |
16 | // Supermemo2Plus calculates review intervals using SM2+ algorithm
17 | type Supermemo2Plus struct {
18 | LastReviewedAt time.Time
19 | Difficulty float64
20 | Interval float64
21 | Historical []IntervalSnapshot
22 | }
23 |
24 | // NewSupermemo2Plus returns a new Supermemo2Plus instance
25 | func NewSupermemo2Plus() *Supermemo2Plus {
26 | return &Supermemo2Plus{
27 | LastReviewedAt: time.Now().Add(-4 * time.Hour),
28 | Difficulty: 0.3,
29 | Interval: 0.2,
30 | Historical: make([]IntervalSnapshot, 0),
31 | }
32 | }
33 |
34 | // NextReviewAt returns next review timestamp for a card.
35 | func (sm *Supermemo2Plus) NextReviewAt() time.Time {
36 | return sm.LastReviewedAt.Add(time.Duration(24*sm.Interval) * time.Hour)
37 | }
38 |
39 | // Less defines card order for the review.
40 | func (sm *Supermemo2Plus) Less(other SRSAlgorithm) bool {
41 | return sm.PercentOverdue() < other.(*Supermemo2Plus).PercentOverdue()
42 | }
43 |
44 | // PercentOverdue returns corresponding SM2+ value for a Card.
45 | func (sm *Supermemo2Plus) PercentOverdue() float64 {
46 | percentOverdue := time.Since(sm.LastReviewedAt).Hours() / float64(24*sm.Interval)
47 | return math.Min(2, percentOverdue)
48 | }
49 |
50 | // Advance advances supermemo state for a card.
51 | func (sm *Supermemo2Plus) Advance(rating float64) float64 {
52 | success := rating >= ratingSuccess
53 | percentOverdue := float64(1)
54 | if success {
55 | percentOverdue = sm.PercentOverdue()
56 | }
57 |
58 | sm.Difficulty += percentOverdue / 17 * (8 - 9*rating)
59 | sm.Difficulty = math.Max(0, math.Min(1, sm.Difficulty))
60 | difficultyWeight := 3 - 1.7*sm.Difficulty
61 |
62 | factor := 1.0 / math.Pow(difficultyWeight, 2)
63 | if success {
64 | factor = 1.0 + (difficultyWeight-1)*percentOverdue
65 | }
66 |
67 | sm.LastReviewedAt = time.Now()
68 | if sm.Historical == nil {
69 | sm.Historical = make([]IntervalSnapshot, 0)
70 | }
71 | sm.Historical = append(
72 | sm.Historical,
73 | IntervalSnapshot{time.Now().Unix(), sm.Interval, sm.Difficulty},
74 | )
75 | sm.Interval = sm.Interval * factor
76 | return sm.Interval
77 | }
78 |
79 | // MarshalJSON implements json.Marshaller for Supermemo2Plus
80 | func (sm *Supermemo2Plus) MarshalJSON() ([]byte, error) {
81 | return json.Marshal(&struct {
82 | LastReviewedAt time.Time
83 | Difficulty float64
84 | Interval float64
85 | Historical []IntervalSnapshot
86 | }{sm.LastReviewedAt, sm.Difficulty, sm.Interval, sm.Historical})
87 | }
88 |
89 | // UnmarshalJSON implements json.Unmarshaller for Supermemo2Plus
90 | func (sm *Supermemo2Plus) UnmarshalJSON(b []byte) error {
91 | payload := &struct {
92 | LastReviewedAt time.Time
93 | Difficulty float64
94 | Interval float64
95 | Historical []IntervalSnapshot
96 | }{}
97 |
98 | if err := json.Unmarshal(b, payload); err != nil {
99 | return err
100 | }
101 |
102 | sm.LastReviewedAt = payload.LastReviewedAt
103 | sm.Difficulty = payload.Difficulty
104 | sm.Interval = payload.Interval
105 | sm.Historical = payload.Historical
106 | return nil
107 | }
108 |
--------------------------------------------------------------------------------
/supermemo2_plus_custom.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "math"
5 | "time"
6 | )
7 |
8 | // Supermemo2PlusCustom calculates review intervals using altered SM2+ algorithm
9 | type Supermemo2PlusCustom struct {
10 | Supermemo2Plus
11 | }
12 |
13 | // NewSupermemo2PlusCustom returns a new Supermemo2PlusCustom instance
14 | func NewSupermemo2PlusCustom() *Supermemo2PlusCustom {
15 | sm := NewSupermemo2Plus()
16 | return &Supermemo2PlusCustom{*sm}
17 | }
18 |
19 | // Less defines card order for the review.
20 | func (sm *Supermemo2PlusCustom) Less(other SRSAlgorithm) bool {
21 | return sm.PercentOverdue() < other.(*Supermemo2PlusCustom).PercentOverdue()
22 | }
23 |
24 | // Advance advances supermemo state for a card.
25 | func (sm *Supermemo2PlusCustom) Advance(rating float64) float64 {
26 | success := rating >= ratingSuccess
27 | percentOverdue := float64(1)
28 | if success {
29 | percentOverdue = sm.PercentOverdue()
30 | }
31 |
32 | sm.Difficulty += percentOverdue / 35 * (8 - 9*rating)
33 | sm.Difficulty = math.Max(0, math.Min(1, sm.Difficulty))
34 | difficultyWeight := 3.5 - 1.7*sm.Difficulty
35 |
36 | minInterval := math.Min(1.0, sm.Interval)
37 | factor := minInterval / math.Pow(difficultyWeight, 2)
38 | if success {
39 | minInterval = 0.2
40 | factor = minInterval + (difficultyWeight-1)*percentOverdue
41 | }
42 |
43 | sm.LastReviewedAt = time.Now()
44 | if sm.Historical == nil {
45 | sm.Historical = make([]IntervalSnapshot, 0)
46 | }
47 | sm.Historical = append(
48 | sm.Historical,
49 | IntervalSnapshot{time.Now().Unix(), sm.Interval, sm.Difficulty},
50 | )
51 | sm.Interval = math.Max(minInterval, math.Min(sm.Interval*factor, 300))
52 | return sm.Interval
53 | }
54 |
--------------------------------------------------------------------------------
/supermemo2_plus_custom_test.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "sort"
7 | "testing"
8 | "time"
9 |
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestNextReviewAt(t *testing.T) {
15 | sm := &Supermemo2PlusCustom{Supermemo2Plus{LastReviewedAt: time.Unix(100, 0), Interval: 1}}
16 | assert.Equal(t, int64(86500), sm.NextReviewAt().Unix())
17 |
18 | sm = &Supermemo2PlusCustom{Supermemo2Plus{LastReviewedAt: time.Unix(100, 0).Add(-24 * time.Hour), Interval: 1}}
19 | assert.Equal(t, int64(100), sm.NextReviewAt().Unix())
20 |
21 | sm = NewSupermemo2PlusCustom()
22 | assert.InDelta(t, time.Since(sm.NextReviewAt()), time.Duration(0), float64(time.Minute))
23 | interval := sm.Advance(1)
24 | assert.InDelta(t, time.Duration(24*interval)*time.Hour, time.Until(sm.NextReviewAt()), float64(time.Minute))
25 | }
26 |
27 | func TestPercentOverdue(t *testing.T) {
28 | sm := &Supermemo2PlusCustom{Supermemo2Plus{LastReviewedAt: time.Now().Add(-time.Hour), Interval: 1}}
29 | assert.InDelta(t, 0.04, sm.PercentOverdue(), 0.01)
30 |
31 | sm = &Supermemo2PlusCustom{Supermemo2Plus{LastReviewedAt: time.Now().Add(-48 * time.Hour), Interval: 1}}
32 | assert.InDelta(t, 2.0, sm.PercentOverdue(), 0.01)
33 | }
34 |
35 | func TestLess(t *testing.T) {
36 | sm1 := &Supermemo2PlusCustom{Supermemo2Plus{LastReviewedAt: time.Now().Add(-time.Hour), Interval: 1}}
37 | sm2 := &Supermemo2PlusCustom{Supermemo2Plus{LastReviewedAt: time.Now().Add(-48 * time.Hour), Interval: 1}}
38 |
39 | slice := []SRSAlgorithm{sm1, sm2}
40 | sort.Slice(slice, func(i, j int) bool { return slice[j].Less(slice[i]) })
41 | assert.Equal(t, []SRSAlgorithm{sm2, sm1}, slice)
42 | }
43 |
44 | func TestRecord(t *testing.T) {
45 | results := [][]float64{
46 | {0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2},
47 | {0.35, 0.69, 1.27, 2.16, 3.42, 4.97, 6.60, 7.92, 8.52},
48 | {0.37, 0.86, 2.0, 4.76, 11.56, 28.60, 72.14, 185.44, 300},
49 | }
50 | for idx, rating := range []float64{0.5, 0.6, 1.0} {
51 | t.Run(fmt.Sprintf("%f", rating), func(t *testing.T) {
52 | sm := NewSupermemo2PlusCustom()
53 | intervals := []float64{}
54 | for i := 0; i < 9; i++ {
55 | interval := sm.Advance(rating)
56 | intervals = append(intervals, interval)
57 |
58 | curInterval := sm.Interval * 24 * float64(time.Hour)
59 | sm.LastReviewedAt = time.Now().Add(-time.Duration(curInterval))
60 | }
61 |
62 | assert.InDeltaSlice(t, results[idx], intervals, 0.01)
63 | })
64 | }
65 |
66 | t.Run("sequence", func(t *testing.T) {
67 | sm := NewSupermemo2PlusCustom()
68 | intervals := []float64{}
69 | for _, rating := range []float64{1, 1, 1, 1, 0.5, 1} {
70 | interval := sm.Advance(rating)
71 | intervals = append(intervals, interval)
72 |
73 | curInterval := sm.Interval * 24 * float64(time.Hour)
74 | sm.LastReviewedAt = time.Now().Add(-time.Duration(curInterval))
75 | }
76 |
77 | assert.InDeltaSlice(t, []float64{0.37, 0.86, 2.00, 4.76, 1, 2.25}, intervals, 0.01)
78 |
79 | historical := []float64{}
80 | for _, snap := range sm.Historical {
81 | assert.NotNil(t, snap.Timestamp)
82 | historical = append(historical, snap.Interval)
83 | }
84 | assert.InDeltaSlice(t, []float64{0.2, 0.37, 0.86, 2.00, 4.76, 1}, historical, 0.01)
85 | })
86 | }
87 |
88 | func TestJsonMarshalling(t *testing.T) {
89 | sm := &Supermemo2PlusCustom{Supermemo2Plus{LastReviewedAt: time.Unix(100, 0), Interval: 1, Difficulty: 0.2}}
90 | res, err := json.Marshal(sm)
91 | require.NoError(t, err)
92 |
93 | newSM := new(Supermemo2PlusCustom)
94 | require.NoError(t, json.Unmarshal(res, newSM))
95 | assert.Equal(t, int64(100), newSM.LastReviewedAt.Unix())
96 | assert.Equal(t, 1.0, newSM.Interval)
97 | assert.Equal(t, 0.2, newSM.Difficulty)
98 | }
99 |
--------------------------------------------------------------------------------
/supermemo2_plus_test.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "sort"
7 | "testing"
8 | "time"
9 |
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestSM2PlusNextReviewAt(t *testing.T) {
15 | sm := &Supermemo2Plus{LastReviewedAt: time.Unix(100, 0), Interval: 1}
16 | assert.Equal(t, int64(86500), sm.NextReviewAt().Unix())
17 |
18 | sm = &Supermemo2Plus{LastReviewedAt: time.Unix(100, 0).Add(-24 * time.Hour), Interval: 1}
19 | assert.Equal(t, int64(100), sm.NextReviewAt().Unix())
20 |
21 | sm = NewSupermemo2Plus()
22 | assert.InDelta(t, time.Since(sm.NextReviewAt()), time.Duration(0), float64(time.Minute))
23 | interval := sm.Advance(1)
24 | assert.InDelta(t, time.Duration(24*interval)*time.Hour, time.Until(sm.NextReviewAt()), float64(time.Minute))
25 | }
26 |
27 | func TestSM2PlusPercentOverdue(t *testing.T) {
28 | sm := Supermemo2Plus{LastReviewedAt: time.Now().Add(-time.Hour), Interval: 1}
29 | assert.InDelta(t, 0.04, sm.PercentOverdue(), 0.01)
30 |
31 | sm = Supermemo2Plus{LastReviewedAt: time.Now().Add(-48 * time.Hour), Interval: 1}
32 | assert.InDelta(t, 2.0, sm.PercentOverdue(), 0.01)
33 | }
34 |
35 | func TestSM2PlusLess(t *testing.T) {
36 | sm1 := &Supermemo2Plus{LastReviewedAt: time.Now().Add(-time.Hour), Interval: 1}
37 | sm2 := &Supermemo2Plus{LastReviewedAt: time.Now().Add(-48 * time.Hour), Interval: 1}
38 |
39 | slice := []SRSAlgorithm{sm1, sm2}
40 | sort.Slice(slice, func(i, j int) bool { return slice[j].Less(slice[i]) })
41 | assert.Equal(t, []SRSAlgorithm{sm2, sm1}, slice)
42 | }
43 |
44 | func TestSM2PlusRecord(t *testing.T) {
45 | results := [][]float64{
46 | {0.04, 0.01, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0},
47 | {0.41, 0.82, 1.45, 2.17, 2.82, 3.67, 4.77, 6.21, 8.07},
48 | {0.46, 1.23, 3.42, 9.84, 29.27, 87.83, 263.49, 790.49, 2371.48},
49 | }
50 | for idx, rating := range []float64{0.5, 0.6, 1.0} {
51 | t.Run(fmt.Sprintf("%f", rating), func(t *testing.T) {
52 | sm := NewSupermemo2Plus()
53 | intervals := []float64{}
54 | for i := 0; i < 9; i++ {
55 | interval := sm.Advance(rating)
56 | intervals = append(intervals, interval)
57 |
58 | curInterval := sm.Interval * 24 * float64(time.Hour)
59 | sm.LastReviewedAt = time.Now().Add(-time.Duration(curInterval))
60 | }
61 |
62 | assert.InDeltaSlice(t, results[idx], intervals, 0.01)
63 | })
64 | }
65 |
66 | t.Run("sequence", func(t *testing.T) {
67 | sm := NewSupermemo2Plus()
68 | intervals := []float64{}
69 | for _, rating := range []float64{1, 1, 1, 1, 0.5, 1} {
70 | interval := sm.Advance(rating)
71 | intervals = append(intervals, interval)
72 |
73 | curInterval := sm.Interval * 24 * float64(time.Hour)
74 | sm.LastReviewedAt = time.Now().Add(-time.Duration(curInterval))
75 | }
76 |
77 | assert.InDeltaSlice(t, []float64{0.46, 1.23, 3.42, 9.84, 1.54, 4.05}, intervals, 0.01)
78 |
79 | historical := []float64{}
80 | for _, snap := range sm.Historical {
81 | assert.NotNil(t, snap.Timestamp)
82 | historical = append(historical, snap.Interval)
83 | }
84 | assert.InDeltaSlice(t, []float64{0.2, 0.46, 1.23, 3.42, 9.84, 1.54}, historical, 0.01)
85 | })
86 | }
87 |
88 | func TestSM2PlusJsonMarshalling(t *testing.T) {
89 | sm := &Supermemo2Plus{LastReviewedAt: time.Unix(100, 0), Interval: 1, Difficulty: 0.2}
90 | res, err := json.Marshal(sm)
91 | require.NoError(t, err)
92 |
93 | newSM := new(Supermemo2Plus)
94 | require.NoError(t, json.Unmarshal(res, newSM))
95 | assert.Equal(t, int64(100), newSM.LastReviewedAt.Unix())
96 | assert.Equal(t, 1.0, newSM.Interval)
97 | assert.Equal(t, 0.2, newSM.Difficulty)
98 | }
99 |
--------------------------------------------------------------------------------
/supermemo2_test.go:
--------------------------------------------------------------------------------
1 | package leaf
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "sort"
7 | "testing"
8 | "time"
9 |
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestSM2NextReviewAt(t *testing.T) {
15 | sm := &Supermemo2{LastReviewedAt: time.Unix(100, 0), Interval: 1}
16 | assert.Equal(t, int64(86500), sm.NextReviewAt().Unix())
17 |
18 | sm = &Supermemo2{LastReviewedAt: time.Unix(100, 0).Add(-24 * time.Hour), Interval: 1}
19 | assert.Equal(t, int64(100), sm.NextReviewAt().Unix())
20 |
21 | sm = NewSupermemo2()
22 | assert.InDelta(t, time.Since(sm.NextReviewAt()), time.Duration(0), float64(time.Minute))
23 | interval := sm.Advance(1)
24 | assert.InDelta(t, time.Duration(24*interval)*time.Hour, time.Until(sm.NextReviewAt()), float64(time.Minute))
25 | }
26 |
27 | func TestSM2Less(t *testing.T) {
28 | sm1 := &Supermemo2{Interval: 1}
29 | sm2 := &Supermemo2{Interval: 0.2}
30 |
31 | slice := []SRSAlgorithm{sm1, sm2}
32 | sort.Slice(slice, func(i, j int) bool { return slice[j].Less(slice[i]) })
33 | assert.Equal(t, []SRSAlgorithm{sm2, sm1}, slice)
34 | }
35 |
36 | func TestSM2Record(t *testing.T) {
37 | results := [][]float64{
38 | {1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0},
39 | {1.0, 6.0, 12.0, 23.0, 41.0, 68.0, 103.0, 142.0, 185.0},
40 | {1.0, 6.0, 17.0, 49.0, 147.0, 456.0, 1459.0, 4815.0, 16371.0},
41 | }
42 | for idx, rating := range []float64{0.5, 0.6, 1.0} {
43 | t.Run(fmt.Sprintf("%f", rating), func(t *testing.T) {
44 | sm := NewSupermemo2()
45 | intervals := []float64{}
46 | for i := 0; i < 9; i++ {
47 | interval := sm.Advance(rating)
48 | intervals = append(intervals, interval)
49 |
50 | curInterval := sm.Interval * 24 * float64(time.Hour)
51 | sm.LastReviewedAt = time.Now().Add(-time.Duration(curInterval))
52 | }
53 |
54 | assert.InDeltaSlice(t, results[idx], intervals, 0.01)
55 | })
56 | }
57 |
58 | t.Run("sequence", func(t *testing.T) {
59 | sm := NewSupermemo2()
60 | intervals := []float64{}
61 | for _, rating := range []float64{1, 1, 1, 1, 0.5, 1} {
62 | interval := sm.Advance(rating)
63 | intervals = append(intervals, interval)
64 |
65 | curInterval := sm.Interval * 24 * float64(time.Hour)
66 | sm.LastReviewedAt = time.Now().Add(-time.Duration(curInterval))
67 | }
68 |
69 | assert.InDeltaSlice(t, []float64{1.0, 6.0, 17.0, 49.0, 1.0, 3.0}, intervals, 0.01)
70 |
71 | historical := []float64{}
72 | for _, snap := range sm.Historical {
73 | assert.NotNil(t, snap.Timestamp)
74 | historical = append(historical, snap.Interval)
75 | }
76 |
77 | assert.InDeltaSlice(t, []float64{0.0, 1.0, 6.0, 17.0, 49.0, 1.0}, historical, 0.01)
78 | })
79 | }
80 |
81 | func TestSM2JsonMarshalling(t *testing.T) {
82 | sm := &Supermemo2{LastReviewedAt: time.Unix(100, 0), Interval: 1, Easiness: 2.5}
83 | res, err := json.Marshal(sm)
84 | require.NoError(t, err)
85 |
86 | newSM := new(Supermemo2)
87 | require.NoError(t, json.Unmarshal(res, newSM))
88 | assert.Equal(t, int64(100), newSM.LastReviewedAt.Unix())
89 | assert.Equal(t, 1.0, newSM.Interval)
90 | assert.Equal(t, 2.5, newSM.Easiness)
91 | }
92 |
--------------------------------------------------------------------------------
/ui/server.go:
--------------------------------------------------------------------------------
1 | //go:generate esc -o ui/static.go -prefix ui/static -pkg ui -ignore tests ui/static
2 |
3 | package ui
4 |
5 | import (
6 | "encoding/json"
7 | "net/http"
8 | "strings"
9 |
10 | "github.com/ap4y/leaf"
11 | )
12 |
13 | type statsResponse struct {
14 | Card string `json:"card"`
15 | Stats *leaf.Stats `json:"stats"`
16 | }
17 |
18 | // Server implements web ui for reviews.
19 | type Server struct {
20 | dm *leaf.DeckManager
21 |
22 | sessionState *SessionState
23 | }
24 |
25 | // NewServer construct a new Server instance.
26 | func NewServer(dm *leaf.DeckManager) *Server {
27 | return &Server{dm: dm}
28 | }
29 |
30 | // Handler returns a new handler for a Server.
31 | func (srv *Server) Handler(devMode bool) *http.ServeMux {
32 | mux := http.NewServeMux()
33 | mux.Handle("/", http.FileServer(FS(devMode)))
34 | mux.HandleFunc("/decks", srv.listDecks)
35 | mux.HandleFunc("/start/", srv.startSession)
36 | mux.HandleFunc("/stats/", srv.deckStats)
37 | mux.HandleFunc("/advance", srv.advanceSession)
38 | mux.HandleFunc("/resolve", srv.resolveAnswer)
39 | return mux
40 | }
41 |
42 | func (srv *Server) listDecks(w http.ResponseWriter, req *http.Request) {
43 | if req.Method != http.MethodGet {
44 | http.Error(w, "invalid method", http.StatusMethodNotAllowed)
45 | return
46 | }
47 |
48 | decks, err := srv.dm.ReviewDecks()
49 | if err != nil {
50 | http.Error(w, err.Error(), http.StatusUnprocessableEntity)
51 | return
52 | }
53 |
54 | if err := json.NewEncoder(w).Encode(decks); err != nil {
55 | http.Error(w, err.Error(), http.StatusUnprocessableEntity)
56 | }
57 | }
58 |
59 | func (srv *Server) startSession(w http.ResponseWriter, req *http.Request) {
60 | if req.Method != http.MethodPost {
61 | http.Error(w, "invalid method", http.StatusMethodNotAllowed)
62 | return
63 | }
64 |
65 | deckName := strings.Replace(req.URL.Path, "/start/", "", -1)
66 | session, err := srv.dm.ReviewSession(deckName)
67 | if err != nil {
68 | http.Error(w, err.Error(), http.StatusUnprocessableEntity)
69 | return
70 | }
71 |
72 | srv.sessionState = NewSessionState(session)
73 | if err := json.NewEncoder(w).Encode(srv.sessionState); err != nil {
74 | http.Error(w, err.Error(), http.StatusUnprocessableEntity)
75 | }
76 | }
77 |
78 | func (srv *Server) deckStats(w http.ResponseWriter, req *http.Request) {
79 | if req.Method != http.MethodGet {
80 | http.Error(w, "invalid method", http.StatusMethodNotAllowed)
81 | return
82 | }
83 |
84 | deckName := strings.Replace(req.URL.Path, "/stats/", "", -1)
85 | stats, err := srv.dm.DeckStats(deckName)
86 | if err != nil {
87 | http.Error(w, err.Error(), http.StatusUnprocessableEntity)
88 | return
89 | }
90 |
91 | res := make([]statsResponse, len(stats))
92 | for idx, stat := range stats {
93 | res[idx] = statsResponse{stat.RawQuestion, stat.Stats}
94 | }
95 |
96 | if err := json.NewEncoder(w).Encode(res); err != nil {
97 | http.Error(w, err.Error(), http.StatusUnprocessableEntity)
98 | }
99 | }
100 |
101 | func (srv *Server) advanceSession(w http.ResponseWriter, req *http.Request) {
102 | if req.Method != http.MethodPost {
103 | http.Error(w, "invalid method", http.StatusMethodNotAllowed)
104 | return
105 | }
106 |
107 | if srv.sessionState == nil {
108 | http.Error(w, "no active sessions", http.StatusBadRequest)
109 | return
110 | }
111 |
112 | data := map[string]leaf.ReviewScore{}
113 | if err := json.NewDecoder(req.Body).Decode(&data); err != nil {
114 | http.Error(w, err.Error(), http.StatusUnprocessableEntity)
115 | return
116 | }
117 |
118 | srv.sessionState.Advance(data["score"])
119 |
120 | if err := json.NewEncoder(w).Encode(srv.sessionState); err != nil {
121 | http.Error(w, err.Error(), http.StatusUnprocessableEntity)
122 | }
123 | }
124 |
125 | func (srv *Server) resolveAnswer(w http.ResponseWriter, req *http.Request) {
126 | if req.Method != http.MethodGet {
127 | http.Error(w, "invalid method", http.StatusMethodNotAllowed)
128 | return
129 | }
130 |
131 | if srv.sessionState == nil {
132 | http.Error(w, "no active sessions", http.StatusBadRequest)
133 | return
134 | }
135 |
136 | answer := srv.sessionState.ResolveAnswer()
137 | res := map[string]string{"answer": answer}
138 | if err := json.NewEncoder(w).Encode(res); err != nil {
139 | http.Error(w, err.Error(), http.StatusUnprocessableEntity)
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/ui/server_test.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | "net/http"
7 | "net/http/httptest"
8 | "os"
9 | "strings"
10 | "testing"
11 |
12 | "github.com/ap4y/leaf"
13 | "github.com/stretchr/testify/assert"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func TestWebUI(t *testing.T) {
18 | tmpfile, err := ioutil.TempFile("", "leaf.db")
19 | require.NoError(t, err)
20 | defer os.Remove(tmpfile.Name())
21 |
22 | db, err := leaf.OpenBoltStore(tmpfile.Name())
23 | require.NoError(t, err)
24 |
25 | dm, err := leaf.NewDeckManager("../fixtures", db, leaf.OutputFormatOrg)
26 | require.NoError(t, err)
27 |
28 | srv := NewServer(dm)
29 |
30 | t.Run("listDecks", func(t *testing.T) {
31 | req := httptest.NewRequest("GET", "http://example.com/decks", nil)
32 | w := httptest.NewRecorder()
33 |
34 | srv.listDecks(w, req)
35 | res := w.Result()
36 | assert.Equal(t, http.StatusOK, res.StatusCode)
37 |
38 | decks := make([]*leaf.DeckStats, 0)
39 | require.NoError(t, json.NewDecoder(w.Body).Decode(&decks))
40 | assert.Len(t, decks, 2)
41 | })
42 |
43 | t.Run("deckStats", func(t *testing.T) {
44 | req := httptest.NewRequest("GET", "http://example.com/stats/Org-mode", nil)
45 | w := httptest.NewRecorder()
46 |
47 | srv.deckStats(w, req)
48 | res := w.Result()
49 | assert.Equal(t, http.StatusOK, res.StatusCode)
50 |
51 | stats := make([]map[string]interface{}, 0)
52 | require.NoError(t, json.NewDecoder(w.Body).Decode(&stats))
53 | require.Len(t, stats, 10)
54 | assert.Equal(t, "/emphasis/", stats[0]["card"])
55 | })
56 |
57 | t.Run("startReview", func(t *testing.T) {
58 | req := httptest.NewRequest("POST", "http://example.com/start/Hiragana", nil)
59 | w := httptest.NewRecorder()
60 |
61 | srv.startSession(w, req)
62 | res := w.Result()
63 | assert.Equal(t, http.StatusOK, res.StatusCode)
64 |
65 | state := new(SessionState)
66 | require.NoError(t, json.NewDecoder(w.Body).Decode(state))
67 | assert.Equal(t, 20, state.Total)
68 | assert.Equal(t, 20, state.Left)
69 | })
70 |
71 | t.Run("advanceSession", func(t *testing.T) {
72 | req := httptest.NewRequest("POST", "http://example.com/advance", strings.NewReader("{\"score\":0}"))
73 | w := httptest.NewRecorder()
74 |
75 | srv.advanceSession(w, req)
76 | res := w.Result()
77 | assert.Equal(t, http.StatusOK, res.StatusCode)
78 |
79 | state := new(SessionState)
80 | require.NoError(t, json.NewDecoder(w.Body).Decode(state))
81 | assert.Equal(t, 20, state.Total)
82 | assert.Equal(t, 20, state.Left)
83 | })
84 |
85 | t.Run("resolveAnswer", func(t *testing.T) {
86 | req := httptest.NewRequest("GET", "http://example.com/resolve", nil)
87 | w := httptest.NewRecorder()
88 |
89 | srv.resolveAnswer(w, req)
90 | res := w.Result()
91 | assert.Equal(t, http.StatusOK, res.StatusCode)
92 |
93 | result := make(map[string]string)
94 | require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
95 | assert.Equal(t, "i", result["answer"])
96 | })
97 | }
98 |
--------------------------------------------------------------------------------
/ui/state.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "github.com/ap4y/leaf"
5 | )
6 |
7 | // SessionState state holds public state of the ReviewSession.
8 | type SessionState struct {
9 | Total int `json:"total"`
10 | Left int `json:"left"`
11 | Question string `json:"question"`
12 | AnswerLen int `json:"answer_length"`
13 | RatingType leaf.RatingType `json:"rating_type"`
14 | Sides []string `json:"sides"`
15 |
16 | session *leaf.ReviewSession
17 | rater leaf.Rater
18 | }
19 |
20 | // NewSessionState constructs a new SessionState.
21 | func NewSessionState(session *leaf.ReviewSession) *SessionState {
22 | var rater leaf.Rater
23 | if session.RatingType() == leaf.RatingTypeSelf {
24 | rater = leaf.TableRater()
25 | } else {
26 | rater = leaf.HarshRater()
27 | }
28 |
29 | s := &SessionState{
30 | Total: session.Total(),
31 | Left: session.Left(),
32 | Question: session.Next(),
33 | AnswerLen: len([]rune(session.CorrectAnswer())),
34 | RatingType: session.RatingType(),
35 | Sides: session.Sides(),
36 | session: session,
37 | rater: rater,
38 | }
39 |
40 | return s
41 | }
42 |
43 | // ResolveAnswer submits answer to a session.
44 | func (s *SessionState) ResolveAnswer() (correctAnswer string) {
45 | return s.session.CorrectAnswer()
46 | }
47 |
48 | // Advance fetches next question if available or sets session to finished otherwise.
49 | func (s *SessionState) Advance(score leaf.ReviewScore) {
50 | rating := s.rater.Rate(s.Question, score) // increment misses in auto rater
51 |
52 | if score == leaf.ReviewScoreAgain {
53 | s.session.Again() // nolint: errcheck
54 | } else {
55 | s.session.Rate(rating) // nolint: errcheck
56 | s.Left = s.session.Left()
57 | }
58 |
59 | s.Question = s.session.Next()
60 | s.AnswerLen = len([]rune(s.session.CorrectAnswer()))
61 | }
62 |
--------------------------------------------------------------------------------
/ui/state_test.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/ap4y/leaf"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestSessionState(t *testing.T) {
11 | cards := []leaf.CardWithStats{
12 | {Card: leaf.Card{Question: "foo", Sides: []string{"bar"}}, Stats: leaf.NewStats(leaf.SRSSupermemo2Plus)},
13 | {Card: leaf.Card{Question: "bar", Sides: []string{"baz"}}, Stats: leaf.NewStats(leaf.SRSSupermemo2Plus)},
14 | }
15 |
16 | stats := make(map[string]*leaf.Stats)
17 | s := leaf.NewReviewSession(cards, []string{"answer"}, leaf.RatingTypeAuto, func(card *leaf.CardWithStats) error {
18 | stats[card.Question] = card.Stats
19 | return nil
20 | })
21 |
22 | state := NewSessionState(s)
23 | t.Run("state", func(t *testing.T) {
24 | assert.Equal(t, 2, state.Total)
25 | assert.Equal(t, 2, state.Left)
26 | assert.Equal(t, "foo", state.Question)
27 | assert.Equal(t, 3, state.AnswerLen)
28 | assert.Equal(t, []string{"answer"}, state.Sides)
29 | })
30 |
31 | t.Run("ResolveAnswer", func(t *testing.T) {
32 | answer := state.ResolveAnswer()
33 | assert.Equal(t, "bar", answer)
34 | assert.Equal(t, 2, state.Left)
35 | })
36 |
37 | t.Run("Advance - incorrect", func(t *testing.T) {
38 | state.Advance(leaf.ReviewScoreAgain)
39 | assert.Equal(t, "bar", state.Question)
40 | assert.Equal(t, 3, state.AnswerLen)
41 | assert.Equal(t, 2, state.Left)
42 | })
43 |
44 | t.Run("Advance - correct", func(t *testing.T) {
45 | state.Advance(leaf.ReviewScoreGood)
46 | assert.Equal(t, 1, state.Left)
47 | })
48 | }
49 |
50 | func TestSessionStateUnicode(t *testing.T) {
51 | cards := []leaf.CardWithStats{
52 | {Card: leaf.Card{Question: "hello", Sides: []string{"おはよう"}}, Stats: leaf.NewStats(leaf.SRSSupermemo2Plus)},
53 | }
54 |
55 | stats := make(map[string]*leaf.Stats)
56 | s := leaf.NewReviewSession(cards, nil, leaf.RatingTypeAuto, func(card *leaf.CardWithStats) error {
57 | stats[card.Question] = card.Stats
58 | return nil
59 | })
60 |
61 | state := NewSessionState(s)
62 | t.Run("state", func(t *testing.T) {
63 | assert.Equal(t, 1, state.Total)
64 | assert.Equal(t, 1, state.Left)
65 | assert.Equal(t, "hello", state.Question)
66 | assert.Equal(t, 4, state.AnswerLen)
67 | })
68 | }
69 |
--------------------------------------------------------------------------------
/ui/static.go:
--------------------------------------------------------------------------------
1 | // Code generated by "esc -o ui/static.go -prefix ui/static -pkg ui -ignore tests|node_modules|package.*json|babel.*|eslint.* ui/static"; DO NOT EDIT.
2 |
3 | package ui
4 |
5 | import (
6 | "bytes"
7 | "compress/gzip"
8 | "encoding/base64"
9 | "fmt"
10 | "io"
11 | "io/ioutil"
12 | "net/http"
13 | "os"
14 | "path"
15 | "sync"
16 | "time"
17 | )
18 |
19 | type _escLocalFS struct{}
20 |
21 | var _escLocal _escLocalFS
22 |
23 | type _escStaticFS struct{}
24 |
25 | var _escStatic _escStaticFS
26 |
27 | type _escDirectory struct {
28 | fs http.FileSystem
29 | name string
30 | }
31 |
32 | type _escFile struct {
33 | compressed string
34 | size int64
35 | modtime int64
36 | local string
37 | isDir bool
38 |
39 | once sync.Once
40 | data []byte
41 | name string
42 | }
43 |
44 | func (_escLocalFS) Open(name string) (http.File, error) {
45 | f, present := _escData[path.Clean(name)]
46 | if !present {
47 | return nil, os.ErrNotExist
48 | }
49 | return os.Open(f.local)
50 | }
51 |
52 | func (_escStaticFS) prepare(name string) (*_escFile, error) {
53 | f, present := _escData[path.Clean(name)]
54 | if !present {
55 | return nil, os.ErrNotExist
56 | }
57 | var err error
58 | f.once.Do(func() {
59 | f.name = path.Base(name)
60 | if f.size == 0 {
61 | return
62 | }
63 | var gr *gzip.Reader
64 | b64 := base64.NewDecoder(base64.StdEncoding, bytes.NewBufferString(f.compressed))
65 | gr, err = gzip.NewReader(b64)
66 | if err != nil {
67 | return
68 | }
69 | f.data, err = ioutil.ReadAll(gr)
70 | })
71 | if err != nil {
72 | return nil, err
73 | }
74 | return f, nil
75 | }
76 |
77 | func (fs _escStaticFS) Open(name string) (http.File, error) {
78 | f, err := fs.prepare(name)
79 | if err != nil {
80 | return nil, err
81 | }
82 | return f.File()
83 | }
84 |
85 | func (dir _escDirectory) Open(name string) (http.File, error) {
86 | return dir.fs.Open(dir.name + name)
87 | }
88 |
89 | func (f *_escFile) File() (http.File, error) {
90 | type httpFile struct {
91 | *bytes.Reader
92 | *_escFile
93 | }
94 | return &httpFile{
95 | Reader: bytes.NewReader(f.data),
96 | _escFile: f,
97 | }, nil
98 | }
99 |
100 | func (f *_escFile) Close() error {
101 | return nil
102 | }
103 |
104 | func (f *_escFile) Readdir(count int) ([]os.FileInfo, error) {
105 | if !f.isDir {
106 | return nil, fmt.Errorf(" escFile.Readdir: '%s' is not directory", f.name)
107 | }
108 |
109 | fis, ok := _escDirs[f.local]
110 | if !ok {
111 | return nil, fmt.Errorf(" escFile.Readdir: '%s' is directory, but we have no info about content of this dir, local=%s", f.name, f.local)
112 | }
113 | limit := count
114 | if count <= 0 || limit > len(fis) {
115 | limit = len(fis)
116 | }
117 |
118 | if len(fis) == 0 && count > 0 {
119 | return nil, io.EOF
120 | }
121 |
122 | return fis[0:limit], nil
123 | }
124 |
125 | func (f *_escFile) Stat() (os.FileInfo, error) {
126 | return f, nil
127 | }
128 |
129 | func (f *_escFile) Name() string {
130 | return f.name
131 | }
132 |
133 | func (f *_escFile) Size() int64 {
134 | return f.size
135 | }
136 |
137 | func (f *_escFile) Mode() os.FileMode {
138 | return 0
139 | }
140 |
141 | func (f *_escFile) ModTime() time.Time {
142 | return time.Unix(f.modtime, 0)
143 | }
144 |
145 | func (f *_escFile) IsDir() bool {
146 | return f.isDir
147 | }
148 |
149 | func (f *_escFile) Sys() interface{} {
150 | return f
151 | }
152 |
153 | // FS returns a http.Filesystem for the embedded assets. If useLocal is true,
154 | // the filesystem's contents are instead used.
155 | func FS(useLocal bool) http.FileSystem {
156 | if useLocal {
157 | return _escLocal
158 | }
159 | return _escStatic
160 | }
161 |
162 | // Dir returns a http.Filesystem for the embedded assets on a given prefix dir.
163 | // If useLocal is true, the filesystem's contents are instead used.
164 | func Dir(useLocal bool, name string) http.FileSystem {
165 | if useLocal {
166 | return _escDirectory{fs: _escLocal, name: name}
167 | }
168 | return _escDirectory{fs: _escStatic, name: name}
169 | }
170 |
171 | // FSByte returns the named file from the embedded assets. If useLocal is
172 | // true, the filesystem's contents are instead used.
173 | func FSByte(useLocal bool, name string) ([]byte, error) {
174 | if useLocal {
175 | f, err := _escLocal.Open(name)
176 | if err != nil {
177 | return nil, err
178 | }
179 | b, err := ioutil.ReadAll(f)
180 | _ = f.Close()
181 | return b, err
182 | }
183 | f, err := _escStatic.prepare(name)
184 | if err != nil {
185 | return nil, err
186 | }
187 | return f.data, nil
188 | }
189 |
190 | // FSMustByte is the same as FSByte, but panics if name is not present.
191 | func FSMustByte(useLocal bool, name string) []byte {
192 | b, err := FSByte(useLocal, name)
193 | if err != nil {
194 | panic(err)
195 | }
196 | return b
197 | }
198 |
199 | // FSString is the string version of FSByte.
200 | func FSString(useLocal bool, name string) (string, error) {
201 | b, err := FSByte(useLocal, name)
202 | return string(b), err
203 | }
204 |
205 | // FSMustString is the string version of FSMustByte.
206 | func FSMustString(useLocal bool, name string) string {
207 | return string(FSMustByte(useLocal, name))
208 | }
209 |
210 | var _escData = map[string]*_escFile{
211 |
212 | "/deck_list.js": {
213 | name: "deck_list.js",
214 | local: "ui/static/deck_list.js",
215 | size: 1463,
216 | modtime: 1570480914,
217 | compressed: `
218 | H4sIAAAAAAAC/3RUUYvjNhB+z68Y1IWzuViRvT6WvbXzUK4lhcvTlYNSyq5WnsRqFClIirPdkP9eJNvJ
219 | ZtvDkEgz33zzzWgkfNkZ66HBFd8rD0Jx5+ALis1X6TwcJwDCaOftXnhjkzRaAHwrHX1EBTU0Ruy3qD0V
220 | FrnHXxSGXUL2iqQPV2AayQMv5U2TkAbFJlPS+R54mkwA1ugBB4oxmUW/t/pMc8Y6DLrFxiXx91pbNAV5
221 | 4f+tDou6Qfubx61LLnmvze+qpFJrtIvfl1+hfksfQQDUGeuThE/hOYV6DpxqvkWYw3NcpCNsy3fJsAZI
222 | jhCcUxDcNu7RIm/+mYLGF/9osZN4eOQeToHvHALwVCk5n1QcWourmvx0cwwcJwJGCyXFpiZ8t6POc+u/
223 | oXPS6OTDgPkwvTm+SXVKH8a+rrhy+EDmA7Ca8fmkamQXElfCNDi/OY6tC8K+ee5d8k72Ab5wj8m1/jQ9
224 | VbPIMKlmkbHi/YTVxAWWTEm9IWM1vekHNbVmSHwu6L8VBMGuW8PLVmlXk9b73efZ7HA40MMtNXY9Kxhj
225 | M9etCRxk49uaFCWBFuW69f06CP/ZvNSEAYOihKIkff+rHfctNDVZ5vdwu/gkspzmwLIC6H1WQNHlpWCQ
226 | 05zeQxG+Ni9FhECRBVtWfP8kWIjKQkT4Xpf3kN8t7rrsri26u9dtCazNiu9hl7Nx22VlW3TlK5m9V8KA
227 | tUXZFeWCvRJYSaVqoo3GHlmFQkPjw3HOlJw/DYN0nse/jdQJIVeX4HLAbyZyvA9yBb0Z5sDSsfvR8jCJ
228 | iPhWQCNXK6hjKGSX4Rieg8ASERXkjF14CO+4VPxZIWhzIAOjCpd8IOvjw+7PoQbCCHyEhq7R9ymmV44k
229 | epZG+zZJ4SPkF/857te9Un8gt+9jo3Nh9tb9r2cp9d6jS/pu/hUvtzDbndGofXgFzhvqlBSYZEWaDjUN
230 | BTeDh03hNh1Og5Kgk0Cfp/efnZ/HszpN/g0AAP//jQlJmbcFAAA=
231 | `,
232 | },
233 |
234 | "/index.html": {
235 | name: "index.html",
236 | local: "ui/static/index.html",
237 | size: 455,
238 | modtime: 1563585442,
239 | compressed: `
240 | H4sIAAAAAAAC/2xRsU4rMRDs7yv2bf3CKR1C9kmIUENBQ2nsDV7i8528mwv5e+RzElFQeUazO5rxmn+7
241 | l6e399dniDqmoetMfSG5/GmRMg4dgInkQgUAZiR14KMrQmrxqPvNPUL/W8xuJIsL02meiiL4KStltXji
242 | oNEGWtjTZiX/gTMru7QR7xLZ7WrVvJQ10bAjf4DHeTZ9401LnA9QKFkUPSeSSKQIsdDe4ug433kRBD3P
243 | ZFHpW/uVrylN38pU+DGF88Ux8AIcLAbyB0HwyYlYrNEdZyrYxupXbNdM8mD6uL0s94GXa+yrkZAITxmH
244 | v1V1KjetWrQsFYovPCtI8ZcyX7cu4xSOiepeGxo607ez/QQAAP//fSD4gMcBAAA=
245 | `,
246 | },
247 |
248 | "/main.css": {
249 | name: "main.css",
250 | local: "ui/static/main.css",
251 | size: 4150,
252 | modtime: 1592189875,
253 | compressed: `
254 | H4sIAAAAAAAC/6xX/46rNhP9n6cY3dUn3f2EEWQhm2XVq75HVVUGJsFdY1PbbLKt8u6VbQg/QrLdqtLV
255 | 3YBnjo/tM8dDbRoOfwUAeykM2dOG8Y8cNBWaaFRs/zoMafYn5rBJ2tNrcA6CQlYfLq+hJ3JklalzeI5j
256 | O2rfqQMTOcRAOyPtm5ZWFROHHJJok2EDcbTbvWDzGgQABS3fDkp2osrhAUvc79PX+4Q4E0hqZIfaWMQ0
257 | sy9LyaXK4SEts+2WOpJ1Egb1JgzqpzCo0zCos57yQC+GRFkWX5ktyXrscdv83iTRLt5YtHMQRIYZji7C
258 | 4MkQytlB5FCiMKh8hFblEiGL/+exN9fY2wt2/XQ9mm7SYTS9Ht1st8Nodj2a2BNxo7qhnIdBZCn/5h6W
259 | 0ZdzOwdBGwZ7qZrJlhIj2xziUQKkkMbIZjh2l8bZNGMIiKPtJaLjYSD5l3AnCpuGcdyb2eQdB85yTrUh
260 | ck/MR4tuqsW7VYIuv5QV3tyTqZRJL0d1KOj3OIT+XxTvHmdss/YEW181hVQVKqJoxTqdw8uylobSE/Td
261 | UaiYbjn9yGHP0YXav6RiCkvDpFWb5F0jXE5DmVhP+r3Thu0/SCmFQWFGjd7Fow7suuRo/s40M1hNx5mo
262 | UTHjfaMzRoowYKLtzC92s3/6pruiYebbry6HHLF4Y4bQtkWqqCgxByEFWkJDET5lfm/GM4c4ytR9P/Hb
263 | O4Ittvup393IbgRlApWjszCu1wX+sWYGbx9eIU9E17SSx14JyS4NIXlJQ9gkmdXDJnuEuD1B6nUQBgBw
264 | IzTxkVYxz9dk/+8VXNaMV7f1G2nUmknxNQEBOP8izGCjFy7W4/2AinlZ9pdBEvdmdgm5mMVi3mlMpKhh
265 | 4kAusf+CkpPWiDDj0wMeFW1zsP/fLYEFWuSFSryGHfhSkf1k/nGZToU+oiKFPF2WlkMyL/I4ctejd7FW
266 | auaXrZBTw95xKuSkPYGWnFXwUO0qxJcVHV4VSrTzTjgj5n9ThfQ/08VkrfY2uWAvTmNaS/PCHJ9lZ+wV
267 | PL5Q6I13eJ7d3Y0UUre0xEXnkvjivdc6THxqyv8H6Jb64x4PhBZa8s7X/ng7+fvG/VR+itiv6FKDFoPZ
268 | bSL4jsLoYRVfXMY/8aDbZz9ZXH/2DdOGvvmrbYrtneglCeHlOYQk3lojyh5voJRSWY3cQNk+WT+LQ0jS
269 | eIQZa7+9Y1sKdcfN3X6qwvKNcKZ9lP1BtPngE53MmoJ4mdX3JSvmNMbQfM/Uep/Qn3cyNIBj0mCMs+l9
270 | YzwPjLShRhPOxNucChNOtkM5rhbdsue55qHfD953GOfzPrmfWCPH0hAlj+s2cH/esZPzN/Eqbt+dzvxO
271 | DYY37a9ml/hgb+cgePijQ22GG8wVxrGvZiFVQ/mnzXVP6qBoW8893H++LEMiemLa+YaL1kbJN5yTWom3
272 | Kr1qtTe+IvsD+BxgRW1e/aKsrWFpQ5VZAXAG8045tNTUc9ZFukNa3cspmSr7T5ee6Oc542o/ydjT0ki1
273 | wos+Fbgrb2dcs/osY8npZrz/c6Hkw71n3IidkBkWcDd+VQxpL4aphAvJq9flKTesqvgKuFPI1WfLLBWF
274 | P4SfG6wYBV0qRAFUVPB98t2eWeE/uuyFi157JsA5OAd/BwAA//+GlgmyNhAAAA==
275 | `,
276 | },
277 |
278 | "/main.js": {
279 | name: "main.js",
280 | local: "ui/static/main.js",
281 | size: 2810,
282 | modtime: 1570480914,
283 | compressed: `
284 | H4sIAAAAAAAC/6xWTY/jNgy9+1ew3h5sIPX0PIELTL+AFkW32CnQY6LazFo7jqWKyqRG4P9e6Mu2Yicz
285 | h94Chnx6fHyizI9SKA0/YvXyGycNByWOkBYPNVYvu5aTLr5Quk182id85Xh+RiIuujFX2eiOXDgqeNZM
286 | UwRMJjJDTqqWEcGTlHBJACrRkVanSguV5TYCoBtORR0YltDheSSc5dspZ2eSCEqoRXU6YqeLz6h/atH8
287 | /L7/pc5Sm5AuawomJXb1Dw1v6yw6r0BXn2+TqYjGthybsc2Yjk27R8cmpMuaJZ3xxFU+KpqL4xTNKuIV
288 | ZRcKSbSv+NTRGRWUkOVQfufJRP/dwWD1K+sqnAgw6rsKqBIKDZqbo58u0JR2Zlz7s2KMzNb6E1fPnFD8
289 | L5c7RGKOObdH4FKuhhD6uh5DTGEaBcBgxqGwq3Ey7pl3tTgXopNCmgHiKO9l3hc14mz8TEHhwc/WydUw
290 | aqAMYK2omDaHm/B2fkzDSQvVFwplyyo0nsTsMmwgNeBPUqYbSB9Co/wA2VcGI3+DjOlKn1SQNxmrTbHx
291 | pdL0F9dNln7wdl5BtBfEVXh6If0bwyrNQ+eALeFVvTki2OIKIi4OY/DumzqZLxJ/4Un3LRY1J9my3lyZ
292 | U9uuWeA6L+1Eh+nyxt7KW26wImypufsPqKtmpvu8j3n/pnYDFVM1fUJW96E1M5ApCmVZwrf5OLk1m8gT
293 | Nd4jYFBh2MDeLY1H+PpiQsN+A/sP4ffK1nyHODdkXMj9ThHjC2h5l5b+zd20vm0Woi5UH11r/41v9F0N
294 | bVUsoXP6TMj/Ucn3GtIpvvKG3VBx+j88Ywu/zvS5Um+n8J8Tks4k080GhDQry2BchiClW24KJ2QvroWO
295 | C2c7SyEV4iVY21ea4Bdyz5zNZC0qne1/ZrzFGrQAC2qGMhVo/Fdn+ehrDxiMaZuJrqXn7fPCA+nanH9Y
296 | zCqXBlqt3luFH6KL5lCWNn0LR+mHyXlhjx5RN6J+hPSPj89/pm5Zzk65eubvNupz562uvtx3QXxFeovh
297 | xkf/FnX/CL8+f/y9IK1495kf+uzivyuGPGpkSBJvICal/wZ6ktJYYooX4YXeJv8FAAD//1mSjsL6CgAA
298 | `,
299 | },
300 |
301 | "/rater.js": {
302 | name: "rater.js",
303 | local: "ui/static/rater.js",
304 | size: 5999,
305 | modtime: 1592362285,
306 | compressed: `
307 | H4sIAAAAAAAC/8RYX2/juBF/96eYZQ+BhMR2vNmnxPJh0Qt6C9we0E2BPmSDCy2NbToypZKUHfc2X6F9
308 | KPrWT7efpOAfUZRWToxtgL4YFjm/+T9DDtOCSwW0UsUnqjCDBO4H00UhNsCyhDBeVmqoPwmkOZWytTQb
309 | AEwztq23KJc7FMN58Wi2AKYKHxUVSBtmxMhaFGklzb+02JQ5KkxIsVgQEMVOJhdQ5jTFVZFnKBJyzRUK
310 | 2BeVACuBzKbjmrMTJEvKjZC0EAJTNawpT/hcltOx3jfqjjO27eptTdLcar3NCqh9iQmR1XzDlLfffg7n
311 | lVIFJ7CleYUJ+frPfxAYd5VxDpGKKjykynSsfTkb3F8NBvhYFkJZSfDexUTA7wMAEydRpaoQUWxWANSK
312 | yZFMC4GQwPlVs/Yb5pBAVqTVBrkapQKpwusc9VdEMrYlcZt6xDhH8fNfPv4CSZMMHZq/VSj2N5ijUYL8
313 | IciEeFRw6xhIACGZOQ0BcFQK3CJXP+GCVrmKnOSab8FvDC5qjHEUT8eIN5IfcJ8VO94VzRYQ4egB9/Am
314 | ScCmEYlBoKoEvxq8joJPms8SFaBzbx0cK8ar72klKvA8U5rnc5o+tCLqRUICNUEDXxW7P1coFSt4JFmG
315 | so3tcVQrC+NWqInJSfKCpzs19SospNrnOPr7B57hIyQwnLiA2H5k6y95MfhWqvkYmUrUCpFwNegkurd9
316 | 00t+qLPF+BJOTuyfUY58qVYwg3P4EcglEDh1O+uC8YgAieESCLGZcO+0tzJNd7N55GP2CaVOLiu0Dpk1
317 | tpIoPhxpsLXyKkBbjjc6uM/h20kQMnCheW8InmPRjWErYiVVCoWuQbs9kmXOVDT+LMexc9nnz9JHbAGR
318 | N3u0oSpdRRx38AmX149l5JjFceyLOTCznX5f//MvctVDZBMsLfJCG0WWApF7wpbNh9O502Inru4Bc4nH
319 | aPbvYzQTmB2hl41KxhaLj0wq+oCyceCZYx/3s+mU2qTXNnd8PNU5+1uum6JcKWT8I1WCPUbMynLM69Bs
320 | qYCNIYAEbu9cUiwKAVGOCpjhDQymiasOW1lXDnTL7jSOnZ7eOfU9dG2haw11Qrvg87vbtcavT0/jPsmT
321 | Xsns9LRJrJa4yQFx6xDhm5T8oyUDD7hdwxAmd5AkTuYtMwtXh6B/XTGFsqQpQuJpoMPOFYipJd2hQs7h
322 | XiPF+9a4J2Dc6PzlS58WASnAj56PEeW0aZFcwkeqVqMN41FrPVQiBMMpTM4OUh5H5fgZqg6Rd8GTy+bw
323 | KLZ435XbxdSf3LpPvbGHkfZXkEUmxOexE+ck3NurX+ta6TiS2Q+/u79P7g5432qgvoZspb9YftbSOtFD
324 | 1WrPraGbyPWOttxUa72woY/XGVMSEnjX0opXm3ojzKmmi3uCmWfyKk7ZrViOEDFzAn/5Amv9p6lBq52s
325 | 5tp2Q3NyYmkOZC1cev2azGJcG9aFPQvJzNWa9Uvqh2yYPhZ9nchqfqYln2lesb+HamcayiTRdoXtRm8Z
326 | U9tBCElsTEdlJVc2UW4jBsMEJvFd0BYA1sNh89k5yNpcWpXVF8ONLR0dw04VdhRo7dZxPiopbqO14+HT
327 | Y9BT7HW5e4tCXzIuQ0c1Bt5/rwpNk+n673nmgcM6HuphPWh+XR0Z5npUERKj+k5F3C3zaTBwJYH5Ipjk
328 | SzOHCnP59COs+zRDqJ9VNbB/ap6Oy9kgeBQQuGW4a78KCKoYXwbPArZpmiE421KeIumM025wvlkVO7AX
329 | FQI0TVHKB9wnxJxGZDwb1LO6kWyk1EO6HcFrRuct+ITM3i8p49OxpeqFTFqQt2T2MxXZs4i3LcQFmf2p
330 | KJ5HXLQQ78jsmsp9iHjpGeDGBfSFZ4D/beT3WePakedDs+xaD8e/MKmQo4iIG7bJ2bfD9hvPu6QCufq1
331 | yLCet4FXee6bndwxla70eJ4amuBqRSUCuTHRvwyKvTVO+/k5aAEW+BNbMjU5AnneD317BHTSD704Avq2
332 | H/ruCOhFpzdA27FmI35hCg8r9xWebI55qHmf5xFxj2XxaFGIa5q6IwqS2SA4MHpyLc1Z+tDNtOcU/EbF
333 | X6vNHIXOR4kfuIpwpKhYonstiBuf2ktL/H9/0nnxNSfs09/zEuN6aP0Cs2WSzVnO1F5zWLEs82Py4bcE
334 | 19H7eZivHMmxzx9Hvz+0LK9fMA7Ny5bONZzvc0ZoyHd7I/CoPqX/GwAA//8x2/0nbxcAAA==
335 | `,
336 | },
337 |
338 | "/review_session.js": {
339 | name: "review_session.js",
340 | local: "ui/static/review_session.js",
341 | size: 2026,
342 | modtime: 1592361923,
343 | compressed: `
344 | H4sIAAAAAAAC/5xVTW/jNhC961dM1R5kwJUbBLnEloqgLdDDLrAb7z1mpLHFDU0q5Mhew9B/X5Ai9eE4
345 | OezFNok3b2Ye34z5vlaa4AwPDalHRqjnsEaxdT+hha1We4jThbbn9LuJl1FUKGkICPe1YISQwSZaVchK
346 | 1HkEsKpu83+xeLmHlamZBF5mcYnFS5yvFvYiXy2q2w54l3/RaqfRmDG49nfjgLs8Wi1Cjmi1Z7zDGjSG
347 | KxlDIZgx/RkKJYlxiTruMt049GuDhsZw4iTQ5qlubAJLm0ebZRThDydLiVvWCOrg8IgHjse1T3GOAJwS
348 | uilI6WTmbgCo4iZ9QgEZlKpo9igpLTQywv8E2lMSl/wQz5YTdMqlRP3/t8+fIOulXUYDxvSPkoHE4/BI
349 | yZipR6VKrpvnPSfIgElztHG5w4WcFZOlwI5iGjv3EbNxfhb84fP3fpnk71G/kJ8NDuzzA7S2hh0SoFcv
350 | 6KyRGi17AXusQftuxUtiPy4fJX1tUJ/WKNC9Wfy7c+Zsor69mrB5UyX+e8oZHJcF2PhdNcoyKNTTaTRK
351 | HPDB9ZgUTIhndlnpBAMZBNSEiJUHJgv0hnyHaQq6RtVXOY7jpsvN5c4aUjc47qupS0a4JkY4am567dm6
352 | ZXGGMHtzIEVMzEHgluagGXG5e6JTjXMwvEQDrU041tbbkG8hsUGQZRn8FfgBjlyW6phW3JDSp9T2FjwZ
353 | XNKd2uhjL/SLZ+qHzR9nVzL86WpuF/7cbpYf8/XrZsoXrn1bnUDaj9ZID9dnbKcyhr8vl8D9xbwtR1yD
354 | Jd8tLexNr5OV1l+lRcVFqVGmAuWOKlfFzaB2gLG6Rln+Y8FJ9+fgB9RTtoDC4JswjbVgBV6Jm/cYwQw5
355 | QKDq5t2BTaWOX72AifPL4D9mTrKAyWLR3T4xhdIYerDNXnp86O+K+7dMGAyGCn72S82alR0Zp2uDO7Jh
356 | X/wjmkZQMtpwb7RyBZ5qVNuucPjNGkE2+2fU8Wxi6etTnnT9Duq1URv9DAAA//9ZR4TB6gcAAA==
357 | `,
358 | },
359 |
360 | "/stats_graph.js": {
361 | name: "stats_graph.js",
362 | local: "ui/static/stats_graph.js",
363 | size: 2917,
364 | modtime: 1570480914,
365 | compressed: `
366 | H4sIAAAAAAAC/5RW3W7jNhO991MMCH8AlbUl2V9yk1guWmzdvUiKAim6GxRFrUi0zEIWDWpsayHo3Qv+
367 | SKLsjZu9cULO8JwzR+SQrNoLiZCyTXzIEZI8Lkt4xhjLX2S830I9AkhEUaI8JCgk9fQMAG556f/Ncogg
368 | Fclhxwr0E8liZD/nTI1+faZki7i/D4LT6eSf/u8LmQXzMAyD8piRCRD1x3sYoPma/5GX6MdpSkmphEwz
369 | pcSkNqMRQMYQmGHp9EiGB1l0QF1uyRA0CtW/Q/l6CiKT4CqRrEiZ1A7Qnnc4bZG0OdAiObgPTjgRhwJb
370 | Ij9nRYZbE+cboCa6gLlny3gYOWtPPMVtB6094qzAz2ra5dgynm3xMvGTnj/zuWT4I6LkrwdklBw5O/0k
371 | KjKBdQghjGvN2cC4NqDN2htI2sUy4wVEUAOK/T3MwgnkbIP3MLubgFRLzOSrQBQ7NQ2Nq5UXBZOfbWGm
372 | wKlF9RVQP5K9eGfpp7ZWW3SXjmLfDwz7QHmJscQv7Zf4M/zLH34oVqR91HyXKczOs8okzpnKc+oIgOrF
373 | U8vhuQtUrtofS6BY9ilwY6HO3K1eIIKnGLf+Lq6o7/tm3+ziPaU18AKZPMY5NJ5CbIeeBx9gdiHzpZVp
374 | PQsMcMJ4ThXTQKdJtvBKrdYy7TlaxS/nilfXFG9i1TusXjN4Q+3qutrVQO1GybXYVuwKpi1BK3U1kLqP
375 | ncM0EInlxLF2AjytlF69FtQIoiiCEH6A9dO4/kKx9JrJuH6hnTnNGu5h/fhWUCMNC/jtqprWtndr2bxQ
376 | W/ylFDfWKnGkpOK8f2kllvHCHv0l14uEyyRnkFQRaakIJF/VyC2dgIzIHQmW3/Lg43uYBzvoKq9T5yWt
377 | w3uM8wP73pJtDGC9QFYhDPjPy1adI2zIclzn8SvLHT8WgVr9TTf+eJ8s14//FtV70msyEz6KFa9YSueX
378 | qoY3hjmVvz89QgTr0SIDlHFRboTcRUT/m8fI6Lh2urjadn1fbjyipC4y88iISFzxkhj1i5wXDKpZREIC
379 | X2dKs9MFGgLVvJvT7VZVNr9IWy4CBWQxWzNC64LbVz7A7E7bULATfFTKrYv2griBWRiGxgkfxaNQnUTl
380 | PaPkRUY7t4ZU5xLfTauvju8hXQTZcjSwU7+S7MZobdXdLtVbQLUa/x/BC0qI15DAZIxrff6admC2X3OF
381 | oN3FlxT7NxhSh+A4xFc/a/O+akajzaFIkIsCzs5L/wiFrThIdTq6INzA/NbrtrE+TLaj8aN6IOgFAcxv
382 | +8hOpF3kfzZi34/me1ATW0Ywv1U9dlzrW2iTCyFpyo9ek4JqsISoW4wqvKXpxuN6J9Jma4MjfY6a0b8B
383 | AAD//xIbl/xlCwAA
384 | `,
385 | },
386 |
387 | "/stats_list.js": {
388 | name: "stats_list.js",
389 | local: "ui/static/stats_list.js",
390 | size: 2147,
391 | modtime: 1570480914,
392 | compressed: `
393 | H4sIAAAAAAAC/5RVTW/bRhC981cM1i5AtjUZODrZJIvALpoAzqXuzTCiBXckbkztssuh3EDQfy/2gxQp
394 | K45zEcjZt2/mzYwe5abVhuCeOHV/Gd7WsDJ6AyzNOhv6srax9GvHrqOo0qojINy0DSeEApZRXiMXaMoI
395 | IK/fl7dYPV1B3rVcgRQFcxwXAqsnVuaZDZd5Vr93cCG3UDW86wZYhw1WdGH0M7MAy7gob7gR3RXkWb0I
396 | QQ+b0DeyI0fvDhx3JuS2jPJsqC7KN1yqIV2lFXGp0LCh7pveGFShDbDS5oWGihtxpMHe7ZtQVSP9gy2Q
397 | jFbr8o53BH/jVuIzCvhAVkQ4GpFDDhNgF5zGJJ43G4hPZPikCM2WN68xy4D5CdpbuVrJqm/o22vEYkSd
398 | ps4z25s8s40vo+V1FOF/btUErnjfkB+G7/id7Ah2EYDbMNNXpE2cuAgA1bJLv2ADBQhd9RtUlFYGOeGf
399 | Ddq3mAm5Zcn1DJ1KpdB8/OfzHRTjyk4hbrGhAIXPk+2Pj2n+7dF8u3ebpU3MrBqWpLxtUYmbWjYintCl
400 | 6CtyJHu7IGskCMFRkEHqjRpTjNgObXOqp9j+HKs/LuRs8tdKZmJtaMbpkLH7nbO6EBQeMNVtUAk0nwg3
401 | XXzQMg8HJu8JA9OE1/PJFfjMaYNqTTUURQHvktCD62iStNVtb4fkNYZ6X1Z1f1Dz8O4xmVF8t03OIpJU
402 | q6rmam2tK94BcWPHs0+gKIOaqZ6hMelKKhHHO7AWENDu0UrxHOmWNz2GYr9XbjjeH/p5UvHb5h4ETefu
403 | DkIF6Ya3RyUvc92S1Ko839noPs/C+zIZLn3VUsWMvZi41+DZfg+z3s8XYLAZKOAzpzo1ulcivlzArx7/
404 | wAazYo+hEfOL92SkWkMRaolHwrKAywX8AcvznWNeNVqbw3EGl4tkL2AJV8BYAr8FguX5bsT8ApeLfb18
405 | 26o4o5931oZet4WzqYHPL1t/ueWEcSgstMN+HYaPwwdij+40SUnf6Yo36NvxIzc6G919nnPe1B9wTIz8
406 | xEI9sMPngD2+MNB05iEP7KPsSBtZ2Tn7NdpH/wcAAP//jj7YDGMIAAA=
407 | `,
408 | },
409 |
410 | "/": {
411 | name: "/",
412 | local: `ui/static`,
413 | isDir: true,
414 | },
415 | }
416 |
417 | var _escDirs = map[string][]os.FileInfo{
418 |
419 | "ui/static": {
420 | _escData["/deck_list.js"],
421 | _escData["/index.html"],
422 | _escData["/main.css"],
423 | _escData["/main.js"],
424 | _escData["/rater.js"],
425 | _escData["/review_session.js"],
426 | _escData["/stats_graph.js"],
427 | _escData["/stats_list.js"],
428 | },
429 | }
430 |
--------------------------------------------------------------------------------
/ui/static/.eslintignore:
--------------------------------------------------------------------------------
1 | babel.config.js
2 |
--------------------------------------------------------------------------------
/ui/static/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es6: true
5 | },
6 | extends: ["eslint:recommended", "plugin:jest/recommended"],
7 | globals: {
8 | Atomics: "readonly",
9 | SharedArrayBuffer: "readonly"
10 | },
11 | parserOptions: {
12 | ecmaVersion: 2018,
13 | sourceType: "module"
14 | },
15 | rules: {}
16 | };
17 |
--------------------------------------------------------------------------------
/ui/static/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | [
4 | "@babel/preset-env",
5 | {
6 | targets: {
7 | node: "current"
8 | }
9 | }
10 | ]
11 | ]
12 | };
13 |
--------------------------------------------------------------------------------
/ui/static/deck_list.js:
--------------------------------------------------------------------------------
1 | export default class DeckList {
2 | constructor() {
3 | this._el = document.createElement("ul");
4 | this._el.classList.add("deck-list");
5 | }
6 |
7 | get element() {
8 | return this._el;
9 | }
10 |
11 | set decks(decks) {
12 | this._decks = decks;
13 | this._renderItems();
14 | }
15 |
16 | _renderItems() {
17 | this._el.innerHTML = this._decks
18 | .sort((a, b) => a.name > b.name)
19 | .map(
20 | ({ name, cards_ready, next_review_at }) =>
21 | `
22 | ${name}
23 |
24 | ${this._reviewStats(cards_ready, new Date(next_review_at))}
25 |
26 |
27 |
31 |
32 | `
33 | )
34 | .join("");
35 | }
36 |
37 | _reviewStats(ready, next) {
38 | if (ready > 0) return ready;
39 |
40 | const diff = next - new Date();
41 | if (diff < 1000) return "available now";
42 |
43 | let d = next;
44 | d = [
45 | "0" + d.getDate(),
46 | "0" + (d.getMonth() + 1),
47 | "" + d.getFullYear(),
48 | "0" + d.getHours(),
49 | "0" + d.getMinutes()
50 | ].map(component => component.slice(-2));
51 |
52 | return d.slice(0, 3).join(".") + " " + d.slice(3).join(":");
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/ui/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Deck App
9 |
10 |
11 |
12 |
13 |
14 |
Decks:
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/ui/static/main.css:
--------------------------------------------------------------------------------
1 | html {
2 | font-family: sans-serif;
3 | font-size: 21px;
4 | }
5 |
6 | body {
7 | max-width: 700px;
8 | margin: 0 auto;
9 | padding: 1.25em 0.889em;
10 |
11 | background: #eceff4;
12 | font-family: sans-serif;
13 | line-height: 1.45;
14 | color: #4c566a;
15 | }
16 |
17 | h1,
18 | h2,
19 | h3,
20 | h4,
21 | h5 {
22 | margin: 0 0 1rem;
23 | font-family: sans-serif;
24 | line-height: 1.15;
25 | }
26 |
27 | h1 {
28 | font-size: 1.802em;
29 | }
30 |
31 | .title {
32 | text-align: center;
33 | }
34 |
35 | .src {
36 | font-size: 50%;
37 | }
38 |
39 | h2 {
40 | font-size: 1.602em;
41 | }
42 |
43 | h3 {
44 | font-size: 1.424em;
45 | }
46 |
47 | h4 {
48 | font-size: 1.266em;
49 | }
50 |
51 | h5 {
52 | font-size: 1.125em;
53 | }
54 |
55 | small,
56 | .text_small {
57 | font-size: 0.889em;
58 | }
59 |
60 | p,
61 | form {
62 | margin-top: 0;
63 | margin-bottom: 1.25em;
64 | }
65 |
66 | li {
67 | margin-bottom: 0.625em;
68 | }
69 |
70 | ul,
71 | ol {
72 | margin-top: 0;
73 | margin-bottom: 1.25em;
74 | padding: 0;
75 | margin-left: 1.25em;
76 | }
77 |
78 | ul li:last-of-type,
79 | ol li:last-of-type {
80 | margin-bottom: 0;
81 | }
82 |
83 | code {
84 | font-size: 0.889em;
85 | background-color: rgba(0, 0, 0, 0.08);
86 | padding: 5px 6px;
87 | border-radius: 9px;
88 | margin: 0 1px;
89 | }
90 |
91 | nav {
92 | display: flex;
93 | flex-direction: column;
94 | }
95 |
96 | main {
97 | display: flex;
98 | justify-content: center;
99 | flex-direction: column;
100 | }
101 |
102 | a {
103 | color: #4c566a;
104 | }
105 | a:visited {
106 | color: inherit;
107 | }
108 |
109 | button,
110 | input[type="submit"] {
111 | -webkit-appearance: none;
112 | height: 35px;
113 | padding: 0 0.5rem;
114 |
115 | background: #eceff4;
116 | border: none;
117 | border-radius: 3px;
118 | }
119 |
120 | .container {
121 | padding: 1.25em;
122 | background: white;
123 | border-radius: 9px;
124 | box-shadow: rgba(184, 194, 215, 0.25) 0px 4px 6px,
125 | rgba(184, 194, 215, 0.1) 0px 5px 7px;
126 | }
127 |
128 | .container *:last-child {
129 | margin-bottom: 0;
130 | }
131 |
132 | .session {
133 | display: flex;
134 | flex-direction: column;
135 | align-items: center;
136 | }
137 |
138 | .session > div {
139 | width: 100%;
140 | }
141 |
142 | .session form {
143 | display: flex;
144 | }
145 |
146 | .session .rating-form {
147 | flex-direction: column;
148 | align-items: center;
149 | }
150 |
151 | .input-form {
152 | width: 100%;
153 | flex-wrap: wrap;
154 | justify-content: center;
155 | }
156 |
157 | .input-form .submit-button {
158 | height: 35px;
159 | width: 35px;
160 | }
161 |
162 | .input-form .answer-box {
163 | flex: 1;
164 | margin: 0 0.5em 0 0;
165 | position: relative;
166 | border: 1px solid #d8dee9;
167 | border-radius: 5px;
168 | padding: 0.8em;
169 | }
170 | .input-form .input-area {
171 | display: flex;
172 | flex-direction: column;
173 | align-items: center;
174 | }
175 |
176 | .answer-box textarea {
177 | width: 100%;
178 | background: none;
179 | border: none;
180 | outline: none;
181 | resize: none;
182 | font-family: monospace;
183 | font-size: 1em;
184 | line-height: 1.45;
185 | color: inherit;
186 | }
187 | .answer-box > span {
188 | position: absolute;
189 | top: 0;
190 | left: 0;
191 | right: 0;
192 | bottom: 0;
193 | pointer-events: none;
194 |
195 | font-family: monospace;
196 | font-size: 1em;
197 | background: white;
198 | border-radius: 5px;
199 | padding: 0.8em;
200 | }
201 | .answer-box .input-mistake {
202 | background: rgba(191, 97, 106, 0.5);
203 | }
204 | .answer-box .input-correct {
205 | background: rgba(163, 190, 140, 0.5);
206 | }
207 |
208 | .session p {
209 | margin-bottom: 0;
210 | }
211 |
212 | .result {
213 | text-align: center;
214 | }
215 |
216 | .deck-list {
217 | list-style: none;
218 | margin-left: 0;
219 | }
220 |
221 | .deck-list li {
222 | display: flex;
223 | }
224 |
225 | .deck-list a:first-of-type {
226 | margin-right: 1em;
227 | }
228 |
229 | .deck-list div {
230 | margin-left: auto;
231 | }
232 |
233 | .deck-list .stats-link {
234 | display: inline-flex;
235 | align-items: center;
236 | margin-left: 1em;
237 | }
238 |
239 | .deck-list svg {
240 | fill: #4c566a;
241 | }
242 |
243 | .stats-select-row {
244 | display: flex;
245 | align-items: center;
246 | margin-bottom: 0.5rem;
247 | }
248 |
249 | .stats-select-row h4 {
250 | margin: 0 0.5rem 0 0;
251 | }
252 |
253 | code {
254 | background: #d8dee9;
255 | }
256 |
257 | #question {
258 | font-weight: normal;
259 | }
260 |
261 | .src {
262 | font-size: 50%;
263 | }
264 |
265 | .stats-graph {
266 | height: 300px;
267 | }
268 |
269 | .stats-graph .axis line {
270 | stroke: #d8dee9;
271 | }
272 |
273 | .stats-graph .axis text {
274 | font-size: 12px;
275 | fill: #d8dee9;
276 | }
277 |
278 | .stats-graph .axis text:first-of-type {
279 | text-anchor: start;
280 | }
281 |
282 | .stats-graph .interval path {
283 | stroke: #b48ead;
284 | }
285 |
286 | .stats-graph .interval circle {
287 | fill: #b48ead;
288 | }
289 |
290 | .stats-graph .interval text {
291 | fill: #b48ead;
292 | }
293 |
294 | .stats-graph .factor path {
295 | stroke: #a3be8c;
296 | }
297 |
298 | .stats-graph .factor circle {
299 | fill: #a3be8c;
300 | }
301 |
302 | .stats-graph .factor text {
303 | fill: #a3be8c;
304 | }
305 |
306 | .stats-graph .graph path {
307 | fill: none;
308 | }
309 |
310 | .stats-graph .graph circle {
311 | stroke: none;
312 | }
313 |
314 | .stats-graph .graph text {
315 | font-size: 14px;
316 | font-weight: bold;
317 | text-anchor: middle;
318 | }
319 |
320 | .stats-graph text:last-of-type {
321 | text-anchor: end;
322 | }
323 |
324 | @media screen and (max-width: 500px) {
325 | .deck-list {
326 | margin-left: 0;
327 | }
328 | }
329 |
--------------------------------------------------------------------------------
/ui/static/main.js:
--------------------------------------------------------------------------------
1 | import DeckList from "./deck_list.js";
2 | import ReviewSession from "./review_session.js";
3 | import StatsList from "./stats_list.js";
4 |
5 | class App {
6 | constructor() {
7 | this.deckList = new DeckList();
8 | this._decks = document.getElementById("decks");
9 | this._decks.appendChild(this.deckList.element);
10 |
11 | this.statsList = new StatsList();
12 | this._stats = document.getElementById("stats");
13 | this._stats.appendChild(this.statsList.element);
14 |
15 | this.reviewSession = new ReviewSession();
16 | this.reviewSession.resolveAnswer = () => this._resolveAnswer();
17 | this.reviewSession.advanceSession = async score => {
18 | const session = await this._advanceSession(score);
19 | this.reviewSession.session = session;
20 | };
21 | this._session = document.getElementById("session");
22 | this._session.appendChild(this.reviewSession.element);
23 | }
24 |
25 | render() {
26 | window.onpopstate = () => {
27 | this.showDecks();
28 | };
29 |
30 | const hash = window.location.hash;
31 | window.history.replaceState({}, "DeckApp", "/");
32 | if (!hash) {
33 | this.showDecks();
34 | return;
35 | }
36 |
37 | if (hash.startsWith("#stats")) {
38 | this.showStats(hash.replace("#stats-", ""));
39 | } else {
40 | this.startSession(hash.replace("#", ""));
41 | }
42 | }
43 |
44 | async showDecks() {
45 | this._decks.style.display = null;
46 | this._session.style.display = "none";
47 | this._stats.style.display = "none";
48 |
49 | this.deckList.decks = await this._fetchDecks();
50 | }
51 |
52 | async startSession(deck, cardsReady) {
53 | if (cardsReady === 0) return;
54 |
55 | window.history.pushState({ deck }, `Review: ${deck}`, `#${deck}`);
56 | this._decks.style.display = "none";
57 | this._session.style.display = null;
58 | this._stats.style.display = "none";
59 |
60 | this.reviewSession.deck = deck;
61 | this.reviewSession.session = await this._startSession(deck);
62 | }
63 |
64 | async showStats(deck) {
65 | window.history.pushState({ deck }, `Stats: ${deck}`, `#stats-${deck}`);
66 |
67 | this._decks.style.display = "none";
68 | this._session.style.display = "none";
69 | this._stats.style.display = null;
70 |
71 | this.statsList.deck = deck;
72 | this.statsList.stats = await this._fetchStats(deck);
73 | }
74 |
75 | async _request(path, options = {}) {
76 | const res = await window.fetch(path, options);
77 | if (res.ok) return await res.json();
78 |
79 | alert(`Failed to fetch: ${await res.text()}`);
80 | return null;
81 | }
82 |
83 | _fetchDecks() {
84 | return this._request("decks");
85 | }
86 |
87 | _fetchStats(deck) {
88 | return this._request(`stats/${deck}`);
89 | }
90 |
91 | _startSession(deck) {
92 | return this._request(`start/${deck}`, {
93 | method: "POST"
94 | });
95 | }
96 |
97 | _resolveAnswer() {
98 | return this._request("resolve");
99 | }
100 |
101 | _advanceSession(score) {
102 | return this._request("advance", {
103 | method: "POST",
104 | body: JSON.stringify({ score })
105 | });
106 | }
107 | }
108 |
109 | window.app = new App();
110 | window.app.render();
111 |
--------------------------------------------------------------------------------
/ui/static/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "test": "jest"
4 | },
5 | "devDependencies": {
6 | "@babel/core": "^7.10.2",
7 | "@babel/preset-env": "^7.10.2",
8 | "babel-jest": "^26.0.1",
9 | "eslint": "^6.8.0",
10 | "eslint-plugin-import": "^2.21.2",
11 | "eslint-plugin-jest": "^22.21.0",
12 | "eslint-plugin-node": "^10.0.0",
13 | "eslint-plugin-promise": "^4.2.1",
14 | "eslint-plugin-standard": "^4.0.1",
15 | "jest": "^26.0.1"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/ui/static/rater.js:
--------------------------------------------------------------------------------
1 | const autoRated = `
2 |
12 | `;
13 |
14 | export class AutoRater {
15 | constructor() {
16 | this.score = 0;
17 | this._el = document.createElement("div");
18 | this._el.innerHTML = autoRated;
19 | this._el.querySelector("#input-form").onsubmit = e => {
20 | e.preventDefault();
21 | this._onSubmit(this.score);
22 | };
23 | this._el.querySelector("#input").onkeydown = e => {
24 | if (e.key !== "Enter") return;
25 |
26 | e.preventDefault();
27 | this._onSubmit(this.score);
28 | };
29 | }
30 |
31 | get element() {
32 | return this._el;
33 | }
34 |
35 | set onSubmit(callback) {
36 | this._onSubmit = callback;
37 | }
38 |
39 | showQuestion(sides) {
40 | this._el.querySelector("#answer-state").innerHTML = " ";
41 | this._el.querySelector("#correct-answer").innerHTML = " ";
42 | this._el.querySelector("#correct-answer").style.zIndex = -1;
43 |
44 | const input = this._el.querySelector("#input");
45 | input.value = "";
46 | input.placeholder = `Enter your answer${
47 | sides && sides.length > 0 ? ": " + sides.join(" ") : ""
48 | }`;
49 |
50 | input.focus();
51 | }
52 |
53 | showResult(answer) {
54 | const userInput = this._el.querySelector("#input").value;
55 | const answerState = this._el.querySelector("#answer-state");
56 | const correctAnswer = this._el.querySelector("#correct-answer");
57 |
58 | const pattern = answer.split(/\s/).join("\\s");
59 | if (userInput.match(new RegExp(pattern))) {
60 | answerState.innerHTML = "✓";
61 | answerState.style.color = "green";
62 | correctAnswer.innerHTML = " ";
63 | this.score = 1;
64 | } else {
65 | answerState.innerHTML = "✕";
66 | answerState.style.color = "red";
67 | correctAnswer.innerHTML = this._diffMistakes(userInput, answer);
68 | correctAnswer.style.zIndex = 1;
69 | this.score = 0;
70 | }
71 | }
72 |
73 | _levenshteinMatrix(input, correct) {
74 | var matrix = [];
75 |
76 | for (let i = 0; i <= input.length; matrix[i] = [i++]);
77 | for (let j = 0; j <= correct.length; matrix[0][j] = j++);
78 |
79 | for (let i = 1; i <= input.length; i++) {
80 | for (let j = 1; j <= correct.length; j++) {
81 | const isCorrect = correct[j - 1] === input[i - 1];
82 | const isCorrectWhitespace =
83 | correct[j - 1].match(/\s/) && input[i - 1].match(/\s/);
84 | matrix[i][j] =
85 | isCorrect || isCorrectWhitespace
86 | ? matrix[i - 1][j - 1]
87 | : Math.min(
88 | matrix[i - 1][j - 1] + 1,
89 | matrix[i][j - 1] + 1,
90 | matrix[i - 1][j] + 1
91 | );
92 | }
93 | }
94 |
95 | return matrix;
96 | }
97 |
98 | _diffMistakes(input, correct) {
99 | if (!input || input.length === 0)
100 | return `${correct}`;
101 |
102 | const matrix = this._levenshteinMatrix(input, correct);
103 | let i = input.length,
104 | j = correct.length,
105 | diff = [],
106 | maxEdits = 4;
107 |
108 | const numEdits = matrix[i][j];
109 | if (numEdits > maxEdits)
110 | return `${correct}`;
111 |
112 | while (i > 0 || j > 0) {
113 | const sub = i > 0 && j > 0 ? matrix[i - 1][j - 1] : maxEdits,
114 | ins = j > 0 ? matrix[i][j - 1] : maxEdits,
115 | del = i > 0 ? matrix[i - 1][j] : maxEdits,
116 | min = Math.min(sub, ins, del);
117 |
118 | if (min === sub) {
119 | if (sub == matrix[i][j]) {
120 | diff.push(input[(i -= 1)]);
121 | j--;
122 | } else {
123 | diff.push(
124 | `${
125 | input[(i -= 1)]
126 | }${correct[(j -= 1)]}`
127 | );
128 | }
129 | } else if (min === ins) {
130 | diff.push(`${correct[(j -= 1)]}`);
131 | } else {
132 | diff.push(`${input[(i -= 1)]}`);
133 | }
134 | }
135 | return diff.reverse().join("");
136 | }
137 | }
138 |
139 | const selfRated = `
140 |
141 |  
142 |
143 |
144 |
154 | `;
155 |
156 | export class SelfRater {
157 | constructor() {
158 | this._el = document.createElement("div");
159 | this._el.innerHTML = selfRated;
160 |
161 | document.addEventListener("keydown", e => {
162 | if (!this._el.parentNode) return null;
163 |
164 | switch (e.code) {
165 | case "Space":
166 | return this._onSubmit();
167 | case "Digit1":
168 | return this._onSubmit(0);
169 | case "Digit2":
170 | return this._onSubmit(1);
171 | case "Digit3":
172 | return this._onSubmit(2);
173 | case "Digit4":
174 | return this._onSubmit(3);
175 | }
176 | return null;
177 | });
178 | this._el.querySelector("#review-form").onsubmit = e => {
179 | e.preventDefault();
180 | this._onSubmit();
181 | };
182 | this._el.querySelectorAll("button").forEach(input =>
183 | input.addEventListener("click", e => {
184 | e.preventDefault();
185 | this._onSubmit(Number.parseInt(e.target.value));
186 | })
187 | );
188 | }
189 |
190 | get element() {
191 | return this._el;
192 | }
193 |
194 | set onSubmit(callback) {
195 | this._onSubmit = callback;
196 | }
197 |
198 | showQuestion() {
199 | this._el.querySelector("#self-answer").innerHTML = " ";
200 | this._el.querySelector("#rating").style.visibility = "hidden";
201 | this._el.querySelector("#advance").style.visibility = "visible";
202 | }
203 |
204 | showResult(answer) {
205 | const correctAnswer = this._el.querySelector("#self-answer");
206 | correctAnswer.innerHTML = answer;
207 |
208 | this._el.querySelector("#rating").style.visibility = "visible";
209 | this._el.querySelector("#advance").style.visibility = "hidden";
210 | }
211 | }
212 |
--------------------------------------------------------------------------------
/ui/static/review_session.js:
--------------------------------------------------------------------------------
1 | import { AutoRater, SelfRater } from "./rater.js";
2 |
3 | const template = `
4 |
5 | Deck:
6 | Progress:
7 |
8 |
9 |
10 |
11 |
12 | `;
13 |
14 | export default class ReviewSession {
15 | constructor() {
16 | this._el = document.createElement("div");
17 | this._el.innerHTML = template;
18 |
19 | this.selfRater = new SelfRater();
20 | this.selfRater.onSubmit = answer =>
21 | this._handleRater(this.selfRater, answer);
22 |
23 | this.autoRater = new AutoRater();
24 | this.autoRater.onSubmit = answer =>
25 | this._handleRater(this.autoRater, answer);
26 | }
27 |
28 | get element() {
29 | return this._el;
30 | }
31 |
32 | set deck(deck) {
33 | this._el.querySelector("#deck").innerHTML = deck;
34 | }
35 |
36 | set session(session) {
37 | this._session = session;
38 | this._render();
39 | }
40 |
41 | set resolveAnswer(callback) {
42 | this._resolveAnswer = callback;
43 | }
44 |
45 | set advanceSession(callback) {
46 | this._advanceSession = callback;
47 | }
48 |
49 | _render() {
50 | this.isAnswering = true;
51 | this._updateState();
52 | }
53 |
54 | _updateState() {
55 | const { question, total, left, rating_type, sides } = this._session;
56 |
57 | if (left === 0) {
58 | window.history.back();
59 | return;
60 | }
61 |
62 | this._el.querySelector("#progress").innerHTML = `${total - left}/${total}`;
63 | this._el.querySelector("#question").innerHTML = question;
64 |
65 | const rater = rating_type === "self" ? this.selfRater : this.autoRater;
66 | const session = this._el.querySelector("#session");
67 | if (session.children.length === 1) {
68 | session.appendChild(rater.element);
69 | } else {
70 | session.replaceChild(rater.element, session.lastChild);
71 | }
72 | rater.showQuestion(sides);
73 | }
74 |
75 | async _handleRater(rater, score) {
76 | if (this.isAnswering) {
77 | this.isAnswering = false;
78 | const { answer } = await this._resolveAnswer();
79 | rater.showResult(answer);
80 | } else {
81 | if (typeof score !== "number") return;
82 | this._advanceSession(score);
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/ui/static/stats_graph.js:
--------------------------------------------------------------------------------
1 | export default class StatsGraph {
2 | constructor() {
3 | this._el = document.createElementNS("http://www.w3.org/2000/svg", "svg");
4 | this._el.classList.add("stats-graph");
5 | }
6 |
7 | get element() {
8 | return this._el;
9 | }
10 |
11 | set stats(stats) {
12 | this._stats = stats;
13 | this._renderGraph();
14 | }
15 |
16 | _renderGraph() {
17 | const stats = this._stats;
18 | const count = stats.length;
19 | if (count < 2) return;
20 |
21 | const width = this._el.clientWidth;
22 | const height = this._el.clientHeight;
23 | this._el.setAttribute("viewBox", `0 0 ${width} ${height}`);
24 |
25 | const margin = { top: 10, left: 15, right: 10, bottom: 15 };
26 | const innerWidth = width - margin.left - margin.right;
27 | const innerHeight = height - margin.top - margin.bottom;
28 |
29 | const startX = stats[0].ts;
30 | const endX = stats[count - 1].ts;
31 | const scaleX = innerWidth / (endX - startX);
32 | const X = ts => (ts - startX) * scaleX;
33 |
34 | const maxY = Math.max(...stats.map(({ interval }) => interval)) + 1;
35 | const scaleY = innerHeight / Math.ceil(maxY);
36 | const Y = interval => (maxY - interval) * scaleY;
37 |
38 | const maxF = Math.max(...stats.map(({ factor }) => factor)) + 1;
39 | const scaleF = innerHeight / Math.ceil(maxF);
40 | const fY = factor => (maxF - factor) * scaleF;
41 |
42 | const path = this._stats.map(({ ts, interval }, idx) =>
43 | idx === 0 ? `M${X(ts)},${Y(interval)}` : `L${X(ts)},${Y(interval)}`
44 | );
45 | const fPath = this._stats.map(({ ts, factor }, idx) =>
46 | idx === 0 ? `M${X(ts)},${fY(factor)}` : `L${X(ts)},${fY(factor)}`
47 | );
48 |
49 | const dots = this._stats.map(
50 | ({ ts, interval }) => ``
51 | );
52 | const fDots = this._stats.map(
53 | ({ ts, factor }) => ``
54 | );
55 |
56 | const values = this._stats.map(
57 | ({ ts, interval }) =>
58 | `${label(interval)}`
59 | );
60 | const fValues = this._stats.map(
61 | ({ ts, factor }) =>
62 | `${factor.toFixed(2)}`
63 | );
64 |
65 | this._el.innerHTML = `
66 |
67 |
68 |
69 | ${new Date(
70 | startX * 1000
71 | ).toLocaleDateString()}
72 | ${new Date(
73 | endX * 1000
74 | ).toLocaleDateString()}
75 |
76 |
77 |
78 |
79 | ${fDots}
80 | ${fValues}
81 |
82 |
83 |
84 |
85 | ${dots}
86 | ${values}
87 |
88 | `;
89 | }
90 | }
91 |
92 | function label(interval) {
93 | const hours = (interval * 24).toFixed();
94 | const div = hours / 24;
95 | const mod = hours % 24;
96 | return (
97 | (hours >= 24 ? `${Math.floor(div)}d ` : "") + (mod > 0 ? `${mod}h` : "")
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/ui/static/stats_list.js:
--------------------------------------------------------------------------------
1 | import StatsGraph from "./stats_graph.js";
2 |
3 | const template = `
4 |
5 | Deck:
6 |
7 |
Cards:
8 |
9 |
10 |
11 |
12 |
13 | Current Stats for
14 |
15 |
16 | -
17 | Last Reviewed At:
18 |
19 |
20 | -
21 | Interval:
22 |
23 |
24 | -
25 | Difficulty:
26 |
27 |
28 |
29 |
30 | `;
31 |
32 | export default class StatsList {
33 | constructor() {
34 | this._el = document.createElement("div");
35 | this._el.innerHTML = template;
36 | this._graph = new StatsGraph();
37 | this._el.querySelector("main").appendChild(this._graph.element);
38 | }
39 |
40 | get element() {
41 | return this._el;
42 | }
43 |
44 | set deck(deck) {
45 | this._el.querySelector("#stats-deck").innerHTML = deck;
46 | }
47 |
48 | set stats(stats) {
49 | this._stats = stats;
50 | this._renderItems();
51 | }
52 |
53 | _renderItems() {
54 | const stats = this._stats;
55 | if (stats.length === 0) return;
56 |
57 | this._populateSelect(stats);
58 | this._renderStats(stats[0]);
59 |
60 | this._el.querySelector("#stats-list").onchange = ({ target }) => {
61 | const stat = stats.find(({ card }) => card === target.value);
62 | this._renderStats(stat);
63 | };
64 | }
65 |
66 | _populateSelect(stats) {
67 | this._el.querySelector("#stats-list").innerHTML = stats
68 | .map(({ card }) => ``)
69 | .join("");
70 | }
71 |
72 | _renderStats({ card, stats }) {
73 | const interval = Math.round(24 * stats["Interval"]);
74 | const intervalString =
75 | (interval >= 24 ? `${Math.floor(interval / 24)}d ` : "") +
76 | `${interval % 24}h`;
77 |
78 | this._el.querySelector("#stats-card").innerHTML = card;
79 | this._el.querySelector("#reviewed-at").innerHTML = new Date(
80 | stats["LastReviewedAt"]
81 | ).toLocaleString();
82 | this._el.querySelector("#interval").innerHTML = intervalString;
83 | this._el.querySelector("#difficulty").innerHTML = stats["Difficulty"];
84 | this._graph.stats = stats["Historical"];
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/ui/static/tests/deck_list.test.js:
--------------------------------------------------------------------------------
1 | import DeckList from "../deck_list.js";
2 |
3 | test("render", () => {
4 | const deckList = new DeckList();
5 | expect(deckList.element).not.toBeNull();
6 |
7 | deckList.decks = [
8 | { name: "foo", cards_ready: 10, next_review_at: new Date().toString() },
9 | { name: "bar", cards_ready: 10, next_review_at: new Date().toString() }
10 | ];
11 |
12 | const el = deckList.element;
13 | expect(el.children.length).toEqual(2);
14 |
15 | const child = el.children[0];
16 | expect(child.querySelector("a").text).toEqual("foo");
17 | expect(child.querySelector("code").innerHTML).toEqual("10");
18 | });
19 |
20 | test("render not ready", () => {
21 | const deckList = new DeckList();
22 | deckList.decks = [
23 | { name: "foo", cards_ready: 0, next_review_at: new Date(0).toString() }
24 | ];
25 |
26 | const child = deckList.element.children[0];
27 | expect(child.querySelector("code").innerHTML).toEqual("available now");
28 | });
29 |
30 | test("deck click", () => {
31 | const deckList = new DeckList();
32 | deckList.decks = [
33 | { name: "foo", cards_ready: 10, next_review_at: new Date(0) }
34 | ];
35 |
36 | const el = deckList.element;
37 | let event = {};
38 | window.app = {
39 | startSession: (deck, count) => {
40 | event = { deck, count };
41 | }
42 | };
43 | el.querySelector("a").click();
44 |
45 | expect(event.deck).toEqual("foo");
46 | expect(event.count).toEqual(10);
47 | });
48 |
49 | test("stats click", () => {
50 | const deckList = new DeckList();
51 | deckList.decks = [
52 | { name: "foo", cards_ready: 10, next_review_at: new Date(0) }
53 | ];
54 |
55 | const el = deckList.element;
56 | let event = {};
57 | window.app = {
58 | showStats: deck => {
59 | event = { deck };
60 | }
61 | };
62 | el.querySelector(".stats-link").click();
63 |
64 | expect(event.deck).toEqual("foo");
65 | });
66 |
--------------------------------------------------------------------------------
/ui/static/tests/review_sessions.test.js:
--------------------------------------------------------------------------------
1 | import ReviewSession from "../review_session.js";
2 |
3 | describe("auto rater", () => {
4 | const session = {
5 | total: 10,
6 | left: 5,
7 | question: "foo"
8 | };
9 |
10 | test("render", () => {
11 | const reviewSession = new ReviewSession();
12 | expect(reviewSession.element).not.toBeNull();
13 |
14 | reviewSession.deck = "Test";
15 | reviewSession.session = session;
16 |
17 | const el = reviewSession.element;
18 | expect(el.querySelector("#deck").innerHTML).toEqual("Test");
19 | expect(el.querySelector("#progress").innerHTML).toEqual("5/10");
20 | expect(el.querySelector("#answer-state").innerHTML).toEqual(" ");
21 | expect(el.querySelector("#correct-answer").innerHTML).toEqual(" ");
22 | expect(el.querySelector("#input").placeholder).toEqual("Enter your answer");
23 | });
24 |
25 | test("render - sides", () => {
26 | const reviewSession = new ReviewSession();
27 | expect(reviewSession.element).not.toBeNull();
28 |
29 | reviewSession.deck = "Test";
30 | reviewSession.session = { ...session, sides: ["reading", "meaning"] };
31 |
32 | const el = reviewSession.element;
33 | expect(el.querySelector("#input").placeholder).toEqual(
34 | "Enter your answer: reading meaning"
35 | );
36 | });
37 |
38 | test("submit incorrect", async () => {
39 | const reviewSession = new ReviewSession();
40 | reviewSession.session = session;
41 | reviewSession.resolveAnswer = () => ({ answer: "bar" });
42 |
43 | let rating = null;
44 | reviewSession.advanceSession = r => {
45 | rating = r;
46 | };
47 |
48 | const el = reviewSession.element;
49 | el.querySelector("#input-form").onsubmit({ preventDefault: () => {} });
50 | await new Promise(resolve => window.setTimeout(resolve, 100));
51 | expect(el.querySelector("#answer-state").innerHTML).toEqual("✕");
52 | expect(el.querySelector("#correct-answer").innerHTML).toEqual(
53 | 'bar'
54 | );
55 |
56 | el.querySelector("#input-form").onsubmit({ preventDefault: () => {} });
57 | await new Promise(resolve => window.setTimeout(resolve, 100));
58 | expect(rating).toEqual(0);
59 | });
60 |
61 | test("mistake diff", async () => {
62 | const reviewSession = new ReviewSession();
63 | reviewSession.session = session;
64 | reviewSession.resolveAnswer = () => ({ answer: "にほんごのかくせい" });
65 |
66 | reviewSession.advanceSession = () => {};
67 |
68 | const el = reviewSession.element;
69 | el.querySelector("#input").value = "にほんごこくせいい";
70 | el.querySelector("#input-form").onsubmit({ preventDefault: () => {} });
71 | await new Promise(resolve => window.setTimeout(resolve, 100));
72 | expect(el.querySelector("#correct-answer").innerHTML).toEqual(
73 | 'にほんごの' +
74 | 'こか' +
75 | 'くせいい'
76 | );
77 | });
78 |
79 | test("mistake diff - too many mistakes", async () => {
80 | const reviewSession = new ReviewSession();
81 | reviewSession.session = session;
82 | reviewSession.resolveAnswer = () => ({ answer: "んごのかくせい" });
83 |
84 | reviewSession.advanceSession = () => {};
85 |
86 | const el = reviewSession.element;
87 | el.querySelector("#input").value = "にほんごこくせいい";
88 | el.querySelector("#input-form").onsubmit({ preventDefault: () => {} });
89 | await new Promise(resolve => window.setTimeout(resolve, 100));
90 | expect(el.querySelector("#correct-answer").innerHTML).toEqual(
91 | 'んごのかくせい'
92 | );
93 | });
94 |
95 | test("mistake diff - short answer", async () => {
96 | const reviewSession = new ReviewSession();
97 | reviewSession.session = session;
98 | reviewSession.resolveAnswer = () => ({
99 | answer: "わたしのじてんしゃはさんまんえんでした"
100 | });
101 |
102 | reviewSession.advanceSession = () => {};
103 |
104 | const el = reviewSession.element;
105 | el.querySelector("#input").value = "じてんしゃはさんまんえんでした";
106 | el.querySelector("#input-form").onsubmit({ preventDefault: () => {} });
107 | await new Promise(resolve => window.setTimeout(resolve, 100));
108 | expect(el.querySelector("#correct-answer").innerHTML).toEqual(
109 | 'わ' +
110 | 'た' +
111 | 'し' +
112 | 'のじてんしゃはさんまんえんでした'
113 | );
114 | });
115 |
116 | test("mistake diff - unicode space", async () => {
117 | const reviewSession = new ReviewSession();
118 | reviewSession.session = session;
119 | reviewSession.resolveAnswer = () => ({
120 | answer: "こわれる to break"
121 | });
122 |
123 | reviewSession.advanceSession = () => {};
124 |
125 | const el = reviewSession.element;
126 | el.querySelector("#input").value = "きわれる to break";
127 | el.querySelector("#input-form").onsubmit({ preventDefault: () => {} });
128 | await new Promise(resolve => window.setTimeout(resolve, 100));
129 | expect(el.querySelector("#correct-answer").innerHTML).toEqual(
130 | 'き' +
131 | 'こわれる to break'
132 | );
133 | });
134 |
135 | test("submit correct", async () => {
136 | const reviewSession = new ReviewSession();
137 | reviewSession.session = session;
138 | reviewSession.resolveAnswer = () => ({ answer: "bar" });
139 |
140 | let rating = null;
141 | reviewSession.advanceSession = r => {
142 | rating = r;
143 | };
144 |
145 | const el = reviewSession.element;
146 | el.querySelector("#input").value = "bar";
147 | el.querySelector("#input-form").onsubmit({ preventDefault: () => {} });
148 | await new Promise(resolve => window.setTimeout(resolve, 100));
149 | expect(el.querySelector("#answer-state").innerHTML).toEqual("✓");
150 | expect(el.querySelector("#correct-answer").innerHTML).toEqual(" ");
151 |
152 | el.querySelector("#input-form").onsubmit({ preventDefault: () => {} });
153 | await new Promise(resolve => window.setTimeout(resolve, 100));
154 | expect(rating).toEqual(1);
155 | });
156 |
157 | test("submit correct with unicode separator", async () => {
158 | const reviewSession = new ReviewSession();
159 | reviewSession.session = session;
160 | reviewSession.resolveAnswer = () => ({ answer: "いち に" });
161 |
162 | let rating = null;
163 | reviewSession.advanceSession = r => {
164 | rating = r;
165 | };
166 |
167 | const el = reviewSession.element;
168 | el.querySelector("#input").value = "いち に";
169 | el.querySelector("#input-form").onsubmit({ preventDefault: () => {} });
170 | await new Promise(resolve => window.setTimeout(resolve, 100));
171 | expect(el.querySelector("#answer-state").innerHTML).toEqual("✓");
172 | expect(el.querySelector("#correct-answer").innerHTML).toEqual(" ");
173 |
174 | el.querySelector("#input-form").onsubmit({ preventDefault: () => {} });
175 | await new Promise(resolve => window.setTimeout(resolve, 100));
176 | expect(rating).toEqual(1);
177 | });
178 | });
179 |
180 | describe("self rater", () => {
181 | const session = {
182 | total: 10,
183 | left: 5,
184 | question: "foo",
185 | rating_type: "self"
186 | };
187 |
188 | test("render", () => {
189 | const reviewSession = new ReviewSession();
190 | expect(reviewSession.element).not.toBeNull();
191 |
192 | reviewSession.deck = "Test";
193 | reviewSession.session = session;
194 |
195 | const el = reviewSession.element;
196 | expect(el.querySelector("#deck").innerHTML).toEqual("Test");
197 | expect(el.querySelector("#progress").innerHTML).toEqual("5/10");
198 | expect(el.querySelector("#self-answer").innerHTML).toEqual(" ");
199 | expect(el.querySelector("#advance").style.visibility).toEqual("visible");
200 | expect(el.querySelector("#rating").style.visibility).toEqual("hidden");
201 | });
202 |
203 | test("show answer", async () => {
204 | const reviewSession = new ReviewSession();
205 | reviewSession.session = session;
206 | reviewSession.resolveAnswer = () => ({ answer: "bar" });
207 |
208 | const el = reviewSession.element;
209 | el.querySelector("#review-form").onsubmit({ preventDefault: () => {} });
210 | await new Promise(resolve => window.setTimeout(resolve, 100));
211 | expect(el.querySelector("#self-answer").innerHTML).toEqual("bar");
212 | expect(el.querySelector("#advance").style.visibility).toEqual("hidden");
213 | expect(el.querySelector("#rating").style.visibility).toEqual("visible");
214 | });
215 |
216 | test("submit review", async () => {
217 | const reviewSession = new ReviewSession();
218 | reviewSession.session = session;
219 | reviewSession.resolveAnswer = () => ({ answer: "bar" });
220 | let rating = null;
221 | reviewSession.advanceSession = r => {
222 | rating = r;
223 | };
224 |
225 | const el = reviewSession.element;
226 | el.querySelector("#review-form").onsubmit({ preventDefault: () => {} });
227 | el.querySelector("#rating button").click({ preventDefault: () => {} });
228 | await new Promise(resolve => window.setTimeout(resolve, 100));
229 | expect(rating).toEqual(0);
230 | });
231 | });
232 |
--------------------------------------------------------------------------------
/ui/static/tests/stats_list.test.js:
--------------------------------------------------------------------------------
1 | import StatsList from "../stats_list.js";
2 |
3 | test("render", () => {
4 | const statsList = new StatsList();
5 | expect(statsList.element).not.toBeNull();
6 |
7 | statsList.stats = [
8 | {
9 | card: "foo",
10 | stats: {
11 | LastReviewedAt: new Date(0).toString(),
12 | Interval: 0.2,
13 | Difficulty: 1.3,
14 | Historical: [
15 | { interval: 0.3, factor: 0.3 },
16 | { interval: 0.2, factor: 0.3 }
17 | ]
18 | }
19 | },
20 | { card: "bar", stats: {} }
21 | ];
22 | statsList.deck = "Test";
23 |
24 | const el = statsList.element;
25 | expect(el.querySelector("#stats-deck").innerHTML).toEqual("Test");
26 | expect(el.querySelector("#stats-list").children.length).toEqual(2);
27 | expect(el.querySelector("#stats-card").innerHTML).toEqual("foo");
28 | expect(el.querySelector("#reviewed-at").innerHTML).toEqual(
29 | new Date(0).toLocaleString()
30 | );
31 | expect(el.querySelector("#interval").innerHTML).toEqual("5h");
32 | expect(el.querySelector("#difficulty").innerHTML).toEqual("1.3");
33 | });
34 |
--------------------------------------------------------------------------------
/ui/tui.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/ap4y/leaf"
8 | runewidth "github.com/mattn/go-runewidth"
9 | termbox "github.com/nsf/termbox-go"
10 | )
11 |
12 | type step int
13 |
14 | const (
15 | stepAnswering step = iota
16 | stepScore
17 | stepFinished
18 | )
19 |
20 | type align int
21 |
22 | const (
23 | alignLeft align = iota
24 | alignCenter
25 | alignRight
26 | )
27 |
28 | // TUI implements terminal UI.
29 | type TUI struct {
30 | deckName string
31 | userInput []rune
32 | step step
33 | prevResult bool
34 | prevCorrect string
35 | }
36 |
37 | // NewTUI construct a new TUI instance.
38 | func NewTUI(deckName string) *TUI {
39 | return &TUI{deckName: deckName, userInput: make([]rune, 0)}
40 | }
41 |
42 | // Render renders current ui state using termbox.
43 | func (ui *TUI) Render(s *SessionState) error {
44 | if s.Total == 0 {
45 | ui.step = stepFinished
46 | }
47 |
48 | ui.draw(s)
49 |
50 | for {
51 | ev := termbox.PollEvent()
52 | switch ev.Type {
53 | case termbox.EventKey:
54 | if ev.Key == termbox.KeyEsc {
55 | return nil
56 | }
57 |
58 | if ui.step == stepFinished {
59 | break
60 | }
61 |
62 | if ui.step == stepScore {
63 | var score leaf.ReviewScore
64 | if s.RatingType == leaf.RatingTypeSelf {
65 | switch ev.Ch {
66 | case '1':
67 | score = leaf.ReviewScoreAgain
68 | case '2':
69 | score = leaf.ReviewScoreHard
70 | case '3':
71 | score = leaf.ReviewScoreGood
72 | case '4':
73 | score = leaf.ReviewScoreEasy
74 | default:
75 | continue
76 | }
77 | } else {
78 | if ui.prevResult {
79 | score = leaf.ReviewScoreEasy
80 | } else {
81 | score = leaf.ReviewScoreAgain
82 | }
83 | }
84 |
85 | s.Advance(score)
86 | if s.session.Left() == 0 {
87 | ui.step = stepFinished
88 | } else {
89 | ui.step = stepAnswering
90 | }
91 |
92 | break
93 | }
94 |
95 | if ev.Key == termbox.KeyEnter {
96 | ui.prevCorrect = s.ResolveAnswer()
97 | ui.prevResult = ui.prevCorrect == string(ui.userInput)
98 | ui.step = stepScore
99 | ui.userInput = make([]rune, 0)
100 | } else if ev.Key == termbox.KeyBackspace || ev.Key == termbox.KeyBackspace2 {
101 | if len(ui.userInput) > 0 {
102 | ui.userInput = ui.userInput[:len(ui.userInput)-1]
103 | }
104 | } else {
105 | var ch rune
106 | if ev.Key == termbox.KeySpace {
107 | ch = ' '
108 | } else {
109 | ch = ev.Ch
110 | }
111 |
112 | ui.userInput = append(ui.userInput, ch)
113 | }
114 | case termbox.EventError:
115 | return ev.Err
116 | }
117 |
118 | ui.draw(s)
119 | }
120 | }
121 |
122 | func (ui *TUI) draw(s *SessionState) {
123 | termbox.Clear(termbox.ColorDefault, termbox.ColorDefault) // nolint: errcheck
124 | defer termbox.Flush()
125 |
126 | w, h := termbox.Size()
127 |
128 | write(fmt.Sprintf(" Deck: %s", ui.deckName), 1, 1, 0, 0, 0)
129 | write(fmt.Sprintf("Progress: %d/%d", s.Total-s.Left, s.Total), 1, 2, 0, 0, 0)
130 |
131 | if ui.step == stepFinished {
132 | write("no more cards!", w/2, h/2-4, alignCenter, termbox.ColorGreen, 0)
133 | return
134 | }
135 |
136 | write(s.Question, w/2, h/2-4, alignCenter, termbox.ColorYellow|termbox.AttrBold, 0)
137 | if s.RatingType == leaf.RatingTypeSelf {
138 | ui.drawSelfRater(s)
139 | } else {
140 | ui.drawAutoRater(s)
141 | }
142 | }
143 |
144 | func (ui *TUI) drawAutoRater(s *SessionState) {
145 | w, h := termbox.Size()
146 | write("(type answer below)", w/2, h/2-3, alignCenter, 0, 0)
147 |
148 | x := (w / 2) - (s.AnswerLen / 2)
149 | inputBox := []rune{}
150 | for i := 0; i < s.AnswerLen; i++ {
151 | inputBox = append(inputBox, '_')
152 | }
153 | write(string(inputBox)+string('⏎'), x, h/2, 0, termbox.ColorWhite, 0)
154 |
155 | switch ui.step {
156 | case stepAnswering:
157 | input := strings.Replace(string(ui.userInput), " ", "␣", -1)
158 | write(input, x, h/2, 0, termbox.ColorGreen, 0)
159 | case stepScore:
160 | if ui.prevResult {
161 | write("✓", w/2, (h/2)+2, alignCenter, termbox.ColorGreen|termbox.AttrBold, 0)
162 | } else {
163 | write("✕", w/2, (h/2)+2, alignCenter, termbox.ColorRed|termbox.AttrBold, 0)
164 | write(ui.prevCorrect, w/2, (h/2)+3, alignCenter, termbox.ColorWhite, 0)
165 | }
166 | }
167 | }
168 |
169 | func (ui *TUI) drawSelfRater(s *SessionState) {
170 | w, h := termbox.Size()
171 | write("(select option below)", w/2, h/2-3, alignCenter, 0, 0)
172 |
173 | x := (w / 2) - (s.AnswerLen / 2)
174 |
175 | switch ui.step {
176 | case stepAnswering:
177 | write(" Show Answer: Enter ", x-9, h/2, 0, termbox.ColorMagenta, termbox.ColorWhite)
178 | case stepScore:
179 | write(ui.prevCorrect, w/2, h/2, alignCenter, termbox.ColorGreen, 0)
180 | scores := []string{" Again: 1 ", " Hard: 2 ", " Good: 3 ", " Easy: 4 "}
181 | for idx, score := range scores {
182 | scoreX := (w / 2) - 16
183 | for _, prev := range scores[0:idx] {
184 | scoreX += len(prev) + 1
185 | }
186 | write(score, scoreX, h/2+2, alignCenter, termbox.ColorMagenta, termbox.ColorWhite)
187 | }
188 | }
189 | }
190 |
191 | func write(text string, x, y int, align align, fg, bg termbox.Attribute) {
192 | var xOffset int
193 | switch align {
194 | case alignLeft:
195 | xOffset = x
196 | case alignCenter:
197 | xOffset = x - runewidth.StringWidth(text)/2
198 | case alignRight:
199 | xOffset = x - runewidth.StringWidth(text)
200 | }
201 |
202 | for _, c := range text {
203 | termbox.SetCell(xOffset, y, c, fg, bg)
204 | xOffset += runewidth.RuneWidth(c)
205 | }
206 | }
207 |
--------------------------------------------------------------------------------