Skip to content

Commit d5bbbe7

Browse files
Merge pull request #76 from kirill-markin/codex/sql-bounded-review-queue
Bound review queue with SQL window across clients
2 parents 5ed018e + 575cd38 commit d5bbbe7

40 files changed

Lines changed: 9238 additions & 536 deletions

apps/android/app/src/main/java/com/flashcardsopensourceapp/app/notifications/ReviewNotificationsManager.kt

Lines changed: 157 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import com.flashcardsopensourceapp.data.local.model.EffortLevel
1919
import com.flashcardsopensourceapp.data.local.model.ReviewFilter
2020
import com.flashcardsopensourceapp.data.local.model.decodeDeckFilterDefinitionJson
2121
import com.flashcardsopensourceapp.data.local.model.normalizeTagKey
22-
import com.flashcardsopensourceapp.data.local.model.normalizeTags
2322
import com.flashcardsopensourceapp.data.local.notifications.CurrentReviewNotificationCard
2423
import com.flashcardsopensourceapp.data.local.notifications.ReviewNotificationMode
2524
import com.flashcardsopensourceapp.data.local.notifications.ReviewNotificationsReconcileTrigger
@@ -138,9 +137,24 @@ class ReviewNotificationsManager(
138137
val selectedReviewFilter = reviewPreferencesStore.loadSelectedReviewFilter(
139138
workspaceId = workspace.workspaceId
140139
)
140+
val reviewNotificationFilterPlan = loadReviewNotificationFilterPlan(
141+
workspaceId = workspace.workspaceId,
142+
selectedReviewFilter = selectedReviewFilter
143+
)
144+
val resolvedReviewFilter = when (reviewNotificationFilterPlan) {
145+
is ReviewNotificationFilterPlan.Schedule -> reviewNotificationFilterPlan.reviewFilter
146+
ReviewNotificationFilterPlan.SuppressScheduledPayloads -> {
147+
reviewNotificationsStore.saveScheduledPayloads(
148+
workspaceId = workspace.workspaceId,
149+
payloads = emptyList()
150+
)
151+
return
152+
}
153+
}
154+
141155
val currentCard = loadCurrentReviewNotificationCard(
142156
workspaceId = workspace.workspaceId,
143-
reviewFilter = selectedReviewFilter,
157+
reviewFilter = resolvedReviewFilter,
144158
nowMillis = nowMillis
145159
)
146160

@@ -172,7 +186,7 @@ class ReviewNotificationsManager(
172186
}
173187
}
174188
} else {
175-
val persistedReviewFilter = makePersistedReviewFilter(reviewFilter = selectedReviewFilter)
189+
val persistedReviewFilter = makePersistedReviewFilter(reviewFilter = resolvedReviewFilter)
176190
val fallbackFrontText = reviewTextProvider(context = context).notificationFallbackFrontText
177191
when (settings.selectedMode) {
178192
ReviewNotificationMode.DAILY -> buildFallbackDailyReminderPayloads(
@@ -327,6 +341,40 @@ class ReviewNotificationsManager(
327341
}
328342
}
329343

344+
private suspend fun loadReviewNotificationFilterPlan(
345+
workspaceId: String,
346+
selectedReviewFilter: ReviewFilter
347+
): ReviewNotificationFilterPlan {
348+
val selectedDeckFilterDefinition = when (selectedReviewFilter) {
349+
is ReviewFilter.Deck -> loadCurrentWorkspaceDeckFilterDefinitionOrNull(
350+
workspaceId = workspaceId,
351+
deckId = selectedReviewFilter.deckId
352+
)
353+
354+
ReviewFilter.AllCards,
355+
is ReviewFilter.Effort,
356+
is ReviewFilter.Tag -> null
357+
}
358+
359+
return resolveReviewNotificationFilterPlan(
360+
selectedReviewFilter = selectedReviewFilter,
361+
activeReviewTagNames = loadActiveReviewTagNames(workspaceId = workspaceId),
362+
selectedDeckFilterDefinition = selectedDeckFilterDefinition
363+
)
364+
}
365+
366+
private suspend fun loadCurrentWorkspaceDeckFilterDefinitionOrNull(
367+
workspaceId: String,
368+
deckId: String
369+
): DeckFilterDefinition? {
370+
val deck = database.deckDao().loadDeck(deckId = deckId) ?: return null
371+
if (deck.workspaceId != workspaceId || deck.deletedAtMillis != null) {
372+
return null
373+
}
374+
375+
return decodeDeckFilterDefinitionJson(filterDefinitionJson = deck.filterDefinitionJson)
376+
}
377+
330378
private suspend fun loadCurrentAllCardsReviewNotificationCard(
331379
workspaceId: String,
332380
nowMillis: Long
@@ -349,7 +397,7 @@ class ReviewNotificationsManager(
349397
nowMillis: Long
350398
): CurrentReviewNotificationCard? {
351399
val deck = database.deckDao().loadDeck(deckId = deckId)
352-
if (deck == null || deck.deletedAtMillis != null) {
400+
if (deck == null || deck.workspaceId != workspaceId || deck.deletedAtMillis != null) {
353401
return loadCurrentAllCardsReviewNotificationCard(
354402
workspaceId = workspaceId,
355403
nowMillis = nowMillis
@@ -395,11 +443,11 @@ class ReviewNotificationsManager(
395443
tag: String,
396444
nowMillis: Long
397445
): CurrentReviewNotificationCard? {
398-
val hasTag = database.tagDao().hasTag(
446+
val exactTagNames = loadExactStoredReviewTagNames(
399447
workspaceId = workspaceId,
400-
tagName = tag
448+
requestedTagNames = listOf(tag)
401449
)
402-
if (hasTag.not()) {
450+
if (exactTagNames.isEmpty()) {
403451
return loadCurrentAllCardsReviewNotificationCard(
404452
workspaceId = workspaceId,
405453
nowMillis = nowMillis
@@ -409,7 +457,7 @@ class ReviewNotificationsManager(
409457
val card = database.cardDao().loadTopReviewCardByAnyTags(
410458
workspaceId = workspaceId,
411459
nowMillis = nowMillis,
412-
normalizedTagNames = listOf(normalizeTagKey(tag = tag))
460+
tagNames = exactTagNames
413461
) ?: return null
414462

415463
return CurrentReviewNotificationCard(
@@ -424,34 +472,36 @@ class ReviewNotificationsManager(
424472
nowMillis: Long,
425473
filterDefinition: DeckFilterDefinition
426474
): com.flashcardsopensourceapp.data.local.database.CardEntity? {
427-
val normalizedTagNames = normalizeTags(
428-
values = filterDefinition.tags,
429-
referenceTags = emptyList()
430-
).map { tag ->
431-
normalizeTagKey(tag = tag)
475+
val exactTagNames = loadExactStoredReviewTagNames(
476+
workspaceId = workspaceId,
477+
requestedTagNames = filterDefinition.tags
478+
)
479+
val hasTagPredicate = filterDefinition.tags.isNotEmpty()
480+
if (hasTagPredicate && exactTagNames.isEmpty()) {
481+
return null
432482
}
433483

434484
return when {
435-
filterDefinition.effortLevels.isEmpty() && normalizedTagNames.isEmpty() -> {
485+
filterDefinition.effortLevels.isEmpty() && hasTagPredicate.not() -> {
436486
database.cardDao().loadTopReviewCard(
437487
workspaceId = workspaceId,
438488
nowMillis = nowMillis
439489
)
440490
}
441491

442-
filterDefinition.effortLevels.isNotEmpty() && normalizedTagNames.isEmpty() -> {
492+
filterDefinition.effortLevels.isNotEmpty() && hasTagPredicate.not() -> {
443493
database.cardDao().loadTopReviewCardByEffortLevels(
444494
workspaceId = workspaceId,
445495
nowMillis = nowMillis,
446496
effortLevels = filterDefinition.effortLevels
447497
)
448498
}
449499

450-
filterDefinition.effortLevels.isEmpty() && normalizedTagNames.isNotEmpty() -> {
500+
filterDefinition.effortLevels.isEmpty() -> {
451501
database.cardDao().loadTopReviewCardByAnyTags(
452502
workspaceId = workspaceId,
453503
nowMillis = nowMillis,
454-
normalizedTagNames = normalizedTagNames
504+
tagNames = exactTagNames
455505
)
456506
}
457507

@@ -460,13 +510,102 @@ class ReviewNotificationsManager(
460510
workspaceId = workspaceId,
461511
nowMillis = nowMillis,
462512
effortLevels = filterDefinition.effortLevels,
463-
normalizedTagNames = normalizedTagNames
513+
tagNames = exactTagNames
514+
)
515+
}
516+
}
517+
}
518+
519+
private suspend fun loadExactStoredReviewTagNames(
520+
workspaceId: String,
521+
requestedTagNames: List<String>
522+
): List<String> {
523+
return resolveExactStoredReviewTagNames(
524+
requestedTagNames = requestedTagNames,
525+
storedTagNames = loadActiveReviewTagNames(workspaceId = workspaceId)
526+
)
527+
}
528+
529+
private suspend fun loadActiveReviewTagNames(workspaceId: String): List<String> {
530+
return database.tagDao().loadReviewTagNames(workspaceId = workspaceId)
531+
}
532+
}
533+
534+
internal sealed interface ReviewNotificationFilterPlan {
535+
data class Schedule(
536+
val reviewFilter: ReviewFilter
537+
) : ReviewNotificationFilterPlan
538+
539+
data object SuppressScheduledPayloads : ReviewNotificationFilterPlan
540+
}
541+
542+
internal fun resolveReviewNotificationFilterPlan(
543+
selectedReviewFilter: ReviewFilter,
544+
activeReviewTagNames: List<String>,
545+
selectedDeckFilterDefinition: DeckFilterDefinition?
546+
): ReviewNotificationFilterPlan {
547+
return when (selectedReviewFilter) {
548+
ReviewFilter.AllCards -> ReviewNotificationFilterPlan.Schedule(reviewFilter = ReviewFilter.AllCards)
549+
is ReviewFilter.Effort -> ReviewNotificationFilterPlan.Schedule(reviewFilter = selectedReviewFilter)
550+
is ReviewFilter.Tag -> {
551+
val exactTagName = resolveExactStoredReviewTagNames(
552+
requestedTagNames = listOf(selectedReviewFilter.tag),
553+
storedTagNames = activeReviewTagNames
554+
).firstOrNull()
555+
val resolvedReviewFilter = exactTagName?.let { tagName ->
556+
ReviewFilter.Tag(tag = tagName)
557+
} ?: ReviewFilter.AllCards
558+
559+
ReviewNotificationFilterPlan.Schedule(reviewFilter = resolvedReviewFilter)
560+
}
561+
562+
is ReviewFilter.Deck -> {
563+
if (selectedDeckFilterDefinition == null) {
564+
return ReviewNotificationFilterPlan.Schedule(reviewFilter = ReviewFilter.AllCards)
565+
}
566+
567+
if (hasImpossibleStoredTagDeckPredicate(
568+
filterDefinition = selectedDeckFilterDefinition,
569+
storedTagNames = activeReviewTagNames
464570
)
571+
) {
572+
ReviewNotificationFilterPlan.SuppressScheduledPayloads
573+
} else {
574+
ReviewNotificationFilterPlan.Schedule(reviewFilter = selectedReviewFilter)
465575
}
466576
}
467577
}
468578
}
469579

580+
internal fun hasImpossibleStoredTagDeckPredicate(
581+
filterDefinition: DeckFilterDefinition,
582+
storedTagNames: List<String>
583+
): Boolean {
584+
return filterDefinition.tags.isNotEmpty() && resolveExactStoredReviewTagNames(
585+
requestedTagNames = filterDefinition.tags,
586+
storedTagNames = storedTagNames
587+
).isEmpty()
588+
}
589+
590+
internal fun resolveExactStoredReviewTagNames(
591+
requestedTagNames: List<String>,
592+
storedTagNames: List<String>
593+
): List<String> {
594+
val requestedTagKeys: List<String> = requestedTagNames.map { tagName ->
595+
normalizeTagKey(tag = tagName)
596+
}.filter { tagKey ->
597+
tagKey.isNotEmpty()
598+
}.distinct()
599+
if (requestedTagKeys.isEmpty()) {
600+
return emptyList()
601+
}
602+
603+
val requestedTagKeySet: Set<String> = requestedTagKeys.toSet()
604+
return storedTagNames.filter { storedTagName ->
605+
requestedTagKeySet.contains(normalizeTagKey(tag = storedTagName))
606+
}.distinct()
607+
}
608+
470609
fun hasNotificationPermission(context: Context): Boolean {
471610
return ContextCompat.checkSelfPermission(
472611
context,

apps/android/app/src/test/java/com/flashcardsopensourceapp/app/notifications/ReviewNotificationsManagerTest.kt

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.flashcardsopensourceapp.app.notifications
22

3+
import com.flashcardsopensourceapp.data.local.model.DeckFilterDefinition
4+
import com.flashcardsopensourceapp.data.local.model.ReviewFilter
35
import org.junit.Assert.assertEquals
46
import org.junit.Assert.assertNull
57
import org.junit.Test
@@ -63,4 +65,111 @@ class ReviewNotificationsManagerTest {
6365
requireNotNull(request)
6466
assertEquals(AppNotificationTapType.STRICT_REMINDER, request.type)
6567
}
68+
69+
@Test
70+
fun exactStoredReviewTagResolutionPreservesUnicodeStoredNames() {
71+
val exactTagNames = resolveExactStoredReviewTagNames(
72+
requestedTagNames = listOf("éclair", "привет"),
73+
storedTagNames = listOf("Éclair", "Plain", "Привет")
74+
)
75+
76+
assertEquals(listOf("Éclair", "Привет"), exactTagNames)
77+
}
78+
79+
@Test
80+
fun exactStoredReviewTagResolutionReturnsEmptyForImpossibleTagPredicate() {
81+
val exactTagNames = resolveExactStoredReviewTagNames(
82+
requestedTagNames = listOf("missing-tag"),
83+
storedTagNames = listOf("Éclair", "Plain")
84+
)
85+
86+
assertEquals(emptyList<String>(), exactTagNames)
87+
}
88+
89+
@Test
90+
fun directMissingTagFilterSchedulesAllCardsPlan() {
91+
val plan: ReviewNotificationFilterPlan = resolveReviewNotificationFilterPlan(
92+
selectedReviewFilter = ReviewFilter.Tag(tag = "missing-tag"),
93+
activeReviewTagNames = listOf("Éclair", "Plain"),
94+
selectedDeckFilterDefinition = null
95+
)
96+
97+
assertEquals(
98+
ReviewNotificationFilterPlan.Schedule(reviewFilter = ReviewFilter.AllCards),
99+
plan
100+
)
101+
}
102+
103+
@Test
104+
fun directDeletedOnlyTagFilterSchedulesAllCardsFromActiveReviewTagSource() {
105+
val plan: ReviewNotificationFilterPlan = resolveReviewNotificationFilterPlan(
106+
selectedReviewFilter = ReviewFilter.Tag(tag = "stale"),
107+
activeReviewTagNames = listOf("Visible"),
108+
selectedDeckFilterDefinition = null
109+
)
110+
111+
assertEquals(
112+
ReviewNotificationFilterPlan.Schedule(reviewFilter = ReviewFilter.AllCards),
113+
plan
114+
)
115+
}
116+
117+
@Test
118+
fun deckFilterWithMissingStoredTagPredicateSuppressesScheduledPayloads() {
119+
val plan: ReviewNotificationFilterPlan = resolveReviewNotificationFilterPlan(
120+
selectedReviewFilter = ReviewFilter.Deck(deckId = "deck-1"),
121+
activeReviewTagNames = listOf("Éclair", "Plain"),
122+
selectedDeckFilterDefinition = DeckFilterDefinition(
123+
version = 2,
124+
effortLevels = emptyList(),
125+
tags = listOf("missing-tag")
126+
)
127+
)
128+
129+
assertEquals(
130+
ReviewNotificationFilterPlan.SuppressScheduledPayloads,
131+
plan
132+
)
133+
}
134+
135+
@Test
136+
fun missingDeckFilterSchedulesAllCardsPlan() {
137+
val plan: ReviewNotificationFilterPlan = resolveReviewNotificationFilterPlan(
138+
selectedReviewFilter = ReviewFilter.Deck(deckId = "missing-deck"),
139+
activeReviewTagNames = listOf("Éclair", "Plain"),
140+
selectedDeckFilterDefinition = null
141+
)
142+
143+
assertEquals(
144+
ReviewNotificationFilterPlan.Schedule(reviewFilter = ReviewFilter.AllCards),
145+
plan
146+
)
147+
}
148+
149+
@Test
150+
fun validUnicodeCaseNormalizedTagAndDeckFiltersRemainSchedulable() {
151+
val tagPlan: ReviewNotificationFilterPlan = resolveReviewNotificationFilterPlan(
152+
selectedReviewFilter = ReviewFilter.Tag(tag = "éclair"),
153+
activeReviewTagNames = listOf("Éclair", "Привет"),
154+
selectedDeckFilterDefinition = null
155+
)
156+
val deckPlan: ReviewNotificationFilterPlan = resolveReviewNotificationFilterPlan(
157+
selectedReviewFilter = ReviewFilter.Deck(deckId = "deck-1"),
158+
activeReviewTagNames = listOf("Éclair", "Привет"),
159+
selectedDeckFilterDefinition = DeckFilterDefinition(
160+
version = 2,
161+
effortLevels = emptyList(),
162+
tags = listOf("привет")
163+
)
164+
)
165+
166+
assertEquals(
167+
ReviewNotificationFilterPlan.Schedule(reviewFilter = ReviewFilter.Tag(tag = "Éclair")),
168+
tagPlan
169+
)
170+
assertEquals(
171+
ReviewNotificationFilterPlan.Schedule(reviewFilter = ReviewFilter.Deck(deckId = "deck-1")),
172+
deckPlan
173+
)
174+
}
66175
}

0 commit comments

Comments
 (0)