A privacy-first, self-hosted, fully open source personal knowledge management software, written in typescript and golang. (PERSONAL FORK)
1// SiYuan - Refactor your thinking
2// Copyright (c) 2020-present, b3log.org
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program. If not, see <https://www.gnu.org/licenses/>.
16
17package model
18
19import (
20 "math"
21 "os"
22 "path/filepath"
23 "sort"
24 "strconv"
25 "strings"
26 "sync"
27 "time"
28
29 "github.com/88250/gulu"
30 "github.com/88250/lute/ast"
31 "github.com/88250/lute/parse"
32 "github.com/open-spaced-repetition/go-fsrs/v3"
33 "github.com/siyuan-note/filelock"
34 "github.com/siyuan-note/logging"
35 "github.com/siyuan-note/riff"
36 "github.com/siyuan-note/siyuan/kernel/cache"
37 "github.com/siyuan-note/siyuan/kernel/sql"
38 "github.com/siyuan-note/siyuan/kernel/treenode"
39 "github.com/siyuan-note/siyuan/kernel/util"
40)
41
42func GetFlashcardsByBlockIDs(blockIDs []string) (ret []*Block) {
43 deckLock.Lock()
44 defer deckLock.Unlock()
45
46 waitForSyncingStorages()
47
48 ret = []*Block{}
49 deck := Decks[builtinDeckID]
50 if nil == deck {
51 return
52 }
53
54 cards := deck.GetCardsByBlockIDs(blockIDs)
55 blocks, _, _ := getCardsBlocks(cards, 1, math.MaxInt)
56
57 for _, blockID := range blockIDs {
58 found := false
59 for _, block := range blocks {
60 if blockID == block.ID {
61 found = true
62 ret = append(ret, block)
63 break
64 }
65 }
66 if !found {
67 ret = append(ret, &Block{
68 ID: blockID,
69 Content: Conf.Language(180),
70 })
71 }
72 }
73 return
74}
75
76type SetFlashcardDueTime struct {
77 ID string `json:"id"` // 卡片 ID
78 Due string `json:"due"` // 下次复习时间,格式为 YYYYMMDDHHmmss
79}
80
81func SetFlashcardsDueTime(cardDues []*SetFlashcardDueTime) (err error) {
82 // Add internal kernel API `/api/riff/batchSetRiffCardsDueTime` https://github.com/siyuan-note/siyuan/issues/10423
83
84 deckLock.Lock()
85 defer deckLock.Unlock()
86
87 waitForSyncingStorages()
88
89 deck := Decks[builtinDeckID]
90 if nil == deck {
91 return
92 }
93
94 for _, cardDue := range cardDues {
95 card := deck.GetCard(cardDue.ID)
96 if nil == card {
97 continue
98 }
99
100 due, parseErr := time.ParseInLocation("20060102150405", cardDue.Due, time.Local)
101 if nil != parseErr {
102 logging.LogErrorf("parse due time [%s] failed: %s", cardDue.Due, err)
103 err = parseErr
104 return
105 }
106
107 card.SetDue(due)
108 }
109
110 if err = deck.Save(); err != nil {
111 logging.LogErrorf("save deck [%s] failed: %s", builtinDeckID, err)
112 }
113 return
114}
115
116func ResetFlashcards(typ, id, deckID string, blockIDs []string) {
117 // Support resetting the learning progress of flashcards https://github.com/siyuan-note/siyuan/issues/9564
118
119 if 0 < len(blockIDs) {
120 if "" == deckID {
121 // 从全局管理进入时不会指定卡包 ID,这时需要遍历所有卡包
122 for _, deck := range Decks {
123 allBlockIDs := deck.GetBlockIDs()
124 for _, blockID := range blockIDs {
125 if gulu.Str.Contains(blockID, allBlockIDs) {
126 deckID = deck.ID
127 break
128 }
129 }
130 if "" == deckID {
131 logging.LogWarnf("deck not found for blocks [%s]", strings.Join(blockIDs, ","))
132 continue
133 }
134 resetFlashcards(deckID, blockIDs)
135 }
136 return
137 }
138
139 resetFlashcards(deckID, blockIDs)
140 return
141 }
142
143 var blocks []*Block
144 switch typ {
145 case "notebook":
146 for i := 1; ; i++ {
147 pagedBlocks, _, _ := GetNotebookFlashcards(id, i, 20)
148 if 1 > len(pagedBlocks) {
149 break
150 }
151 blocks = append(blocks, pagedBlocks...)
152 }
153 for _, block := range blocks {
154 blockIDs = append(blockIDs, block.ID)
155 }
156 case "tree":
157 for i := 1; ; i++ {
158 pagedBlocks, _, _ := GetTreeFlashcards(id, i, 20)
159 if 1 > len(pagedBlocks) {
160 break
161 }
162 blocks = append(blocks, pagedBlocks...)
163 }
164 for _, block := range blocks {
165 blockIDs = append(blockIDs, block.ID)
166 }
167 case "deck":
168 for i := 1; ; i++ {
169 pagedBlocks, _, _ := GetDeckFlashcards(id, i, 20)
170 if 1 > len(pagedBlocks) {
171 break
172 }
173 blocks = append(blocks, pagedBlocks...)
174 }
175 default:
176 logging.LogErrorf("invalid type [%s]", typ)
177 }
178
179 blockIDs = gulu.Str.RemoveDuplicatedElem(blockIDs)
180 resetFlashcards(deckID, blockIDs)
181}
182
183func resetFlashcards(deckID string, blockIDs []string) {
184 transactions := []*Transaction{
185 {
186 DoOperations: []*Operation{
187 {
188 Action: "removeFlashcards",
189 DeckID: deckID,
190 BlockIDs: blockIDs,
191 },
192 },
193 },
194 {
195 DoOperations: []*Operation{
196 {
197 Action: "addFlashcards",
198 DeckID: deckID,
199 BlockIDs: blockIDs,
200 },
201 },
202 },
203 }
204
205 PerformTransactions(&transactions)
206 FlushTxQueue()
207}
208
209func GetFlashcardNotebooks() (ret []*Box) {
210 deck := Decks[builtinDeckID]
211 if nil == deck {
212 return
213 }
214
215 deckBlockIDs := deck.GetBlockIDs()
216 boxes := Conf.GetOpenedBoxes()
217 for _, box := range boxes {
218 newFlashcardCount, dueFlashcardCount, flashcardCount := countBoxFlashcard(box.ID, deck, deckBlockIDs)
219 if 0 < flashcardCount {
220 box.NewFlashcardCount = newFlashcardCount
221 box.DueFlashcardCount = dueFlashcardCount
222 box.FlashcardCount = flashcardCount
223 ret = append(ret, box)
224 }
225 }
226 return
227}
228
229func countTreeFlashcard(rootID string, deck *riff.Deck, deckBlockIDs []string) (newFlashcardCount, dueFlashcardCount, flashcardCount int) {
230 blockIDsMap, blockIDs := getTreeSubTreeChildBlocks(rootID)
231 for _, deckBlockID := range deckBlockIDs {
232 if blockIDsMap[deckBlockID] {
233 flashcardCount++
234 }
235 }
236 if 1 > flashcardCount {
237 return
238 }
239
240 newFlashCards := deck.GetNewCardsByBlockIDs(blockIDs)
241 newFlashcardCount = len(newFlashCards)
242 newDueFlashcards := deck.GetDueCardsByBlockIDs(blockIDs)
243 dueFlashcardCount = len(newDueFlashcards)
244 return
245}
246
247func countBoxFlashcard(boxID string, deck *riff.Deck, deckBlockIDs []string) (newFlashcardCount, dueFlashcardCount, flashcardCount int) {
248 blockIDsMap, blockIDs := getBoxBlocks(boxID)
249 for _, deckBlockID := range deckBlockIDs {
250 if blockIDsMap[deckBlockID] {
251 flashcardCount++
252 }
253 }
254 if 1 > flashcardCount {
255 return
256 }
257
258 newFlashCards := deck.GetNewCardsByBlockIDs(blockIDs)
259 newFlashcardCount = len(newFlashCards)
260 newDueFlashcards := deck.GetDueCardsByBlockIDs(blockIDs)
261 dueFlashcardCount = len(newDueFlashcards)
262 return
263}
264
265var (
266 Decks = map[string]*riff.Deck{}
267 deckLock = sync.Mutex{}
268)
269
270func GetNotebookFlashcards(boxID string, page, pageSize int) (blocks []*Block, total, pageCount int) {
271 blocks = []*Block{}
272
273 entries, err := os.ReadDir(filepath.Join(util.DataDir, boxID))
274 if err != nil {
275 logging.LogErrorf("read dir failed: %s", err)
276 return
277 }
278
279 var rootIDs []string
280 for _, entry := range entries {
281 if entry.IsDir() {
282 continue
283 }
284
285 if !strings.HasSuffix(entry.Name(), ".sy") {
286 continue
287 }
288
289 rootIDs = append(rootIDs, strings.TrimSuffix(entry.Name(), ".sy"))
290 }
291
292 var treeBlockIDs []string
293 for _, rootID := range rootIDs {
294 _, blockIDs := getTreeSubTreeChildBlocks(rootID)
295 treeBlockIDs = append(treeBlockIDs, blockIDs...)
296 }
297 treeBlockIDs = gulu.Str.RemoveDuplicatedElem(treeBlockIDs)
298
299 deck := Decks[builtinDeckID]
300 if nil == deck {
301 return
302 }
303
304 var allBlockIDs []string
305 deckBlockIDs := deck.GetBlockIDs()
306 for _, blockID := range deckBlockIDs {
307 if gulu.Str.Contains(blockID, treeBlockIDs) {
308 allBlockIDs = append(allBlockIDs, blockID)
309 }
310 }
311 allBlockIDs = gulu.Str.RemoveDuplicatedElem(allBlockIDs)
312 cards := deck.GetCardsByBlockIDs(allBlockIDs)
313
314 blocks, total, pageCount = getCardsBlocks(cards, page, pageSize)
315 return
316}
317
318func GetTreeFlashcards(rootID string, page, pageSize int) (blocks []*Block, total, pageCount int) {
319 blocks = []*Block{}
320 cards := getTreeSubTreeFlashcards(rootID)
321 blocks, total, pageCount = getCardsBlocks(cards, page, pageSize)
322 return
323}
324
325func getTreeSubTreeFlashcards(rootID string) (ret []riff.Card) {
326 deck := Decks[builtinDeckID]
327 if nil == deck {
328 return
329 }
330
331 var allBlockIDs []string
332 deckBlockIDs := deck.GetBlockIDs()
333 treeBlockIDsMap, _ := getTreeSubTreeChildBlocks(rootID)
334 for _, blockID := range deckBlockIDs {
335 if treeBlockIDsMap[blockID] {
336 allBlockIDs = append(allBlockIDs, blockID)
337 }
338 }
339 allBlockIDs = gulu.Str.RemoveDuplicatedElem(allBlockIDs)
340 ret = deck.GetCardsByBlockIDs(allBlockIDs)
341 return
342}
343
344func getTreeFlashcards(rootID string) (ret []riff.Card) {
345 deck := Decks[builtinDeckID]
346 if nil == deck {
347 return
348 }
349
350 var allBlockIDs []string
351 deckBlockIDs := deck.GetBlockIDs()
352 treeBlockIDsMap, _ := getTreeBlocks(rootID)
353 for _, blockID := range deckBlockIDs {
354 if treeBlockIDsMap[blockID] {
355 allBlockIDs = append(allBlockIDs, blockID)
356 }
357 }
358 allBlockIDs = gulu.Str.RemoveDuplicatedElem(allBlockIDs)
359 ret = deck.GetCardsByBlockIDs(allBlockIDs)
360 return
361}
362
363func GetDeckFlashcards(deckID string, page, pageSize int) (blocks []*Block, total, pageCount int) {
364 blocks = []*Block{}
365 var cards []riff.Card
366 if "" == deckID {
367 for _, deck := range Decks {
368 blockIDs := deck.GetBlockIDs()
369 cards = append(cards, deck.GetCardsByBlockIDs(blockIDs)...)
370 }
371 } else {
372 deck := Decks[deckID]
373 if nil == deck {
374 return
375 }
376
377 blockIDs := deck.GetBlockIDs()
378 cards = append(cards, deck.GetCardsByBlockIDs(blockIDs)...)
379 }
380
381 blocks, total, pageCount = getCardsBlocks(cards, page, pageSize)
382 return
383}
384
385func getCardsBlocks(cards []riff.Card, page, pageSize int) (blocks []*Block, total, pageCount int) {
386 // sort by due date asc https://github.com/siyuan-note/siyuan/pull/9673
387 sort.Slice(cards, func(i, j int) bool {
388 due1 := cards[i].(*riff.FSRSCard).C.Due
389 due2 := cards[j].(*riff.FSRSCard).C.Due
390 if due1.IsZero() || due2.IsZero() {
391 // Improve flashcard management sorting https://github.com/siyuan-note/siyuan/issues/14686
392 cid1 := cards[i].ID()
393 cid2 := cards[j].ID()
394 return cid1 < cid2
395 }
396 return due1.Before(due2)
397 })
398
399 total = len(cards)
400 pageCount = int(math.Ceil(float64(total) / float64(pageSize)))
401 start := (page - 1) * pageSize
402 end := page * pageSize
403 if start > len(cards) {
404 start = len(cards)
405 }
406 if end > len(cards) {
407 end = len(cards)
408 }
409
410 cards = cards[start:end]
411 if 1 > len(cards) {
412 blocks = []*Block{}
413 return
414 }
415
416 var blockIDs []string
417 for _, card := range cards {
418 blockIDs = append(blockIDs, card.BlockID())
419 }
420
421 sqlBlocks := sql.GetBlocks(blockIDs)
422 blocks = fromSQLBlocks(&sqlBlocks, "", 36)
423 if 1 > len(blocks) {
424 blocks = []*Block{}
425 return
426 }
427
428 for i, b := range blocks {
429 if nil == b {
430 blocks[i] = &Block{
431 ID: blockIDs[i],
432 Content: Conf.Language(180),
433 }
434
435 continue
436 }
437
438 b.RiffCardID = cards[i].ID()
439 b.RiffCard = getRiffCard(cards[i].(*riff.FSRSCard).C)
440 }
441 return
442}
443
444func getRiffCard(card *fsrs.Card) *RiffCard {
445 due := card.Due
446 if due.IsZero() {
447 due = time.Now()
448 }
449
450 return &RiffCard{
451 Due: due,
452 Reps: card.Reps,
453 Lapses: card.Lapses,
454 State: card.State,
455 LastReview: card.LastReview,
456 }
457}
458
459var (
460 // reviewCardCache <cardID, card> 用于复习时缓存卡片,以便支持撤销。
461 reviewCardCache = map[string]riff.Card{}
462
463 // skipCardCache <cardID, card> 用于复习时缓存跳过的卡片,以便支持跳过过滤。
464 skipCardCache = map[string]riff.Card{}
465)
466
467func ReviewFlashcard(deckID, cardID string, rating riff.Rating, reviewedCardIDs []string) (err error) {
468 deckLock.Lock()
469 defer deckLock.Unlock()
470
471 waitForSyncingStorages()
472
473 deck := Decks[deckID]
474 card := deck.GetCard(cardID)
475 if nil == card {
476 return
477 }
478
479 if cachedCard := reviewCardCache[cardID]; nil != cachedCard {
480 // 命中缓存说明这张卡片已经复习过了,这次调用复习是撤销后再次复习
481 // 将缓存的卡片重新覆盖回卡包中,以恢复最开始复习前的状态
482 deck.SetCard(cachedCard)
483
484 // 从跳过缓存中移除(如果上一次点的是跳过的话),如果不在跳过缓存中,说明上一次点的是复习,这里移除一下也没有副作用
485 delete(skipCardCache, cardID)
486 } else {
487 // 首次复习该卡片,将卡片缓存以便后续支持撤销后再次复习
488 reviewCardCache[cardID] = card.Clone()
489 }
490
491 log := deck.Review(cardID, rating)
492 if err = deck.Save(); err != nil {
493 logging.LogErrorf("save deck [%s] failed: %s", deckID, err)
494 return
495 }
496
497 if err = deck.SaveLog(log); err != nil {
498 logging.LogErrorf("save review log [%s] failed: %s", deckID, err)
499 return
500 }
501
502 _, unreviewedCount, _, _ := getDueFlashcards(deckID, reviewedCardIDs)
503 if 1 > unreviewedCount {
504 // 该卡包中没有待复习的卡片了,说明最后一张卡片已经复习完了,清空撤销缓存和跳过缓存
505 reviewCardCache = map[string]riff.Card{}
506 skipCardCache = map[string]riff.Card{}
507 }
508 return
509}
510
511func SkipReviewFlashcard(deckID, cardID string) (err error) {
512 deckLock.Lock()
513 defer deckLock.Unlock()
514
515 waitForSyncingStorages()
516
517 deck := Decks[deckID]
518 card := deck.GetCard(cardID)
519 if nil == card {
520 return
521 }
522
523 skipCardCache[cardID] = card
524 return
525}
526
527type Flashcard struct {
528 DeckID string `json:"deckID"`
529 CardID string `json:"cardID"`
530 BlockID string `json:"blockID"`
531 Lapses int `json:"lapses"`
532 Reps int `json:"reps"`
533 State riff.State `json:"state"`
534 LastReview int64 `json:"lastReview"`
535 NextDues map[riff.Rating]string `json:"nextDues"`
536}
537
538func newFlashcard(card riff.Card, deckID string, now time.Time) *Flashcard {
539 nextDues := map[riff.Rating]string{}
540 for rating, due := range card.NextDues() {
541 nextDues[rating] = strings.TrimSpace(util.HumanizeDiffTime(due, now, Conf.Lang))
542 }
543
544 return &Flashcard{
545 DeckID: deckID,
546 CardID: card.ID(),
547 BlockID: card.BlockID(),
548 Lapses: card.GetLapses(),
549 Reps: card.GetReps(),
550 State: card.GetState(),
551 LastReview: card.GetLastReview().UnixMilli(),
552 NextDues: nextDues,
553 }
554}
555
556func GetNotebookDueFlashcards(boxID string, reviewedCardIDs []string) (ret []*Flashcard, unreviewedCount, unreviewedNewCardCount, unreviewedOldCardCount int, err error) {
557 deckLock.Lock()
558 defer deckLock.Unlock()
559
560 waitForSyncingStorages()
561
562 entries, err := os.ReadDir(filepath.Join(util.DataDir, boxID))
563 if err != nil {
564 logging.LogErrorf("read dir failed: %s", err)
565 return
566 }
567
568 var rootIDs []string
569 for _, entry := range entries {
570 if entry.IsDir() {
571 continue
572 }
573
574 if !strings.HasSuffix(entry.Name(), ".sy") {
575 continue
576 }
577
578 rootIDs = append(rootIDs, strings.TrimSuffix(entry.Name(), ".sy"))
579 }
580
581 var treeBlockIDs []string
582 for _, rootID := range rootIDs {
583 _, blockIDs := getTreeSubTreeChildBlocks(rootID)
584 treeBlockIDs = append(treeBlockIDs, blockIDs...)
585 }
586 treeBlockIDs = gulu.Str.RemoveDuplicatedElem(treeBlockIDs)
587
588 deck := Decks[builtinDeckID]
589 if nil == deck {
590 logging.LogWarnf("builtin deck not found")
591 return
592 }
593
594 cards, unreviewedCnt, unreviewedNewCardCnt, unreviewedOldCardCnt := getDeckDueCards(deck, reviewedCardIDs, treeBlockIDs, Conf.Flashcard.NewCardLimit, Conf.Flashcard.ReviewCardLimit, Conf.Flashcard.ReviewMode)
595 now := time.Now()
596 for _, card := range cards {
597 ret = append(ret, newFlashcard(card, builtinDeckID, now))
598 }
599 if 1 > len(ret) {
600 ret = []*Flashcard{}
601 }
602 unreviewedCount = unreviewedCnt
603 unreviewedNewCardCount = unreviewedNewCardCnt
604 unreviewedOldCardCount = unreviewedOldCardCnt
605 return
606}
607
608func GetTreeDueFlashcards(rootID string, reviewedCardIDs []string) (ret []*Flashcard, unreviewedCount, unreviewedNewCardCount, unreviewedOldCardCount int, err error) {
609 deckLock.Lock()
610 defer deckLock.Unlock()
611
612 waitForSyncingStorages()
613
614 deck := Decks[builtinDeckID]
615 if nil == deck {
616 return
617 }
618
619 _, treeBlockIDs := getTreeSubTreeChildBlocks(rootID)
620 newCardLimit := Conf.Flashcard.NewCardLimit
621 reviewCardLimit := Conf.Flashcard.ReviewCardLimit
622 // 文档级新卡/复习卡上限控制 Document-level new card/review card limit control https://github.com/siyuan-note/siyuan/issues/9365
623 ial := sql.GetBlockAttrs(rootID)
624 if newCardLimitStr := ial["custom-riff-new-card-limit"]; "" != newCardLimitStr {
625 var convertErr error
626 newCardLimit, convertErr = strconv.Atoi(newCardLimitStr)
627 if nil != convertErr {
628 logging.LogWarnf("invalid new card limit [%s]: %s", newCardLimitStr, convertErr)
629 }
630 }
631 if reviewCardLimitStr := ial["custom-riff-review-card-limit"]; "" != reviewCardLimitStr {
632 var convertErr error
633 reviewCardLimit, convertErr = strconv.Atoi(reviewCardLimitStr)
634 if nil != convertErr {
635 logging.LogWarnf("invalid review card limit [%s]: %s", reviewCardLimitStr, convertErr)
636 }
637 }
638
639 cards, unreviewedCnt, unreviewedNewCardCnt, unreviewedOldCardCnt := getDeckDueCards(deck, reviewedCardIDs, treeBlockIDs, newCardLimit, reviewCardLimit, Conf.Flashcard.ReviewMode)
640 now := time.Now()
641 for _, card := range cards {
642 ret = append(ret, newFlashcard(card, builtinDeckID, now))
643 }
644 if 1 > len(ret) {
645 ret = []*Flashcard{}
646 }
647 unreviewedCount = unreviewedCnt
648 unreviewedNewCardCount = unreviewedNewCardCnt
649 unreviewedOldCardCount = unreviewedOldCardCnt
650 return
651}
652
653func getTreeSubTreeChildBlocks(rootID string) (treeBlockIDsMap map[string]bool, treeBlockIDs []string) {
654 treeBlockIDsMap = map[string]bool{}
655 root := treenode.GetBlockTree(rootID)
656 if nil == root {
657 return
658 }
659
660 bts := treenode.GetBlockTreesByPathPrefix(strings.TrimSuffix(root.Path, ".sy"))
661 for _, bt := range bts {
662 treeBlockIDsMap[bt.ID] = true
663 treeBlockIDs = append(treeBlockIDs, bt.ID)
664 }
665 return
666}
667
668func getTreeBlocks(rootID string) (treeBlockIDsMap map[string]bool, treeBlockIDs []string) {
669 treeBlockIDsMap = map[string]bool{}
670 bts := treenode.GetBlockTreesByRootID(rootID)
671 for _, bt := range bts {
672 treeBlockIDsMap[bt.ID] = true
673 treeBlockIDs = append(treeBlockIDs, bt.ID)
674 }
675 return
676}
677
678func getBoxBlocks(boxID string) (blockIDsMap map[string]bool, blockIDs []string) {
679 blockIDsMap = map[string]bool{}
680 bts := treenode.GetBlockTreesByBoxID(boxID)
681 for _, bt := range bts {
682 blockIDsMap[bt.ID] = true
683 blockIDs = append(blockIDs, bt.ID)
684 }
685 return
686}
687
688func GetDueFlashcards(deckID string, reviewedCardIDs []string) (ret []*Flashcard, unreviewedCount, unreviewedNewCardCount, unreviewedOldCardCount int, err error) {
689 deckLock.Lock()
690 defer deckLock.Unlock()
691
692 waitForSyncingStorages()
693
694 if "" == deckID {
695 ret, unreviewedCount, unreviewedNewCardCount, unreviewedOldCardCount = getAllDueFlashcards(reviewedCardIDs)
696 return
697 }
698
699 ret, unreviewedCount, unreviewedNewCardCount, unreviewedOldCardCount = getDueFlashcards(deckID, reviewedCardIDs)
700 return
701}
702
703func getDueFlashcards(deckID string, reviewedCardIDs []string) (ret []*Flashcard, unreviewedCount, unreviewedNewCardCount, unreviewedOldCardCount int) {
704 deck := Decks[deckID]
705 if nil == deck {
706 logging.LogWarnf("deck not found [%s]", deckID)
707 return
708 }
709
710 cards, unreviewedCnt, unreviewedNewCardCnt, unreviewedOldCardCnt := getDeckDueCards(deck, reviewedCardIDs, nil, Conf.Flashcard.NewCardLimit, Conf.Flashcard.ReviewCardLimit, Conf.Flashcard.ReviewMode)
711 now := time.Now()
712 for _, card := range cards {
713 ret = append(ret, newFlashcard(card, deckID, now))
714 }
715 if 1 > len(ret) {
716 ret = []*Flashcard{}
717 }
718 unreviewedCount = unreviewedCnt
719 unreviewedNewCardCount = unreviewedNewCardCnt
720 unreviewedOldCardCount = unreviewedOldCardCnt
721 return
722}
723
724func getAllDueFlashcards(reviewedCardIDs []string) (ret []*Flashcard, unreviewedCount, unreviewedNewCardCount, unreviewedOldCardCount int) {
725 now := time.Now()
726 for _, deck := range Decks {
727 if deck.ID != builtinDeckID {
728 // Alt+0 闪卡复习入口不再返回卡包闪卡
729 // Alt+0 flashcard review entry no longer returns to card deck flashcards https://github.com/siyuan-note/siyuan/issues/10635
730 continue
731 }
732
733 cards, unreviewedCnt, unreviewedNewCardCnt, unreviewedOldCardCnt := getDeckDueCards(deck, reviewedCardIDs, nil, Conf.Flashcard.NewCardLimit, Conf.Flashcard.ReviewCardLimit, Conf.Flashcard.ReviewMode)
734 unreviewedCount += unreviewedCnt
735 unreviewedNewCardCount += unreviewedNewCardCnt
736 unreviewedOldCardCount += unreviewedOldCardCnt
737 for _, card := range cards {
738 ret = append(ret, newFlashcard(card, deck.ID, now))
739 }
740 }
741 if 1 > len(ret) {
742 ret = []*Flashcard{}
743 }
744 return
745}
746
747func (tx *Transaction) doRemoveFlashcards(operation *Operation) (ret *TxErr) {
748 deckLock.Lock()
749 defer deckLock.Unlock()
750
751 if isSyncingStorages() {
752 ret = &TxErr{code: TxErrCodeDataIsSyncing}
753 return
754 }
755
756 deckID := operation.DeckID
757 blockIDs := operation.BlockIDs
758
759 if err := tx.removeBlocksDeckAttr(blockIDs, deckID); err != nil {
760 return &TxErr{code: TxErrCodeWriteTree, msg: err.Error(), id: deckID}
761 }
762
763 if "" == deckID { // 支持在 All 卡包中移除闪卡 https://github.com/siyuan-note/siyuan/issues/7425
764 for _, deck := range Decks {
765 removeFlashcardsByBlockIDs(blockIDs, deck)
766 }
767 } else {
768 removeFlashcardsByBlockIDs(blockIDs, Decks[deckID])
769 }
770 return
771}
772
773func (tx *Transaction) removeBlocksDeckAttr(blockIDs []string, deckID string) (err error) {
774 var rootIDs []string
775 blockRoots := map[string]string{}
776 for _, blockID := range blockIDs {
777 bt := treenode.GetBlockTree(blockID)
778 if nil == bt {
779 continue
780 }
781
782 rootIDs = append(rootIDs, bt.RootID)
783 blockRoots[blockID] = bt.RootID
784 }
785 rootIDs = gulu.Str.RemoveDuplicatedElem(rootIDs)
786
787 trees := map[string]*parse.Tree{}
788 for _, blockID := range blockIDs {
789 rootID := blockRoots[blockID]
790
791 tree := trees[rootID]
792 if nil == tree {
793 tree, _ = tx.loadTree(blockID)
794 }
795 if nil == tree {
796 continue
797 }
798 trees[rootID] = tree
799
800 node := treenode.GetNodeInTree(tree, blockID)
801 if nil == node {
802 continue
803 }
804
805 oldAttrs := parse.IAL2Map(node.KramdownIAL)
806
807 deckAttrs := node.IALAttr("custom-riff-decks")
808 var deckIDs []string
809 if "" != deckID {
810 availableDeckIDs := getDeckIDs()
811 for _, dID := range strings.Split(deckAttrs, ",") {
812 if dID != deckID && gulu.Str.Contains(dID, availableDeckIDs) {
813 deckIDs = append(deckIDs, dID)
814 }
815 }
816 }
817
818 deckIDs = gulu.Str.RemoveDuplicatedElem(deckIDs)
819 val := strings.Join(deckIDs, ",")
820 val = strings.TrimPrefix(val, ",")
821 val = strings.TrimSuffix(val, ",")
822 if "" == val {
823 node.RemoveIALAttr("custom-riff-decks")
824 } else {
825 node.SetIALAttr("custom-riff-decks", val)
826 }
827
828 if err = tx.writeTree(tree); err != nil {
829 return
830 }
831
832 cache.PutBlockIAL(blockID, parse.IAL2Map(node.KramdownIAL))
833 pushBroadcastAttrTransactions(oldAttrs, node)
834 }
835
836 return
837}
838
839func removeFlashcardsByBlockIDs(blockIDs []string, deck *riff.Deck) {
840 if nil == deck {
841 logging.LogErrorf("deck is nil")
842 return
843 }
844
845 cards := deck.GetCardsByBlockIDs(blockIDs)
846 if 1 > len(cards) {
847 return
848 }
849
850 for _, card := range cards {
851 deck.RemoveCard(card.ID())
852 }
853 err := deck.Save()
854 if err != nil {
855 logging.LogErrorf("save deck [%s] failed: %s", deck.ID, err)
856 }
857}
858
859func (tx *Transaction) doAddFlashcards(operation *Operation) (ret *TxErr) {
860 deckLock.Lock()
861 defer deckLock.Unlock()
862
863 if isSyncingStorages() {
864 ret = &TxErr{code: TxErrCodeDataIsSyncing}
865 return
866 }
867
868 deckID := operation.DeckID
869 blockIDs := operation.BlockIDs
870
871 foundDeck := false
872 for _, deck := range Decks {
873 if deckID == deck.ID {
874 foundDeck = true
875 break
876 }
877 }
878 if !foundDeck {
879 deck, createErr := createDeck0("Built-in Deck", builtinDeckID)
880 if nil == createErr {
881 Decks[deck.ID] = deck
882 }
883 }
884
885 blockRoots := map[string]string{}
886 for _, blockID := range blockIDs {
887 bt := treenode.GetBlockTree(blockID)
888 if nil == bt {
889 continue
890 }
891
892 blockRoots[blockID] = bt.RootID
893 }
894
895 trees := map[string]*parse.Tree{}
896 for _, blockID := range blockIDs {
897 rootID := blockRoots[blockID]
898
899 tree := trees[rootID]
900 if nil == tree {
901 tree, _ = tx.loadTree(blockID)
902 }
903 if nil == tree {
904 continue
905 }
906 trees[rootID] = tree
907
908 node := treenode.GetNodeInTree(tree, blockID)
909 if nil == node {
910 continue
911 }
912
913 oldAttrs := parse.IAL2Map(node.KramdownIAL)
914
915 deckAttrs := node.IALAttr("custom-riff-decks")
916 deckIDs := strings.Split(deckAttrs, ",")
917 deckIDs = append(deckIDs, deckID)
918 deckIDs = gulu.Str.RemoveDuplicatedElem(deckIDs)
919 val := strings.Join(deckIDs, ",")
920 val = strings.TrimPrefix(val, ",")
921 val = strings.TrimSuffix(val, ",")
922 node.SetIALAttr("custom-riff-decks", val)
923
924 if err := tx.writeTree(tree); err != nil {
925 return &TxErr{code: TxErrCodeWriteTree, msg: err.Error(), id: deckID}
926 }
927
928 cache.PutBlockIAL(blockID, parse.IAL2Map(node.KramdownIAL))
929 pushBroadcastAttrTransactions(oldAttrs, node)
930 }
931
932 deck := Decks[deckID]
933 if nil == deck {
934 logging.LogWarnf("deck [%s] not found", deckID)
935 return
936 }
937
938 for _, blockID := range blockIDs {
939 cards := deck.GetCardsByBlockID(blockID)
940 if 0 < len(cards) {
941 // 一个块只能添加生成一张闪卡 https://github.com/siyuan-note/siyuan/issues/7476
942 continue
943 }
944
945 deck.AddCard(ast.NewNodeID(), blockID)
946 }
947
948 if err := deck.Save(); err != nil {
949 logging.LogErrorf("save deck [%s] failed: %s", deckID, err)
950 return
951 }
952 return
953}
954
955func LoadFlashcards() {
956 riffSavePath := getRiffDir()
957 if err := os.MkdirAll(riffSavePath, 0755); err != nil {
958 logging.LogErrorf("create riff dir [%s] failed: %s", riffSavePath, err)
959 return
960 }
961
962 Decks = map[string]*riff.Deck{}
963
964 entries, err := os.ReadDir(riffSavePath)
965 if err != nil {
966 logging.LogErrorf("read riff dir failed: %s", err)
967 return
968 }
969 for _, entry := range entries {
970 name := entry.Name()
971 if strings.HasSuffix(name, ".deck") {
972 deckID := strings.TrimSuffix(name, ".deck")
973 deck, loadErr := riff.LoadDeck(riffSavePath, deckID, Conf.Flashcard.RequestRetention, Conf.Flashcard.MaximumInterval, Conf.Flashcard.Weights)
974 if nil != loadErr {
975 logging.LogErrorf("load deck [%s] failed: %s", name, loadErr)
976 continue
977 }
978
979 if 0 == deck.Created {
980 deck.Created = time.Now().Unix()
981 }
982 if 0 == deck.Updated {
983 deck.Updated = deck.Created
984 }
985
986 Decks[deckID] = deck
987 }
988 }
989}
990
991const builtinDeckID = "20230218211946-2kw8jgx"
992
993func RenameDeck(deckID, name string) (err error) {
994 deckLock.Lock()
995 defer deckLock.Unlock()
996
997 waitForSyncingStorages()
998
999 deck := Decks[deckID]
1000 deck.Name = name
1001 err = deck.Save()
1002 if err != nil {
1003 logging.LogErrorf("save deck [%s] failed: %s", deckID, err)
1004 return
1005 }
1006 return
1007}
1008
1009func RemoveDeck(deckID string) (err error) {
1010 deckLock.Lock()
1011 defer deckLock.Unlock()
1012
1013 waitForSyncingStorages()
1014
1015 riffSavePath := getRiffDir()
1016 deckPath := filepath.Join(riffSavePath, deckID+".deck")
1017 if filelock.IsExist(deckPath) {
1018 if err = filelock.Remove(deckPath); err != nil {
1019 return
1020 }
1021 }
1022
1023 cardsPath := filepath.Join(riffSavePath, deckID+".cards")
1024 if filelock.IsExist(cardsPath) {
1025 if err = filelock.Remove(cardsPath); err != nil {
1026 return
1027 }
1028 }
1029
1030 LoadFlashcards()
1031 return
1032}
1033
1034func CreateDeck(name string) (deck *riff.Deck, err error) {
1035 deckLock.Lock()
1036 defer deckLock.Unlock()
1037 return createDeck(name)
1038}
1039
1040func createDeck(name string) (deck *riff.Deck, err error) {
1041 waitForSyncingStorages()
1042
1043 deckID := ast.NewNodeID()
1044 deck, err = createDeck0(name, deckID)
1045 return
1046}
1047
1048func createDeck0(name string, deckID string) (deck *riff.Deck, err error) {
1049 riffSavePath := getRiffDir()
1050 deck, err = riff.LoadDeck(riffSavePath, deckID, Conf.Flashcard.RequestRetention, Conf.Flashcard.MaximumInterval, Conf.Flashcard.Weights)
1051 if err != nil {
1052 logging.LogErrorf("load deck [%s] failed: %s", deckID, err)
1053 return
1054 }
1055 deck.Name = name
1056 Decks[deckID] = deck
1057 err = deck.Save()
1058 if err != nil {
1059 logging.LogErrorf("save deck [%s] failed: %s", deckID, err)
1060 return
1061 }
1062 return
1063}
1064
1065func GetDecks() (decks []*riff.Deck) {
1066 deckLock.Lock()
1067 defer deckLock.Unlock()
1068
1069 for _, deck := range Decks {
1070 if deck.ID == builtinDeckID {
1071 continue
1072 }
1073
1074 decks = append(decks, deck)
1075 }
1076 if 1 > len(decks) {
1077 decks = []*riff.Deck{}
1078 }
1079
1080 sort.Slice(decks, func(i, j int) bool {
1081 return decks[i].Updated > decks[j].Updated
1082 })
1083 return
1084}
1085
1086func getRiffDir() string {
1087 return filepath.Join(util.DataDir, "storage", "riff")
1088}
1089
1090func getDeckIDs() (deckIDs []string) {
1091 for deckID := range Decks {
1092 deckIDs = append(deckIDs, deckID)
1093 }
1094 return
1095}
1096
1097func getDeckDueCards(deck *riff.Deck, reviewedCardIDs, blockIDs []string, newCardLimit, reviewCardLimit, reviewMode int) (ret []riff.Card, unreviewedCount, unreviewedNewCardCountInRound, unreviewedOldCardCountInRound int) {
1098 ret = []riff.Card{}
1099 var retNew, retOld []riff.Card
1100
1101 dues := deck.Dues()
1102 toChecks := map[string]riff.Card{}
1103 for _, c := range dues {
1104 if 0 < len(blockIDs) && !gulu.Str.Contains(c.BlockID(), blockIDs) {
1105 continue
1106 }
1107
1108 toChecks[c.BlockID()] = c
1109 }
1110 var toCheckBlockIDs []string
1111 var tmp []riff.Card
1112 for bID, _ := range toChecks {
1113 toCheckBlockIDs = append(toCheckBlockIDs, bID)
1114 }
1115 checkResult := treenode.ExistBlockTrees(toCheckBlockIDs)
1116 for bID, exists := range checkResult {
1117 if exists {
1118 tmp = append(tmp, toChecks[bID])
1119 }
1120 }
1121 dues = tmp
1122
1123 reviewedCardCount := len(reviewedCardIDs)
1124 if 1 > reviewedCardCount {
1125 // 未传入已复习的卡片 ID,说明是开始新的复习,需要清空缓存
1126 reviewCardCache = map[string]riff.Card{}
1127 skipCardCache = map[string]riff.Card{}
1128 }
1129
1130 newCount := 0
1131 reviewCount := 0
1132 for _, reviewedCard := range reviewCardCache {
1133 if riff.New == reviewedCard.GetState() {
1134 newCount++
1135 } else {
1136 reviewCount++
1137 }
1138 }
1139
1140 for _, c := range dues {
1141 if nil != skipCardCache[c.ID()] {
1142 continue
1143 }
1144
1145 if 0 < len(reviewedCardIDs) {
1146 if !gulu.Str.Contains(c.ID(), reviewedCardIDs) {
1147 unreviewedCount++
1148 if riff.New == c.GetState() {
1149 if newCount < newCardLimit {
1150 unreviewedNewCardCountInRound++
1151 }
1152 } else {
1153 if reviewCount < reviewCardLimit {
1154 unreviewedOldCardCountInRound++
1155 }
1156 }
1157 }
1158 } else {
1159 unreviewedCount++
1160 if riff.New == c.GetState() {
1161 if newCount < newCardLimit {
1162 unreviewedNewCardCountInRound++
1163 }
1164 } else {
1165 if reviewCount < reviewCardLimit {
1166 unreviewedOldCardCountInRound++
1167 }
1168 }
1169 }
1170
1171 if riff.New == c.GetState() {
1172 if newCount >= newCardLimit {
1173 continue
1174 }
1175
1176 newCount++
1177 retNew = append(retNew, c)
1178 } else {
1179 if reviewCount >= reviewCardLimit {
1180 continue
1181 }
1182
1183 reviewCount++
1184 retOld = append(retOld, c)
1185 }
1186
1187 ret = append(ret, c)
1188 }
1189
1190 switch reviewMode {
1191 case 1: // 优先复习新卡
1192 ret = nil
1193 ret = append(ret, retNew...)
1194 ret = append(ret, retOld...)
1195 case 2: // 优先复习旧卡
1196 ret = nil
1197 ret = append(ret, retOld...)
1198 ret = append(ret, retNew...)
1199 }
1200 return
1201}