Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(new reviewer): type in the answer (native) #17647

Merged
merged 11 commits into from
Dec 23, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jetbrains.annotations.VisibleForTesting
import org.json.JSONObject
import timber.log.Timber

abstract class CardViewerViewModel(
Expand Down Expand Up @@ -108,12 +106,18 @@ abstract class CardViewerViewModel(
*************************************** Internal methods ***************************************
********************************************************************************************* */

protected abstract suspend fun typeAnsFilter(text: String): String
protected abstract suspend fun typeAnsFilter(
text: String,
typedAnswer: String? = null,
): String

private suspend fun bodyClass(): String = bodyClassForCardOrd(currentCard.await().ord)

/** From the [desktop code](https://github.com/ankitects/anki/blob/1ff55475b93ac43748d513794bcaabd5d7df6d9d/qt/aqt/reviewer.py#L358) */
private suspend fun mungeQA(text: String): String = typeAnsFilter(prepareCardTextForDisplay(text))
private suspend fun mungeQA(
text: String,
typedAnswer: String? = null,
): String = typeAnsFilter(prepareCardTextForDisplay(text), typedAnswer)

private suspend fun prepareCardTextForDisplay(text: String): String =
Sound.addPlayButtons(
Expand All @@ -134,13 +138,21 @@ abstract class CardViewerViewModel(
eval.emit("_showQuestion(${Json.encodeToString(question)}, ${Json.encodeToString(answer)}, '${bodyClass()}');")
}

protected open suspend fun showAnswerInternal() {
/**
* Parses the card answer and sends a [eval] request to load it into the `qa` HTML div
*
* * [Anki reference](https://github.com/ankitects/anki/blob/c985acb9fe36d3651eb83cf4cfe44d046ec7458f/qt/aqt/reviewer.py#L460)
* * [Typescript reference](https://github.com/ankitects/anki/blob/c985acb9fe36d3651eb83cf4cfe44d046ec7458f/ts/reviewer/index.ts#L193)
*
* @see [stdHtml]
*/
protected open suspend fun showAnswer(typedAnswer: String? = null) {
Timber.v("showAnswer()")
showingAnswer.emit(true)

val card = currentCard.await()
val answerData = withCol { card.answer(this) }
val answer = mungeQA(answerData)
val answer = mungeQA(answerData, typedAnswer)

eval.emit("_showAnswer(${Json.encodeToString(answer)}, '${bodyClass()}');")
}
Expand Down Expand Up @@ -195,54 +207,4 @@ abstract class CardViewerViewModel(
} else {
throw IllegalArgumentException("Unhandled POST request: $uri")
}

companion object {
// ********************************** Type-in answer ************************************
/** From the [desktop code](https://github.com/ankitects/anki/blob/1ff55475b93ac43748d513794bcaabd5d7df6d9d/qt/aqt/reviewer.py#L669] */
@VisibleForTesting
val typeAnsRe = Regex("\\[\\[type:(.+?)]]")

suspend fun getTypeAnswerField(
card: Card,
text: String,
): JSONObject? {
val match = typeAnsRe.find(text) ?: return null

val typeAnsFieldName =
match.groups[1]!!.value.let {
if (it.startsWith("cloze:")) {
it.split(":")[1]
} else {
it
}
}

val fields = withCol { card.noteType(this).flds }
for (i in 0 until fields.length()) {
val field = fields.get(i) as JSONObject
if (field.getString("name") == typeAnsFieldName) {
return field
}
}
return null
}

suspend fun getExpectedTypeInAnswer(
card: Card,
field: JSONObject,
): String? {
val fieldName = field.getString("name")
val expected = withCol { card.note(this@withCol).getItem(fieldName) }
return if (fieldName.startsWith("cloze:")) {
val clozeIdx = card.ord + 1
withCol {
extractClozeForTyping(expected, clozeIdx).takeIf { it.isNotBlank() }
}
} else {
expected
}
}

fun getFontSize(field: JSONObject): String = field.getString("size")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import org.intellij.lang.annotations.Language
import org.jetbrains.annotations.VisibleForTesting
import timber.log.Timber

class PreviewerViewModel(
Expand Down Expand Up @@ -109,7 +107,7 @@ class PreviewerViewModel(
showQuestion()
cardMediaPlayer.autoplayAllSoundsForSide(CardSide.QUESTION)
} else if (backSideOnly.value && !showingAnswer.value) {
showAnswerInternal()
showAnswer()
cardMediaPlayer.autoplayAllSoundsForSide(CardSide.ANSWER)
}
}
Expand Down Expand Up @@ -149,7 +147,7 @@ class PreviewerViewModel(
fun onNextButtonClick() {
launchCatchingIO {
if (!showingAnswer.value && !backSideOnly.value) {
showAnswerInternal()
showAnswer()
cardMediaPlayer.autoplayAllSoundsForSide(CardSide.ANSWER)
} else {
currentIndex.update { it + 1 }
Expand Down Expand Up @@ -209,7 +207,7 @@ class PreviewerViewModel(
asyncIO {
withCol { getCard(selectedCardIds[currentIndex.value]) }
}
if (showAnswer) showAnswerInternal() else showQuestion()
if (showAnswer) showAnswer() else showQuestion()
updateFlagIcon()
updateMarkIcon()
}
Expand All @@ -236,11 +234,15 @@ class PreviewerViewModel(
}

/** From the [desktop code](https://github.com/ankitects/anki/blob/1ff55475b93ac43748d513794bcaabd5d7df6d9d/qt/aqt/reviewer.py#L671) */
override suspend fun typeAnsFilter(text: String): String =
override suspend fun typeAnsFilter(
text: String,
typedAnswer: String?,
): String =
if (showingAnswer.value) {
typeAnsAnswerFilter(currentCard.await(), text)
val typeAnswer = TypeAnswer.getInstance(currentCard.await(), text)
typeAnswer?.answerFilter() ?: text
} else {
typeAnsQuestionFilter(text)
TypeAnswer.removeTags(text)
}

companion object {
Expand All @@ -254,30 +256,5 @@ class PreviewerViewModel(
PreviewerViewModel(previewerIdsFile, currentIndex, cardMediaPlayer)
}
}

/** removes `[[type:]]` blocks in questions */
@VisibleForTesting
fun typeAnsQuestionFilter(text: String) = typeAnsRe.replace(text, "")

/** Adapted from the [desktop code](https://github.com/ankitects/anki/blob/1ff55475b93ac43748d513794bcaabd5d7df6d9d/qt/aqt/reviewer.py#L720) */
suspend fun typeAnsAnswerFilter(
card: Card,
text: String,
): String {
val typeAnswerField =
getTypeAnswerField(card, text)
?: return typeAnsRe.replace(text, "")
val expectedAnswer =
getExpectedTypeInAnswer(card, typeAnswerField)
?: return typeAnsRe.replace(text, "")
val typeFont = typeAnswerField.getString("font")
val typeSize = getFontSize(typeAnswerField)
val answerComparison = withCol { compareAnswer(expectedAnswer, provided = "") }

@Language("HTML")
val output =
"""<div style="font-family: '$typeFont'; font-size: ${typeSize}px">$answerComparison</div>"""
return typeAnsRe.replace(text, output)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import com.ichi2.anki.cardviewer.CardMediaPlayer
import com.ichi2.anki.launchCatchingIO
import com.ichi2.anki.pages.AnkiServer
import com.ichi2.anki.reviewer.CardSide
import com.ichi2.anki.utils.ext.ifNullOrEmpty
import com.ichi2.libanki.Card
import com.ichi2.libanki.Note
import com.ichi2.libanki.NotetypeJson
Expand All @@ -37,7 +36,6 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.parcelize.Parcelize
import org.intellij.lang.annotations.Language
import org.jetbrains.annotations.VisibleForTesting

class TemplatePreviewerViewModel(
Expand Down Expand Up @@ -117,7 +115,7 @@ class TemplatePreviewerViewModel(
if (isAfterRecreation) {
launchCatchingIO {
// TODO: We should persist showingAnswer to SavedStateHandle
if (showingAnswer.value) showAnswerInternal() else showQuestion()
if (showingAnswer.value) showAnswer() else showQuestion()
}
return
}
Expand Down Expand Up @@ -147,7 +145,7 @@ class TemplatePreviewerViewModel(
showQuestion()
loadAndPlaySounds(CardSide.QUESTION)
} else {
showAnswerInternal()
showAnswer()
loadAndPlaySounds(CardSide.ANSWER)
}
}
Expand Down Expand Up @@ -186,37 +184,21 @@ class TemplatePreviewerViewModel(
}

// https://github.com/ankitects/anki/blob/df70564079f53e587dc44f015c503fdf6a70924f/qt/aqt/clayout.py#L579
override suspend fun typeAnsFilter(text: String): String {
val typeAnswerField = getTypeAnswerField(currentCard.await(), text)
val expectedAnswer =
typeAnswerField
?.let {
getExpectedTypeInAnswer(currentCard.await(), typeAnswerField)
}.ifNullOrEmpty { "sample" }

val repl =
if (showingAnswer.value) {
withCol { compareAnswer(expectedAnswer, "example") }
} else {
"<center><input id='typeans' type=text value='example' readonly='readonly'></center>"
}
// Anki doesn't set the font size of the type answer field in the template previewer,
// but it does in the reviewer. To get a more accurate preview of what people are going
// to study, the font size is being set here.
val out =
if (typeAnswerField != null) {
val fontSize = getFontSize(typeAnswerField)

@Language("HTML")
val replWithFontSize = """<div style="font-size: ${fontSize}px">$repl</div>"""
typeAnsRe.replaceFirst(text, replWithFontSize)
} else {
typeAnsRe.replaceFirst(text, repl)
override suspend fun typeAnsFilter(
text: String,
typedAnswer: String?,
): String =
if (showingAnswer.value) {
val typeAnswer = TypeAnswer.getInstance(currentCard.await(), text)
if (typeAnswer?.expectedAnswer?.isEmpty() == true) {
typeAnswer.expectedAnswer = "sample"
}

val warning = "<center><b>${CollectionManager.TR.cardTemplatesTypeBoxesWarning()}</b></center>"
return typeAnsRe.replace(out, warning)
}
typeAnswer?.answerFilter(typedAnswer = "example") ?: text
} else {
val repl = "<center><input id='typeans' type=text value='example' readonly='readonly'></center>"
val warning = "<center><b>${CollectionManager.TR.cardTemplatesTypeBoxesWarning()}</b></center>"
StringBuilder(text).replaceFirst(typeAnsRe, repl).replace(typeAnsRe, warning)
}

companion object {
fun factory(
Expand Down
Loading
Loading