(ns frontend.extensions.fsrs "Flashcards functions based on FSRS, only works in db-based graphs" (:require [clojure.string :as string] [frontend.common.missionary :as c.m] [frontend.components.block :as component-block] [frontend.config :as config] [frontend.context.i18n :refer [t]] [frontend.db :as db] [frontend.db-mixins :as db-mixins] [frontend.db.async :as db-async] [frontend.db.model :as db-model] [frontend.db.query-dsl :as query-dsl] [frontend.extensions.srs :as srs] [frontend.handler.block :as block-handler] [frontend.handler.property :as property-handler] [frontend.modules.shortcut.core :as shortcut] [frontend.state :as state] [frontend.ui :as ui] [frontend.util :as util] [logseq.db :as ldb] [logseq.db.frontend.entity-plus :as entity-plus] [logseq.shui.ui :as shui] [missionary.core :as m] [open-spaced-repetition.cljc-fsrs.core :as fsrs.core] [promesa.core :as p] [rum.core :as rum] [tick.core :as tick])) (def ^:private instant->inst-ms (comp inst-ms tick/inst)) (defn- inst-ms->instant [ms] (tick/instant (js/Date. ms))) (defn- fsrs-card-map->property-fsrs-state "Convert card-map to value stored in property" [fsrs-card-map] (-> fsrs-card-map (update :last-repeat instant->inst-ms) (update :due instant->inst-ms))) (defn- property-fsrs-state->fsrs-card-map "opposite version of `fsrs-card->property-fsrs-state`" [prop-fsrs-state] (-> prop-fsrs-state (update :last-repeat inst-ms->instant) (update :due inst-ms->instant))) (defn- get-card-map "Return nil if block is not #card. Return default card-map if `:logseq.property.fsrs/state` or `:logseq.property.fsrs/due` is nil" [block-entity] (when (some (fn [tag] (assert (some? (:db/ident tag)) tag) (= :logseq.class/Card (:db/ident tag))) ;block should contains #Card (:block/tags block-entity)) (let [fsrs-state (:logseq.property.fsrs/state block-entity) fsrs-due (:logseq.property.fsrs/due block-entity) return-default-card-map? (not (and fsrs-state fsrs-due))] (if return-default-card-map? (if-let [block-created-at (some-> (:block/created-at block-entity) (js/Date.) tick/instant)] (assoc (fsrs.core/new-card!) :last-repeat block-created-at :due block-created-at) (fsrs.core/new-card!)) (property-fsrs-state->fsrs-card-map (assoc fsrs-state :due fsrs-due)))))) (defn- repeat-card! [repo block-id rating] (let [eid (if (uuid? block-id) [:block/uuid block-id] block-id) block-entity (db/entity repo eid)] (when-let [card-map (get-card-map block-entity)] (let [next-card-map (fsrs.core/repeat-card! card-map rating) prop-card-map (fsrs-card-map->property-fsrs-state next-card-map) prop-fsrs-state (-> prop-card-map (dissoc :due) (assoc :logseq/last-rating rating)) prop-fsrs-due (:due prop-card-map)] (property-handler/set-block-properties! repo (:block/uuid block-entity) {:logseq.property.fsrs/state prop-fsrs-state :logseq.property.fsrs/due prop-fsrs-due}))))) (defn- = ?now-inst-ms ?due)]) [(missing? $ ?b :logseq.property.fsrs/due)]) [?b :block/uuid]] q' (if query (let [query* (:query result)] (util/concat-without-nil q (if (coll? (first query*)) query* [query*]))) q)] (db-async/next-phase [block phase] (let [cloze? (has-cloze? block)] (case phase :init (if cloze? :show-cloze :show-answer) :show-cloze (if cloze? :show-answer :init) :show-answer :init))) (def ^:private rating->shortcut {:again "1" :hard "2" :good "3" :easy "4"}) (defn- rating-btns [repo block *card-index *phase] (let [block-id (:db/id block)] [:div.flex.flex-row.items-center.gap-8.flex-wrap (mapv (fn [rating] (let [card-map (get-card-map block) due (:due (fsrs.core/repeat-card! card-map rating))] (btn-with-shortcut {:btn-text (string/capitalize (name rating)) :shortcut (rating->shortcut rating) :due due :id (str "card-" (name rating)) :on-click #(do (repeat-card! repo block-id rating) (swap! *card-index inc) (reset! *phase :init))}))) (keys rating->shortcut)) (shui/button {:variant :ghost :size :sm :class "!px-0 text-muted-foreground !h-4" :on-click (fn [e] (shui/popup-show! (.-target e) (fn [] [:div.p-4.max-w-lg [:dl [:dt "Again"] [:dd "We got the answer wrong. Automatically means that we have forgotten the card. This is a lapse in memory."]] [:dl [:dt "Hard"] [:dd "The answer was correct but we were not confident about it and/or took too long to recall."]] [:dl [:dt "Good"] [:dd "The answer was correct but we took some mental effort to recall it."]] [:dl [:dt "Easy"] [:dd "The answer was correct and we were confident and quick in our recall without mental effort."]]]) {:align "start"}))} (ui/icon "info-circle"))])) (rum/defcs ^:private card-view < rum/reactive db-mixins/query {:will-mount (fn [state] (when-let [[repo block-id _] (:rum/args state)] (db-async/next-phase block-entity phase)] [:div.ls-card.content.flex.flex-col.overflow-y-auto.overflow-x-hidden [:div (component-block/breadcrumb {} repo (:block/uuid block-entity) {})] (let [option (case phase :init {:hide-children? true} :show-cloze {:show-cloze? true :hide-children? true} {:show-cloze? true})] (component-block/blocks-container option [block-entity])) [:div.mt-8.pb-2 (if (contains? #{:show-cloze :show-answer} next-phase) (btn-with-shortcut {:btn-text (t (case next-phase :show-answer :flashcards/modal-btn-show-answers :show-cloze :flashcards/modal-btn-show-clozes :init :flashcards/modal-btn-hide-answers)) :shortcut "s" :id (str "card-answers") :on-click #(swap! *phase (fn [phase] (phase->next-phase block-entity phase)))}) [:div.flex.justify-center (rating-btns repo block-entity *card-index *phase)])]]))) (declare update-due-cards-count) (rum/defcs cards-view < rum/reactive (rum/local 0 ::card-index) (shortcut/mixin :shortcut.handler/cards false) {:init (fn [state] (let [*block-ids (atom nil) *loading? (atom nil) cards-id (last (:rum/args state))] (reset! *loading? true) (p/let [result (> block-ids (map (fn [id] (db/entity [:block/uuid id]))) (seq) block-handler/get-top-level-blocks (remove ldb/property?))) (defn batch-make-cards! ([] (batch-make-cards! (state/get-selection-block-ids))) ([block-ids] (let [repo (state/get-current-repo) blocks (get-operating-blocks block-ids)] (when-let [block-ids (not-empty (map :block/uuid blocks))] (property-handler/batch-set-block-property! repo block-ids :block/tags (:db/id (db/entity :logseq.class/Card))))))) (comment (defn- cards-in-time-range [cards start-instant end-instant] (assert (and (tick/instant? start-instant) (tick/instant? end-instant)) [start-instant end-instant]) (->> cards (filter (fn [card] (tick/<= start-instant (:last-repeat card) end-instant))))) (defn- cards-today [cards] (let [date-today (tick/new-date) start-instant (tick/instant (tick/at date-today (tick/new-time 0 0))) end-instant (tick/instant)] (cards-in-time-range cards start-instant end-instant))) (defn- cards-recent-7-days [cards] (let [now-instant (tick/instant) date-7-days-ago (tick/date (tick/<< now-instant (tick/new-duration 7 :days))) start-instant (tick/instant (tick/at date-7-days-ago (tick/new-time 0 0))) end-instant now-instant] (cards-in-time-range cards start-instant end-instant))) (defn- cards-recent-30-days [cards] (let [now-instant (tick/instant) date-30-days-ago (tick/date (tick/<< now-instant (tick/new-duration 30 :days))) start-instant (tick/instant (tick/at date-30-days-ago (tick/new-time 0 0))) end-instant now-instant] (cards-in-time-range cards start-instant end-instant))) (defn- cards-stat [cards] (let [state-grouped-cards (group-by :state cards) state-cards-count (update-vals state-grouped-cards count) {new-count :new learning-count :learning review-count :review relearning-count :relearning} state-cards-count passed-repeat-count (count (filter #(contains? #{:good :easy} (:logseq/last-rating %)) cards)) lapsed-repeat-count (count (filter #(contains? #{:again :hard} (:logseq/last-rating %)) cards)) true-retention-percent (when (seq cards) (/ review-count (count cards)))] {:true-retention true-retention-percent :passed-repeats passed-repeat-count :lapsed-repeats lapsed-repeat-count :new-state-cards (or new-count 0) :learning-state-cards (or learning-count 0) :review-state-cards (or review-count 0) :relearning-state-cards (or relearning-count 0)})) (defn