forked from ankidroid/Anki-Android
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'ankidroid:master' into master
- Loading branch information
Showing
12 changed files
with
684 additions
and
604 deletions.
There are no files selected for viewing
270 changes: 21 additions & 249 deletions
270
AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.java
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
308 changes: 308 additions & 0 deletions
308
AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,308 @@ | ||
/* | ||
* Copyright (c) 2021 David Allison <[email protected]> | ||
* | ||
* This program is free software; you can redistribute it and/or modify it under | ||
* the terms of the GNU General Public License as published by the Free Software | ||
* Foundation; either version 3 of the License, or (at your option) any later | ||
* version. | ||
* | ||
* This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
* PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
* | ||
* You should have received a copy of the GNU General Public License along with | ||
* this program. If not, see <http://www.gnu.org/licenses/>. | ||
*/ | ||
|
||
package com.ichi2.anki.cardviewer | ||
|
||
import android.content.SharedPreferences | ||
import android.content.res.Resources | ||
import android.text.TextUtils | ||
import androidx.annotation.VisibleForTesting | ||
import com.ichi2.anki.R | ||
import com.ichi2.libanki.Card | ||
import com.ichi2.libanki.Sound | ||
import com.ichi2.libanki.Utils | ||
import com.ichi2.utils.DiffEngine | ||
import com.ichi2.utils.JSONArray | ||
import timber.log.Timber | ||
import java.util.* | ||
import java.util.regex.Matcher | ||
import java.util.regex.Pattern | ||
|
||
class TypeAnswer( | ||
@get:JvmName("useInputTag") val useInputTag: Boolean, | ||
@get:JvmName("doNotUseCodeFormatting") val doNotUseCodeFormatting: Boolean, | ||
/** Preference: Whether the user wants to focus "type in answer" */ | ||
val autoFocus: Boolean | ||
) { | ||
|
||
/** The correct answer in the compare to field if answer should be given by learner. Null if no answer is expected. */ | ||
var correct: String? = null | ||
private set | ||
|
||
/** What the learner actually typed (externally mutable) */ | ||
var input = "" | ||
/** Font face of the 'compare to' field */ | ||
var font = "" | ||
private set | ||
/** The font size of the 'compare to' field */ | ||
var size = 0 | ||
private set | ||
|
||
/** | ||
* Optional warning for when a typed answer can't be displayed | ||
* | ||
* * empty card [R.string.empty_card_warning] | ||
* * unknown field specified [R.string.unknown_type_field_warning] | ||
* */ | ||
var warning: String? = null | ||
private set | ||
|
||
/** | ||
* @return true If entering input via EditText | ||
* and if the current card has a {{type:field}} on the card template | ||
*/ | ||
fun validForEditText(): Boolean { | ||
return !useInputTag && correct != null | ||
} | ||
|
||
fun autoFocusEditText(): Boolean { | ||
return validForEditText() && autoFocus | ||
} | ||
|
||
/** | ||
* Extract type answer/cloze text and font/size | ||
* @param card The next card to display | ||
*/ | ||
fun updateInfo(card: Card, res: Resources) { | ||
correct = null | ||
input = "" | ||
val q = card.q(false) | ||
val m = PATTERN.matcher(q) | ||
var clozeIdx = 0 | ||
if (!m.find()) { | ||
return | ||
} | ||
var fldTag = m.group(1)!! | ||
// if it's a cloze, extract data | ||
if (fldTag.startsWith("cloze:")) { | ||
// get field and cloze position | ||
clozeIdx = card.getOrd() + 1 | ||
fldTag = fldTag.split(":").toTypedArray()[1] | ||
} | ||
// loop through fields for a match | ||
val flds: JSONArray = card.model().getJSONArray("flds") | ||
for (fld in flds.jsonObjectIterable()) { | ||
val name = fld.getString("name") | ||
if (name == fldTag) { | ||
correct = card.note().getItem(name) | ||
if (clozeIdx != 0) { | ||
// narrow to cloze | ||
correct = contentForCloze(correct!!, clozeIdx) | ||
} | ||
font = fld.getString("font") | ||
size = fld.getInt("size") | ||
break | ||
} | ||
} | ||
when (correct) { | ||
null -> { | ||
warning = if (clozeIdx != 0) { | ||
res.getString(R.string.empty_card_warning) | ||
} else { | ||
res.getString(R.string.unknown_type_field_warning, fldTag) | ||
} | ||
} | ||
"" -> { | ||
correct = null | ||
} | ||
else -> { | ||
warning = null | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Format question field when it contains typeAnswer or clozes. If there was an error during type text extraction, a | ||
* warning is displayed | ||
* | ||
* @param buf The question text | ||
* @return The formatted question text | ||
*/ | ||
fun filterQuestion(buf: String): String? { | ||
val m = PATTERN.matcher(buf) | ||
if (warning != null) { | ||
return m.replaceFirst(warning!!) | ||
} | ||
val sb = java.lang.StringBuilder() | ||
if (useInputTag) { | ||
// These functions are defined in the JavaScript file assets/scripts/card.js. We get the text back in | ||
// shouldOverrideUrlLoading() in createWebView() in this file. | ||
sb.append( | ||
"""<center> | ||
<input type="text" name="typed" id="typeans" onfocus="taFocus();" onblur="taBlur(this);" onKeyPress="return taKey(this, event)" autocomplete="off" """ | ||
) | ||
// We have to watch out. For the preview we don’t know the font or font size. Skip those there. (Anki | ||
// desktop just doesn’t show the input tag there. Do it with standard values here instead.) | ||
if (!TextUtils.isEmpty(font) && size > 0) { | ||
sb.append("style=\"font-family: '").append(font).append("'; font-size: ") | ||
.append(size).append("px;\" ") | ||
} | ||
sb.append(">\n</center>\n") | ||
} else { | ||
sb.append("<span id=\"typeans\" class=\"typePrompt") | ||
if (useInputTag) { | ||
sb.append(" typeOff") | ||
} | ||
sb.append("\">........</span>") | ||
} | ||
return m.replaceAll(sb.toString()) | ||
} | ||
|
||
/** | ||
* Fill the placeholder for the type comparison: `[[type:(.+?)]]` | ||
* | ||
* Replaces with the HTML for the correct answer, and the comparison to the correct answer if appropriate. | ||
* | ||
* @param answer The card content on the back of the card | ||
* | ||
* @return The formatted answer text with `[[type:(.+?)]]` replaced with HTML | ||
*/ | ||
fun filterAnswer(answer: String): String { | ||
val userAnswer = cleanTypedAnswer(input) | ||
val correctAnswer = cleanCorrectAnswer(correct) | ||
Timber.d("correct answer = %s", correctAnswer) | ||
Timber.d("user answer = %s", userAnswer) | ||
return filterAnswer(answer, userAnswer, correctAnswer) | ||
} | ||
|
||
/** | ||
* Fill the placeholder for the type comparison. Show the correct answer, and the comparison if appropriate. | ||
* | ||
* @param answer The answer text | ||
* @param userAnswer Text typed by the user, or empty. | ||
* @param correctAnswer The correct answer, taken from the note. | ||
* @return The formatted answer text | ||
*/ | ||
fun filterAnswer(answer: String, userAnswer: String, correctAnswer: String): String { | ||
val m: Matcher = PATTERN.matcher(answer) | ||
val diffEngine = DiffEngine() | ||
val sb = StringBuilder() | ||
sb.append(if (doNotUseCodeFormatting) "<div><span id=\"typeans\">" else "<div><code id=\"typeans\">") | ||
|
||
// We have to use Matcher.quoteReplacement because the inputs here might have $ or \. | ||
if (userAnswer.isNotEmpty()) { | ||
// The user did type something. | ||
if (userAnswer == correctAnswer) { | ||
// and it was right. | ||
sb.append(Matcher.quoteReplacement(DiffEngine.wrapGood(correctAnswer))) | ||
sb.append("<span id=\"typecheckmark\">\u2714</span>") // Heavy check mark | ||
} else { | ||
// Answer not correct. | ||
// Only use the complex diff code when needed, that is when we have some typed text that is not | ||
// exactly the same as the correct text. | ||
val diffedStrings = diffEngine.diffedHtmlStrings(correctAnswer, userAnswer) | ||
// We know we get back two strings. | ||
sb.append(Matcher.quoteReplacement(diffedStrings[0])) | ||
sb.append("<br><span id=\"typearrow\">↓</span><br>") | ||
sb.append(Matcher.quoteReplacement(diffedStrings[1])) | ||
} | ||
} else { | ||
if (!useInputTag) { | ||
sb.append(Matcher.quoteReplacement(DiffEngine.wrapMissing(correctAnswer))) | ||
} else { | ||
sb.append(Matcher.quoteReplacement(correctAnswer)) | ||
} | ||
} | ||
sb.append(if (doNotUseCodeFormatting) "</span></div>" else "</code></div>") | ||
return m.replaceAll(sb.toString()) | ||
} | ||
|
||
companion object { | ||
@JvmField | ||
/** Regular expression in card data for a 'type answer' after processing has occurred */ | ||
val PATTERN: Pattern = Pattern.compile("\\[\\[type:(.+?)]]") | ||
|
||
@JvmStatic | ||
fun createInstance(preferences: SharedPreferences): TypeAnswer { | ||
return TypeAnswer( | ||
useInputTag = preferences.getBoolean("useInputTag", false), | ||
doNotUseCodeFormatting = preferences.getBoolean("noCodeFormatting", false), | ||
autoFocus = preferences.getBoolean("autoFocusTypeInAnswer", false) | ||
) | ||
} | ||
|
||
/** Regex pattern used in removing tags from text before diff */ | ||
private val spanPattern = Pattern.compile("</?span[^>]*>") | ||
private val brPattern = Pattern.compile("<br\\s?/?>") | ||
|
||
/** | ||
* Clean up the correct answer text, so it can be used for the comparison with the typed text | ||
* | ||
* @param answer The content of the field the text typed by the user is compared to. | ||
* @return The correct answer text, with actual HTML and media references removed, and HTML entities unescaped. | ||
*/ | ||
@JvmStatic | ||
fun cleanCorrectAnswer(answer: String?): String { | ||
if (answer == null || "" == answer) { | ||
return "" | ||
} | ||
var matcher = spanPattern.matcher(Utils.stripHTML(answer.trim { it <= ' ' })) | ||
var answerText = matcher.replaceAll("") | ||
matcher = brPattern.matcher(answerText) | ||
answerText = matcher.replaceAll("\n") | ||
matcher = Sound.SOUND_PATTERN.matcher(answerText) | ||
answerText = matcher.replaceAll("") | ||
return Utils.nfcNormalized(answerText) | ||
} | ||
|
||
/** | ||
* Clean up the typed answer text, so it can be used for the comparison with the correct answer | ||
* | ||
* @param answer The answer text typed by the user. | ||
* @return The typed answer text, cleaned up. | ||
*/ | ||
@JvmStatic | ||
fun cleanTypedAnswer(answer: String?): String { | ||
return if (answer == null || "" == answer) { | ||
"" | ||
} else Utils.nfcNormalized(answer.trim()) | ||
} | ||
|
||
/** | ||
* Return the correct answer to use for {{type::cloze::NN}} fields. | ||
* | ||
* @param txt The field text with the clozes | ||
* @param idx The index of the cloze to use | ||
* @return If the cloze strings are the same, return a single cloze string, otherwise, return | ||
* a string with a comma-separeted list of strings with the correct index. | ||
*/ | ||
@VisibleForTesting | ||
fun contentForCloze(txt: String, idx: Int): String? { | ||
// In Android, } should be escaped | ||
val re = Pattern.compile("\\{\\{c$idx::(.+?)\\}\\}") | ||
val m = re.matcher(txt) | ||
val matches: MutableList<String?> = ArrayList() | ||
var groupOne: String | ||
while (m.find()) { | ||
groupOne = m.group(1)!! | ||
val colonColonIndex = groupOne.indexOf("::") | ||
if (colonColonIndex > -1) { | ||
// Cut out the hint. | ||
groupOne = groupOne.substring(0, colonColonIndex) | ||
} | ||
matches.add(groupOne) | ||
} | ||
val uniqMatches: Set<String?> = HashSet(matches) // Allow to check whether there are distinct strings | ||
|
||
// Make it consistent with the Desktop version (see issue #8229) | ||
return if (uniqMatches.size == 1) { | ||
matches[0] | ||
} else { | ||
TextUtils.join(", ", matches) | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.