├── .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 | 28 | 29 | 30 | 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 |
    3 |
    4 | 5 |   6 |
    7 |
    8 | 9 |   10 |
    11 |
    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 |
    145 | 146 | 147 |
    148 | 149 | 150 | 151 | 152 |
    153 |
    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 | --------------------------------------------------------------------------------