fsrs.cljs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. (ns frontend.extensions.fsrs
  2. "Flashcards functions based on FSRS, only works in db-based graphs"
  3. (:require [clojure.string :as string]
  4. [frontend.common.missionary :as c.m]
  5. [frontend.components.block :as component-block]
  6. [frontend.config :as config]
  7. [frontend.context.i18n :refer [t]]
  8. [frontend.db :as db]
  9. [frontend.db-mixins :as db-mixins]
  10. [frontend.db.async :as db-async]
  11. [frontend.db.model :as db-model]
  12. [frontend.db.query-dsl :as query-dsl]
  13. [frontend.extensions.srs :as srs]
  14. [frontend.handler.block :as block-handler]
  15. [frontend.handler.property :as property-handler]
  16. [frontend.modules.shortcut.core :as shortcut]
  17. [frontend.state :as state]
  18. [frontend.ui :as ui]
  19. [frontend.util :as util]
  20. [logseq.db :as ldb]
  21. [logseq.db.frontend.entity-plus :as entity-plus]
  22. [logseq.shui.ui :as shui]
  23. [missionary.core :as m]
  24. [open-spaced-repetition.cljc-fsrs.core :as fsrs.core]
  25. [promesa.core :as p]
  26. [rum.core :as rum]
  27. [tick.core :as tick]))
  28. (def ^:private instant->inst-ms (comp inst-ms tick/inst))
  29. (defn- inst-ms->instant [ms] (tick/instant (js/Date. ms)))
  30. (defn- fsrs-card-map->property-fsrs-state
  31. "Convert card-map to value stored in property"
  32. [fsrs-card-map]
  33. (-> fsrs-card-map
  34. (update :last-repeat instant->inst-ms)
  35. (update :due instant->inst-ms)))
  36. (defn- property-fsrs-state->fsrs-card-map
  37. "opposite version of `fsrs-card->property-fsrs-state`"
  38. [prop-fsrs-state]
  39. (-> prop-fsrs-state
  40. (update :last-repeat inst-ms->instant)
  41. (update :due inst-ms->instant)))
  42. (defn- get-card-map
  43. "Return nil if block is not #card.
  44. Return default card-map if `:logseq.property.fsrs/state` or `:logseq.property.fsrs/due` is nil"
  45. [block-entity]
  46. (when (some (fn [tag]
  47. (assert (some? (:db/ident tag)) tag)
  48. (= :logseq.class/Card (:db/ident tag))) ;block should contains #Card
  49. (:block/tags block-entity))
  50. (let [fsrs-state (:logseq.property.fsrs/state block-entity)
  51. fsrs-due (:logseq.property.fsrs/due block-entity)
  52. return-default-card-map? (not (and fsrs-state fsrs-due))]
  53. (if return-default-card-map?
  54. (if-let [block-created-at (some-> (:block/created-at block-entity) (js/Date.) tick/instant)]
  55. (assoc (fsrs.core/new-card!)
  56. :last-repeat block-created-at
  57. :due block-created-at)
  58. (fsrs.core/new-card!))
  59. (property-fsrs-state->fsrs-card-map (assoc fsrs-state :due fsrs-due))))))
  60. (defn- repeat-card!
  61. [repo block-id rating]
  62. (let [eid (if (uuid? block-id) [:block/uuid block-id] block-id)
  63. block-entity (db/entity repo eid)]
  64. (when-let [card-map (get-card-map block-entity)]
  65. (let [next-card-map (fsrs.core/repeat-card! card-map rating)
  66. prop-card-map (fsrs-card-map->property-fsrs-state next-card-map)
  67. prop-fsrs-state (-> prop-card-map
  68. (dissoc :due)
  69. (assoc :logseq/last-rating rating))
  70. prop-fsrs-due (:due prop-card-map)]
  71. (property-handler/set-block-properties!
  72. repo (:block/uuid block-entity)
  73. {:logseq.property.fsrs/state prop-fsrs-state
  74. :logseq.property.fsrs/due prop-fsrs-due})))))
  75. (defn- <get-due-card-block-ids
  76. [repo cards-id]
  77. (let [now-inst-ms (inst-ms (js/Date.))
  78. cards (when (and cards-id (not= (keyword cards-id) :global)) (db/entity cards-id))
  79. query (:block/title cards)
  80. result (query-dsl/parse query {:db-graph? true})
  81. q '[:find [?b ...]
  82. :in $ ?now-inst-ms %
  83. :where
  84. [?b :block/tags :logseq.class/Card]
  85. (or-join [?b ?now-inst-ms]
  86. (and
  87. [?b :logseq.property.fsrs/due ?due]
  88. [(>= ?now-inst-ms ?due)])
  89. [(missing? $ ?b :logseq.property.fsrs/due)])
  90. [?b :block/uuid]]
  91. q' (if query
  92. (let [query* (:query result)]
  93. (util/concat-without-nil
  94. q
  95. (if (coll? (first query*)) query* [query*])))
  96. q)]
  97. (db-async/<q repo {:transact-db? false} q' now-inst-ms (:rules result))))
  98. (defn- btn-with-shortcut [{:keys [shortcut id btn-text due on-click class]}]
  99. (let [bg-class (case id
  100. "card-again" "primary-red"
  101. "card-hard" "primary-purple"
  102. "card-good" "primary-logseq"
  103. "card-easy" "primary-green"
  104. nil)]
  105. [:div.flex.flex-row.items-center.gap-2
  106. (shui/button
  107. {:variant :outline
  108. :title (str "Shortcut: " shortcut)
  109. :auto-focus false
  110. :size :sm
  111. :id id
  112. :class (str id " " class " !px-2 !py-1 bg-primary/5 hover:bg-primary/10
  113. border-primary opacity-90 hover:opacity-100 " bg-class)
  114. :on-pointer-down (fn [e] (util/stop-propagation e))
  115. :on-click (fn [_e] (js/setTimeout #(on-click) 10))}
  116. [:div.flex.flex-row.items-center.gap-1
  117. [:span btn-text]
  118. (when-not (util/sm-breakpoint?)
  119. [:span.scale-90 (shui/shortcut shortcut)])])
  120. (when due [:div.text-sm.opacity-50 (util/human-time due {:ago? false})])]))
  121. (defn- has-cloze?
  122. [block]
  123. (string/includes? (:block/title block) "{{cloze "))
  124. (defn- phase->next-phase
  125. [block phase]
  126. (let [cloze? (has-cloze? block)]
  127. (case phase
  128. :init
  129. (if cloze? :show-cloze :show-answer)
  130. :show-cloze
  131. (if cloze? :show-answer :init)
  132. :show-answer
  133. :init)))
  134. (def ^:private rating->shortcut
  135. {:again "1"
  136. :hard "2"
  137. :good "3"
  138. :easy "4"})
  139. (defn- rating-btns
  140. [repo block *card-index *phase]
  141. (let [block-id (:db/id block)]
  142. [:div.flex.flex-row.items-center.gap-8.flex-wrap
  143. (mapv
  144. (fn [rating]
  145. (let [card-map (get-card-map block)
  146. due (:due (fsrs.core/repeat-card! card-map rating))]
  147. (btn-with-shortcut {:btn-text (string/capitalize (name rating))
  148. :shortcut (rating->shortcut rating)
  149. :due due
  150. :id (str "card-" (name rating))
  151. :on-click #(do (repeat-card! repo block-id rating)
  152. (swap! *card-index inc)
  153. (reset! *phase :init))})))
  154. (keys rating->shortcut))
  155. (shui/button
  156. {:variant :ghost
  157. :size :sm
  158. :class "!px-0 text-muted-foreground !h-4"
  159. :on-click (fn [e]
  160. (shui/popup-show! (.-target e)
  161. (fn []
  162. [:div.p-4.max-w-lg
  163. [:dl
  164. [:dt "Again"]
  165. [:dd "We got the answer wrong. Automatically means that we have forgotten the card. This is a lapse in memory."]]
  166. [:dl
  167. [:dt "Hard"]
  168. [:dd "The answer was correct but we were not confident about it and/or took too long to recall."]]
  169. [:dl
  170. [:dt "Good"]
  171. [:dd "The answer was correct but we took some mental effort to recall it."]]
  172. [:dl
  173. [:dt "Easy"]
  174. [:dd "The answer was correct and we were confident and quick in our recall without mental effort."]]])
  175. {:align "start"}))}
  176. (ui/icon "info-circle"))]))
  177. (rum/defcs ^:private card-view < rum/reactive db-mixins/query
  178. {:will-mount (fn [state]
  179. (when-let [[repo block-id _] (:rum/args state)]
  180. (db-async/<get-block repo block-id {:children? false}))
  181. state)}
  182. [state repo block-id *card-index *phase]
  183. (when-let [block-entity (db/sub-block block-id)]
  184. (let [phase (rum/react *phase)
  185. next-phase (phase->next-phase block-entity phase)]
  186. [:div.ls-card.content.flex.flex-col.overflow-y-auto.overflow-x-hidden
  187. [:div (component-block/breadcrumb {} repo (:block/uuid block-entity) {})]
  188. (let [option (case phase
  189. :init
  190. {:hide-children? true}
  191. :show-cloze
  192. {:show-cloze? true
  193. :hide-children? true}
  194. {:show-cloze? true})]
  195. (component-block/blocks-container option [block-entity]))
  196. [:div.mt-8.pb-2
  197. (if (contains? #{:show-cloze :show-answer} next-phase)
  198. (btn-with-shortcut {:btn-text (t
  199. (case next-phase
  200. :show-answer
  201. :flashcards/modal-btn-show-answers
  202. :show-cloze
  203. :flashcards/modal-btn-show-clozes
  204. :init
  205. :flashcards/modal-btn-hide-answers))
  206. :shortcut "s"
  207. :id (str "card-answers")
  208. :on-click #(swap! *phase
  209. (fn [phase]
  210. (phase->next-phase block-entity phase)))})
  211. [:div.flex.justify-center (rating-btns repo block-entity *card-index *phase)])]])))
  212. (declare update-due-cards-count)
  213. (rum/defcs cards-view < rum/reactive
  214. (rum/local 0 ::card-index)
  215. (shortcut/mixin :shortcut.handler/cards false)
  216. {:init (fn [state]
  217. (let [*block-ids (atom nil)
  218. *loading? (atom nil)
  219. cards-id (last (:rum/args state))]
  220. (reset! *loading? true)
  221. (p/let [result (<get-due-card-block-ids (state/get-current-repo) cards-id)]
  222. (reset! *block-ids result)
  223. (reset! *loading? false))
  224. (assoc state
  225. ::block-ids *block-ids
  226. ::cards-id (atom (or cards-id :global))
  227. ::loading? *loading?)))
  228. :will-unmount (fn [state]
  229. (update-due-cards-count)
  230. state)}
  231. [state _cards-id]
  232. (let [repo (state/get-current-repo)
  233. *cards-id (::cards-id state)
  234. cards-id (rum/react *cards-id)
  235. all-cards (concat
  236. [{:db/id :global
  237. :block/title "All cards"}]
  238. (db-model/get-class-objects repo (:db/id (entity-plus/entity-memoized (db/get-db) :logseq.class/Cards))))
  239. *block-ids (::block-ids state)
  240. block-ids (rum/react *block-ids)
  241. loading? (rum/react (::loading? state))
  242. *card-index (::card-index state)
  243. *phase (atom :init)]
  244. (when (false? loading?)
  245. [:div#cards-modal.flex.flex-col.gap-8.h-full.flex-1
  246. [:div.flex.flex-row.items-center.gap-2.flex-wrap
  247. (shui/select
  248. {:on-value-change (fn [v]
  249. (reset! *cards-id v)
  250. (p/let [result (<get-due-card-block-ids repo (if (= :global v) nil v))]
  251. (reset! *card-index 0)
  252. (reset! *block-ids result)))
  253. :default-value cards-id}
  254. (shui/select-trigger
  255. {:class "!px-2 !py-0 !h-8 w-64"}
  256. (shui/select-value
  257. {:placeholder "Select cards"})
  258. (shui/select-content
  259. (shui/select-group
  260. (for [card-entity all-cards]
  261. (shui/select-item {:value (:db/id card-entity)}
  262. (:block/title card-entity)))))))
  263. [:span.text-sm.opacity-50 (str (min (inc @*card-index) (count @*block-ids)) "/" (count @*block-ids))]]
  264. (let [block-id (nth block-ids @*card-index nil)]
  265. (cond
  266. block-id
  267. [:div.flex.flex-col
  268. (card-view repo block-id *card-index *phase)]
  269. (empty? block-ids)
  270. [:div.ls-card.content.ml-2
  271. [:h2.font-medium (t :flashcards/modal-welcome-title)]
  272. [:div
  273. [:p (t :flashcards/modal-welcome-desc-1)]]]
  274. :else
  275. [:p (t :flashcards/modal-finished)]))])))
  276. (defonce ^:private *last-update-due-cards-count-canceler (atom nil))
  277. (def ^:private new-task--update-due-cards-count
  278. "Return a task that update `:srs/cards-due-count` periodically."
  279. (m/sp
  280. (let [repo (state/get-current-repo)]
  281. (if (config/db-based-graph? repo)
  282. (m/?
  283. (m/reduce
  284. (fn [_ _]
  285. (p/let [due-cards (<get-due-card-block-ids repo nil)]
  286. (state/set-state! :srs/cards-due-count (count due-cards))))
  287. (c.m/clock (* 3600 1000))))
  288. (srs/update-cards-due-count!)))))
  289. (defn update-due-cards-count
  290. []
  291. (when-let [canceler @*last-update-due-cards-count-canceler]
  292. (canceler)
  293. (reset! *last-update-due-cards-count-canceler nil))
  294. (let [canceler (c.m/run-task :update-due-cards-count
  295. new-task--update-due-cards-count)]
  296. (reset! *last-update-due-cards-count-canceler canceler)
  297. nil))
  298. (defn- get-operating-blocks
  299. [block-ids]
  300. (some->> block-ids
  301. (map (fn [id] (db/entity [:block/uuid id])))
  302. (seq)
  303. block-handler/get-top-level-blocks
  304. (remove ldb/property?)))
  305. (defn batch-make-cards!
  306. ([] (batch-make-cards! (state/get-selection-block-ids)))
  307. ([block-ids]
  308. (let [repo (state/get-current-repo)
  309. blocks (get-operating-blocks block-ids)]
  310. (when-let [block-ids (not-empty (map :block/uuid blocks))]
  311. (property-handler/batch-set-block-property!
  312. repo
  313. block-ids
  314. :block/tags
  315. (:db/id (db/entity :logseq.class/Card)))))))
  316. (comment
  317. (defn- cards-in-time-range
  318. [cards start-instant end-instant]
  319. (assert (and (tick/instant? start-instant)
  320. (tick/instant? end-instant))
  321. [start-instant end-instant])
  322. (->> cards
  323. (filter (fn [card] (tick/<= start-instant (:last-repeat card) end-instant)))))
  324. (defn- cards-today
  325. [cards]
  326. (let [date-today (tick/new-date)
  327. start-instant (tick/instant (tick/at date-today (tick/new-time 0 0)))
  328. end-instant (tick/instant)]
  329. (cards-in-time-range cards start-instant end-instant)))
  330. (defn- cards-recent-7-days
  331. [cards]
  332. (let [now-instant (tick/instant)
  333. date-7-days-ago (tick/date (tick/<< now-instant (tick/new-duration 7 :days)))
  334. start-instant (tick/instant (tick/at date-7-days-ago (tick/new-time 0 0)))
  335. end-instant now-instant]
  336. (cards-in-time-range cards start-instant end-instant)))
  337. (defn- cards-recent-30-days
  338. [cards]
  339. (let [now-instant (tick/instant)
  340. date-30-days-ago (tick/date (tick/<< now-instant (tick/new-duration 30 :days)))
  341. start-instant (tick/instant (tick/at date-30-days-ago (tick/new-time 0 0)))
  342. end-instant now-instant]
  343. (cards-in-time-range cards start-instant end-instant)))
  344. (defn- cards-stat
  345. [cards]
  346. (let [state-grouped-cards (group-by :state cards)
  347. state-cards-count (update-vals state-grouped-cards count)
  348. {new-count :new
  349. learning-count :learning
  350. review-count :review
  351. relearning-count :relearning} state-cards-count
  352. passed-repeat-count (count (filter #(contains? #{:good :easy} (:logseq/last-rating %)) cards))
  353. lapsed-repeat-count (count (filter #(contains? #{:again :hard} (:logseq/last-rating %)) cards))
  354. true-retention-percent (when (seq cards) (/ review-count (count cards)))]
  355. {:true-retention true-retention-percent
  356. :passed-repeats passed-repeat-count
  357. :lapsed-repeats lapsed-repeat-count
  358. :new-state-cards (or new-count 0)
  359. :learning-state-cards (or learning-count 0)
  360. :review-state-cards (or review-count 0)
  361. :relearning-state-cards (or relearning-count 0)}))
  362. (defn <cards-stat
  363. "Some explanations on return value:
  364. :true-retention, cards in review-state / all-cards-count
  365. :passed-repeats, last rating is :good or :easy
  366. :lapsed-repeats, last rating is :again or :hard
  367. :XXX-state-cards, cards' state is XXX"
  368. []
  369. (p/let [repo (state/get-current-repo)
  370. all-card-blocks
  371. (db-async/<q repo {:transact-db? false}
  372. '[:find [(pull ?b [* {:block/tags [:db/ident]}]) ...]
  373. :where
  374. [?b :block/tags :logseq.class/Card]
  375. [?b :block/uuid]])
  376. all-cards (map get-card-map all-card-blocks)
  377. [today-stat
  378. recent-7-days-stat
  379. recent-30-days-stat]
  380. (map cards-stat ((juxt cards-today cards-recent-7-days cards-recent-30-days) all-cards))]
  381. {:today-stat today-stat
  382. :recent-7-days-stat recent-7-days-stat
  383. :recent-30-days-stat recent-30-days-stat})))