From 8d14859e4b7c69bb5c51b53f5c51336fc7c9c520 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Sat, 21 Jan 2017 17:33:13 +0100 Subject: [PATCH 1/4] Some notes I wrote a while back --- notes.txt | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 notes.txt diff --git a/notes.txt b/notes.txt new file mode 100644 index 0000000..68a528a --- /dev/null +++ b/notes.txt @@ -0,0 +1,50 @@ +Within the card: + +Question: +Exposed: card, fact(s), note, model +Methods: answer(x) + +Review: +Exposed: card, fact(s), note, model, answer (exact copy of what was passed to answer() on Question) +Methods: result(x) + +Answer: +Exposed: card, fact(s), note, model, answer (exact copy of what was passed to answer() on Question), review result +Methods: +done() + + + +Answer format: +{ + "review": "xxx", + "data": {} +} + +Valid Review types: +none (lesson card) +immediate_self_review -- Normal Anki +standard -- May allow for delayed self-review (i.e. Anki typed answers) or delayed peer review depending on settings. +self -- Specifically requests self review, even if peer review is available +auto -- Auto review (i.e. Anki typed answers), + +Result types: + + +Possible answers: +none (self-reviewed) +Voice recording +Drawing/hand writing +multiple-choice/Button press +typed answer + +Possible reviews: +Wrong +Correct + + +Review processes: +Immediate self-review (standard anki) +Auto-review (typed anki) +Delayed peer/self review +No review (lesson) From 958d34549e352ffa83e301ab5e566f95fa850494 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Sat, 21 Jan 2017 18:28:19 +0100 Subject: [PATCH 2/4] Zap clientstate package which was unused --- clientstate/clientstate.go | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 clientstate/clientstate.go diff --git a/clientstate/clientstate.go b/clientstate/clientstate.go deleted file mode 100644 index 1d12b35..0000000 --- a/clientstate/clientstate.go +++ /dev/null @@ -1,22 +0,0 @@ -package clientstate - -import ( - "golang.org/x/net/context" -) - -type State struct { - CurrentUser string - Stack []context.Context -} - -func New() *State { - return &State{ - "", - make([]context.Context, 0, 5), - } -} - -func (s *State) Reset() { - s.CurrentUser = "" - s.Stack = make([]context.Context, 0, 5) -} From bda03d2a39cfd4a427bc392290d13ae29e981b36 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Sun, 22 Jan 2017 16:23:40 +0100 Subject: [PATCH 3/4] Split 'cardmodel' into a semi-MVC structure The "view" is now in webclient/views/studyview. It contains no logic at the moment, only type definitions for view elements (buttons mostly). The model is now the `repository` directory. The "controllers" are per-model type, and live in controllers/*/, with their glue also in `repository`. This isn't ideal, but it seemed like a step in the right direction. I hope to find some better place to put scheduling logic at some point. But for now, it's in `repository/modelcontroller.go` along with a FIXME note. --- .gitignore | 2 +- Makefile | 4 +- cardmodel/cardmodel.go | 92 ---------- .../ankibasic/ankibasic.go | 64 ++++--- .../ankibasic/js/script.js | 0 {cardmodel => controllers}/mock/mock.go | 35 ++-- repository/card.go | 36 ++-- repository/card_test.go | 25 ++- repository/modelcontroller.go | 169 ++++++++++++++++++ repository/test/card_test.go | 2 +- webclient/handlers/study/study.go | 23 +-- webclient/main.go | 2 +- webclient/views/studyview/studyview.go | 35 ++++ 13 files changed, 301 insertions(+), 188 deletions(-) delete mode 100644 cardmodel/cardmodel.go rename {cardmodel => controllers}/ankibasic/ankibasic.go (53%) rename {cardmodel => controllers}/ankibasic/js/script.js (100%) rename {cardmodel => controllers}/mock/mock.go (51%) create mode 100644 repository/modelcontroller.go create mode 100644 webclient/views/studyview/studyview.go diff --git a/.gitignore b/.gitignore index 34ed8ae..8ad6ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,4 @@ main.js.map .phonegap-facebook-plugin # go-bindata -cardmodel/ankibasic/data.go +controllers/ankibasic/data.go diff --git a/Makefile b/Makefile index fba1f6c..766f671 100644 --- a/Makefile +++ b/Makefile @@ -67,7 +67,7 @@ www/js/cardframe.js: webclient/js/cardframe.js cp $< $@ .PHONY: main.js -main.js: cardmodel/ankibasic/data.go +main.js: controllers/ankibasic/data.go rm -rf ${GOPATH}/pkg/*_js gopherjs build --tags=debug ./webclient/*.go # uglifyjs main.js -c -m -o $@ @@ -98,5 +98,5 @@ www: javascript css images $(HTML_FILES) $(I18N_FILES) cordova-www: www cat www/index.html | sed -e 's//`, string(handler.IframeScript()))) + doc.Find("head").AppendHtml(fmt.Sprintf(``, string(cont.IframeScript()))) newBody, err := goquery.OuterHtml(doc.Selection) if err != nil { diff --git a/repository/card_test.go b/repository/card_test.go index ca049d1..fec608f 100644 --- a/repository/card_test.go +++ b/repository/card_test.go @@ -6,17 +6,26 @@ import ( "testing" "time" - "github.com/flimzy/testify/require" - "github.com/FlashbackSRS/flashback-model" - "github.com/FlashbackSRS/flashback/cardmodel/mock" + "github.com/FlashbackSRS/flashback/webclient/views/studyview" + "github.com/flimzy/testify/require" ) +// We need to implement our own, minimal fake controller here, because using +// controllers/mock results in an import cycle. +type fakeController struct{} + +func (f *fakeController) Type() string { return "fake-model" } +func (f *fakeController) IframeScript() []byte { return []byte("/* Fake Model */") } +func (f *fakeController) Buttons(_ int) (studyview.ButtonMap, error) { return nil, nil } +func (f *fakeController) Action(_ *Card, _ *int, _ time.Time, _ studyview.Button) (bool, error) { + return false, nil +} + func TestPrepareBody(t *testing.T) { - mock.RegisterMock("mock-model") require := require.New(t) doc := strings.NewReader(testDoc1) - result, err := prepareBody(Question, 0, "mock-model", doc) + result, err := prepareBody(Question, 0, &fakeController{}, doc) if err != nil { t.Errorf("error preparing body: %s", err) } @@ -65,6 +74,7 @@ Answer:
instrument
` + var expected1 = ` FB Card @@ -79,10 +89,7 @@ iframeID: '445a737462464b4e', - + Question:
instrument
` diff --git a/repository/modelcontroller.go b/repository/modelcontroller.go new file mode 100644 index 0000000..bc7e128 --- /dev/null +++ b/repository/modelcontroller.go @@ -0,0 +1,169 @@ +package repo + +import ( + "fmt" + "time" + + "github.com/pkg/errors" + + "github.com/FlashbackSRS/flashback-model" + "github.com/FlashbackSRS/flashback/webclient/views/studyview" +) + +// ModelController is an interface for the per-type model controllers. +type ModelController interface { + // Type returns the Model Type identifier string. + Type() string + // IframeScript returns a blob of JavaScript, which is loaded inside the + // iframe of each card associated with this model type. + IframeScript() []byte + // Buttons returns the attributes for the three available answer buttons' + // initial state. Index 0 = left button, 1 = center, 2 = right + Buttons(face int) (studyview.ButtonMap, error) + // Action is called when the user presses a button. If done is returned + // as true, the next card is selected. If done is false, the same card will + // be displayed, with the current value of face (possibly changed by the + // function) + Action(card *Card, face *int, startTime time.Time, button studyview.Button) (done bool, err error) +} + +var modelControllers = map[string]ModelController{} +var modelControllerTypes = []string{} + +// RegisterModelController registers a model controller for use in the app. +// The passed controller's Type() must return a unique value. +func RegisterModelController(c ModelController) { + mType := c.Type() + if _, ok := modelControllers[mType]; ok { + panic("A controller for '" + mType + "' is already registered'") + } + modelControllers[mType] = c + modelControllerTypes = append(modelControllerTypes, mType) +} + +// RegisteredModelControllers returns a list of registered controller type +func RegisteredModelControllers() []string { + return modelControllerTypes +} + +func (m *Model) getController() (ModelController, error) { + mType := m.Type + c, ok := modelControllers[mType] + if !ok { + return nil, errors.Errorf("no handler for '%s' registered", mType) + } + return c, nil +} + +func (c *Card) getModelController() (ModelController, error) { + m, err := c.Model() + if err != nil { + return nil, err + } + return m.getController() +} + +/* +FIXME: This scheduling stuff probably doesn't belong here. But where? +*/ + +// Ease factor options +const ( + InitialEase float32 = 2.5 + MaxEase float32 = 2.5 + MinEase float32 = 1.3 +) + +// Interval options +const ( + InitialInterval = fb.Interval(24 * fb.Hour) + SecondInterval = fb.Interval(6 * fb.Day) +) + +// Lapse options +const ( + LapseInterval = fb.Interval(10 * fb.Minute) +) + +// Now is an alias for time.Now +var Now = time.Now + +func now() fb.Due { + return fb.Due(Now()) +} + +// AnswerQuality represents the SM-2 quality of the answer. See here: +// https://www.supermemo.com/english/ol/sm2.htm +type AnswerQuality int + +// Answer qualities are borrowed from the SM2 algorithm. +const ( + // Complete Blackout + AnswerBlackout AnswerQuality = iota + // incorrect response; the correct one remembered + AnswerIncorrectRemembered + // incorrect response; where the correct one seemed easy to recall + AnswerIncorrectEasy + // correct response recalled with serious difficulty + AnswerCorrectDifficult + // correct response after a hesitation + AnswerCorrect + // perfect response + AnswerPerfect +) + +// Schedule implements the default scheduler. +func Schedule(card *Card, answerDelay time.Duration, quality AnswerQuality) error { + ivl, ease := schedule(card, quality) + due := now().Add(ivl) + card.Due = &due + card.Interval = &ivl + card.EaseFactor = ease + if quality <= AnswerIncorrectEasy { + card.ReviewCount = 0 + } + return nil +} + +func adjustEase(ease float32, q AnswerQuality) float32 { + quality := float32(q) + newEase := ease + (0.1 - (5-quality)*(0.08+(5-quality)*0.02)) + if newEase < MinEase { + return MinEase + } + if newEase > MaxEase { + return MaxEase + } + return newEase +} + +func schedule(card *Card, quality AnswerQuality) (interval fb.Interval, easeFactor float32) { + ease := card.EaseFactor + if ease == 0.0 { + ease = InitialEase + } + + if quality <= AnswerIncorrectEasy { + quality = 0 + return LapseInterval, adjustEase(ease, quality) + } + + if card.ReviewCount == 0 { + return InitialInterval, adjustEase(ease, quality) + } + + ease = adjustEase(ease, quality) + interval = *card.Interval + lastReviewed := card.Due.Add(-interval) + observedInterval := fb.Interval(float32(now().Sub(lastReviewed)) * ease) + if card.ReviewCount == 1 && observedInterval < SecondInterval { + return SecondInterval, ease + } + fmt.Printf("Last reviewed on %s\n", lastReviewed) + fmt.Printf("interval = %s, observed = %s, second = %s\n", interval, observedInterval, SecondInterval) + if observedInterval > interval { + interval = observedInterval + } + + return interval, ease +} diff --git a/repository/test/card_test.go b/repository/test/card_test.go index e016398..0427f29 100644 --- a/repository/test/card_test.go +++ b/repository/test/card_test.go @@ -6,7 +6,7 @@ import ( "github.com/flimzy/testify/require" - "github.com/FlashbackSRS/flashback/cardmodel/mock" + "github.com/FlashbackSRS/flashback/controllers/mock" "github.com/FlashbackSRS/flashback/repository" ) diff --git a/webclient/handlers/study/study.go b/webclient/handlers/study/study.go index 7a92928..77a76fd 100644 --- a/webclient/handlers/study/study.go +++ b/webclient/handlers/study/study.go @@ -11,9 +11,9 @@ import ( "github.com/gopherjs/jquery" "github.com/pkg/errors" - "github.com/FlashbackSRS/flashback/cardmodel" "github.com/FlashbackSRS/flashback/fserve" "github.com/FlashbackSRS/flashback/repository" + "github.com/FlashbackSRS/flashback/webclient/views/studyview" ) var jQuery = jquery.NewJQuery @@ -60,11 +60,6 @@ func ShowCard(u *repo.User) error { } log.Debugf("Card ID: %s\n", currentCard.Card.DocID()) - mh, err := currentCard.Card.ModelHandler() - if err != nil { - return errors.Wrap(err, "failed to get card's model handler") - } - log.Debug("Setting up the buttons\n") buttons := jQuery(":mobile-pagecontainer").Find("#answer-buttons").Find(`[data-role="button"]`) buttons.RemoveClass("ui-btn-active") @@ -72,16 +67,16 @@ func ShowCard(u *repo.User) error { buttons.Off() // Make sure we don't accept other press events id := e.Get("currentTarget").Call("getAttribute", "data-id").String() log.Debugf("Button %s was pressed!\n", id) - HandleCardAction(cardmodel.Button(id)) + HandleCardAction(studyview.Button(id)) }) - buttonAttrs, err := mh.Buttons(currentCard.Face) + buttonAttrs, err := currentCard.Card.Buttons(currentCard.Face) if err != nil { return errors.Wrap(err, "failed to get buttons list") } for i := 0; i < buttons.Length; i++ { button := jQuery(buttons.Underlying().Index(i)) id := button.Attr("data-id") - attr, ok := buttonAttrs[(cardmodel.Button(id))] + attr, ok := buttonAttrs[(studyview.Button(id))] button.Call("button") if !ok { button.SetText(" ") @@ -121,16 +116,10 @@ func ShowCard(u *repo.User) error { return nil } -func HandleCardAction(button cardmodel.Button) { +func HandleCardAction(button studyview.Button) { card := currentCard.Card - mh, err := card.ModelHandler() - if err != nil { - log.Printf("failed to get card's model handler: %s\n", err) - } face := currentCard.Face - done, err := mh.Action(card.Card, ¤tCard.Face, currentCard.StartTime, cardmodel.Action{ - Button: button, - }) + done, err := card.Action(¤tCard.Face, currentCard.StartTime, button) if err != nil { log.Printf("Error executing card action for face %d / %+v: %s", face, card, err) } diff --git a/webclient/main.go b/webclient/main.go index e70c0ef..1e3e461 100644 --- a/webclient/main.go +++ b/webclient/main.go @@ -15,7 +15,7 @@ import ( "github.com/FlashbackSRS/flashback/fserve" "github.com/FlashbackSRS/flashback/util" - _ "github.com/FlashbackSRS/flashback/cardmodel/ankibasic" + _ "github.com/FlashbackSRS/flashback/controllers/ankibasic" "github.com/FlashbackSRS/flashback/webclient/handlers/auth" "github.com/FlashbackSRS/flashback/webclient/handlers/general" "github.com/FlashbackSRS/flashback/webclient/handlers/import" diff --git a/webclient/views/studyview/studyview.go b/webclient/views/studyview/studyview.go new file mode 100644 index 0000000..6f9d9ad --- /dev/null +++ b/webclient/views/studyview/studyview.go @@ -0,0 +1,35 @@ +package studyview + +// Button represents a button displayed on each card face +type Button string + +// The buttons displayed on each card +const ( + ButtonLeft Button = "button-l" + ButtonCenterLeft Button = "button-cl" + ButtonCenterRight Button = "button-cr" + ButtonRight Button = "button-r" +) + +func (b Button) String() string { + switch b { + case ButtonLeft: + return "Left" + case ButtonCenterLeft: + return "Center Left" + case ButtonCenterRight: + return "Center Right" + case ButtonRight: + return "Right" + } + return "Unknown" +} + +// ButtonMap is the state of the three answer buttons +type ButtonMap map[Button]ButtonState + +// ButtonState is one of the four answer buttons. +type ButtonState struct { + Name string + Enabled bool +} From 1c0925d8eb47f25463ee215a98cf96905194b3c7 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Sun, 22 Jan 2017 17:54:18 +0100 Subject: [PATCH 4/4] Update our usage of Find() to use the new calling convention See https://github.com/flimzy/go-pouchdb/commit/621ee42b825c9c1aaaf5bad3b40e3d439b5f86e0 --- repository/card.go | 22 +++++++++++++--------- repository/card_test.go | 9 +++++++++ webclient/handlers/sync/sync.go | 22 +++++++++++++--------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/repository/card.go b/repository/card.go index 9b51827..16b27a0 100644 --- a/repository/card.go +++ b/repository/card.go @@ -132,7 +132,7 @@ func CardPrio(due *fb.Due, interval *fb.Interval, now time.Time) float32 { // GetCards fetches up to limit cards from the db, in priority order. func GetCards(db *DB, now time.Time, limit int) ([]*Card, error) { - doc := make(map[string][]*fb.Card) + var cards []*Card query := map[string]interface{}{ "selector": map[string]interface{}{ "type": "card", @@ -143,21 +143,25 @@ func GetCards(db *DB, now time.Time, limit int) ([]*Card, error) { "sort": []string{"due", "created"}, "limit": limit, } - if err := db.Find(query, &doc); err != nil { + if err := db.Find(query, &cards); err != nil { return nil, errors.Wrap(err, "card list") } - cards := make([]*Card, len(doc["docs"])) - for i, card := range doc["docs"] { - cards[i] = &Card{ - Card: card, - db: db, - priority: CardPrio(card.Due, card.Interval, now), - } + for _, card := range cards { + card.db = db + card.priority = CardPrio(card.Due, card.Interval, now) } sort.Sort(cardList(cards)) return cards, nil } +// UnmarshalJSON wraps fb.Card's Unmarshaler +func (c *Card) UnmarshalJSON(data []byte) error { + fbCard := &fb.Card{} + err := json.Unmarshal(data, fbCard) + c.Card = fbCard + return err +} + // GetNextCard gets the next card to study func (u *User) GetNextCard() (*Card, error) { db, err := u.DB() diff --git a/repository/card_test.go b/repository/card_test.go index fec608f..0d12536 100644 --- a/repository/card_test.go +++ b/repository/card_test.go @@ -1,6 +1,7 @@ package repo import ( + "encoding/json" "math" "strings" "testing" @@ -158,3 +159,11 @@ func parseDue(ds string) fb.Due { } return d } + +func TestUnmarshal(t *testing.T) { + raw := `{"_id":"card-alnlcvykyjxsjtijzonc3456kd5u4757.udROb8T8RmRASG5zGHNKnKL25zI.0","_rev":"1-daccd83780014e8cf35ce8f16d2a144c","created":"2015-09-08T23:55:03.000000539Z","imported":"2017-01-02T17:16:56.764985035+01:00","model":"theme-ELr8cEJJOvJU4lYz-VTXhH8wLTo/0","modified":"2016-08-02T13:05:04Z","type":"card"}` + card := &Card{} + if err := json.Unmarshal([]byte(raw), card); err != nil { + t.Errorf("Failed to unmarshal card: %s\n", err) + } +} diff --git a/webclient/handlers/sync/sync.go b/webclient/handlers/sync/sync.go index 4bfdd7d..d00232d 100644 --- a/webclient/handlers/sync/sync.go +++ b/webclient/handlers/sync/sync.go @@ -131,30 +131,34 @@ func Sync(source, target *repo.DB) (int32, error) { return int32(result["docs_written"].(float64)), nil } +type bundleResult struct { + ID string `json:"_id"` +} + // BundleSync syncs auxilary bundles to the remote server. func BundleSync(udb *repo.DB) (int32, int32, error) { log.Debugf("Reading bundles from user database...\n") - doc := make(map[string][]map[string]string) + var bundles []bundleResult err := udb.Find(map[string]interface{}{ "selector": map[string]string{"type": "bundle"}, "fields": []string{"_id"}, - }, &doc) + }, &bundles) if err != nil { - return 0, 0, err - } - bundles := make([]string, len(doc["docs"])) - for i, bundle := range doc["docs"] { - bundles[i] = bundle["_id"] + if pouchdb.IsWarning(err) { + log.Println(err.Error()) + } else { + return 0, 0, errors.Wrap(err, "BundleSync failed") + } } log.Debugf("bundles = %v\n", bundles) var written, read int32 for _, bundle := range bundles { log.Debugf("Bundle %s", bundle) - local, err := udb.User.NewDB(bundle) + local, err := udb.User.NewDB(bundle.ID) if err != nil { return written, read, err } - remote, err := udb.User.NewRemoteDB(bundle) + remote, err := udb.User.NewRemoteDB(bundle.ID) if err != nil { return written, read, err }