diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt index 598bfb8c5423..1e6b02a070e9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt @@ -235,7 +235,8 @@ class InstantEditorViewModel : * The method also handles incrementing the cloze number based on the current cloze mode. * * @param text the text to be converted into a cloze deletion - * @return the cloze-deleted version of the input text or clean text if already cloze + * @return the cloze-deleted version of the input text, clean text if already cloze, + * or the unchanged text if it contains no word to cloze (e.g. an emoji) */ fun buildClozeText(text: String): String { val cloze = processClozeUndo(text) @@ -246,9 +247,11 @@ class InstantEditorViewModel : Timber.d("Text doesn't have cloze, selecting and adding cloze") - val matcher = clozeBuilderPattern.findAll(text).firstOrNull() - - val clozeText: String? + val matcher = clozeBuilderPattern.find(text) + if (matcher == null) { + Timber.d("Text has no word to cloze") + return text + } val clozeNumber = currentClozeNumber.value if (currentClozeMode.value == InstantNoteEditorActivity.ClozeMode.INCREMENT) { @@ -257,13 +260,12 @@ class InstantEditorViewModel : intClozeList.add(clozeNumber) // Extract the first, second, and third regex groups from the matcher - val punctuationAtStart: String? = matcher?.groups?.get(1)?.value - val capturedWord: String? = matcher?.groups?.get(2)?.value - val punctuationAtEnd: String? = matcher?.groups?.get(4)?.value - - clozeText = "$punctuationAtStart{{c$clozeNumber::$capturedWord}}$punctuationAtEnd" + val punctuationAtStart = matcher.groupValues[1] + val capturedWord = matcher.groupValues[2] + val punctuationAtEnd = matcher.groupValues[4] - return clozeText + // keep any text outside the match, e.g. the '°C' of '20°C' + return text.replaceRange(matcher.range, "$punctuationAtStart{{c$clozeNumber::$capturedWord}}$punctuationAtEnd") } /** @@ -289,25 +291,41 @@ class InstantEditorViewModel : fun getWordsFromFieldText(): List { val sentence = actualClozeFieldText.value ?: "" + // Only split on spaces outside of clozes, as a cloze may contain spaces. + // Text touching a cloze stays in the same word, e.g. '{{c1::20}}°C' + val clozeRanges = outerClozeRanges(sentence) + val words = mutableListOf() - var lastIndex = 0 - - clozePattern.findAll(sentence).forEach { matchResult -> - val cloze = matchResult.value - // Add any words between the last match and this match - val inBetween = sentence.substring(lastIndex, matchResult.range.first) - words.addAll(inBetween.trim().split(spaceRegex).filter { it.isNotEmpty() }) - words.add(cloze) - lastIndex = matchResult.range.last + 1 - } + var wordStart = 0 + + spaceRegex + .findAll(sentence) + .filter { space -> clozeRanges.none { space.range.first in it } } + .forEach { space -> + words.addAll(splitBetweenClozes(sentence.substring(wordStart, space.range.first))) + wordStart = space.range.last + 1 + } + words.addAll(splitBetweenClozes(sentence.substring(wordStart))) - // Add any remaining words after the last cloze - if (lastIndex < sentence.length) { - val remaining = sentence.substring(lastIndex).trim() - words.addAll(remaining.split(spaceRegex).filter { it.isNotEmpty() }) - } + return combineWordsWithPunctuation(words.filter { it.isNotEmpty() }) + } - return combineWordsWithPunctuation(words) + /** + * Splits [word] before each cloze after the first, so a word has at most one cloze + * and can be renumbered on its own: '{{c1::a}}-{{c2::b}}' -> ['{{c1::a}}-', '{{c2::b}}'] + * + * A nested cloze stays in its outer cloze: '{{c1::H2{{c2::O}}}}' is one word + */ + private fun splitBetweenClozes(word: String): List { + val outerClozes = outerClozeRanges(word) + // clozePattern isn't used: its trailing punctuation would include the '{{' of the next cloze + val clozeStarts = + clozeStartPattern + .findAll(word) + .map { it.range.first } + .filter { start -> outerClozes.none { start in it && start != it.first } } + val wordStarts = listOf(0) + clozeStarts.drop(1) + word.length + return wordStarts.zipWithNext { start, end -> word.substring(start, end) } } private fun combineWordsWithPunctuation(words: List): List { @@ -383,7 +401,7 @@ class InstantEditorViewModel : val capturedWord: String = matchResult.groups[3]?.value ?: "" val punctuationAtEnd: String = matchResult.groups[4]?.value ?: "" - return punctuationAtStart + capturedWord + punctuationAtEnd + return text.replaceRange(matchResult.range, punctuationAtStart + capturedWord + punctuationAtEnd) } fun setEditorMode(mode: InstantNoteEditorActivity.EditMode) { @@ -464,5 +482,36 @@ private val punctuationPattern = Regex("""\p{Punct}+$""") /** Used when splitting words **/ private val spaceRegex = Regex("\\s+") +/** The start of a cloze: {{c`number`:: **/ +private val clozeStartPattern = Regex("""\{\{c\d+::""") + +/** + * The ranges of the clozes in [text] which aren't inside another cloze, from '{{' to the + * matching '}}', so '{{c1::H2{{c2::O}}}}' is one range. A cloze without its '}}' has no range. + */ +private fun outerClozeRanges(text: String): List { + val ranges = mutableListOf() + var depth = 0 + var outerStart = 0 + var i = 0 + while (i < text.length) { + val clozeStart = clozeStartPattern.matchAt(text, i) + when { + clozeStart != null -> { + if (depth == 0) outerStart = i + depth++ + i = clozeStart.range.last + 1 + } + depth > 0 && text.startsWith("}}", i) -> { + depth-- + if (depth == 0) ranges.add(outerStart..i + 1) + i += 2 + } + else -> i++ + } + } + return ranges +} + /** Used to build cloze text here word is not null **/ private val clozeBuilderPattern = "(\\p{Punct}*)((?:\\w|\\p{Pd}|\\p{Pc}|'|(\\(\\w+\\)))+)(\\p{Punct}*)".toRegex() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelParameterizedTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelParameterizedTest.kt index 840255565778..7bf5444e27e0 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelParameterizedTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelParameterizedTest.kt @@ -43,6 +43,9 @@ class InstantEditorViewModelParameterizedTest : RobolectricTest() { arrayOf("{hello}", "{{{c1::hello}}}"), arrayOf("test,", "{{c1::test}},"), arrayOf("{{c1::test}},", "test,"), + arrayOf("20°C", "{{c1::20}}°C"), + arrayOf("€100", "€{{c1::100}}"), + arrayOf("🙂", "🙂"), ) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt index 4e400724b407..23173e1cbee0 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt @@ -11,6 +11,7 @@ import com.ichi2.anki.instantnoteeditor.InstantEditorProgress import com.ichi2.anki.instantnoteeditor.InstantEditorViewModel import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity import com.ichi2.anki.instantnoteeditor.SaveNoteResult +import com.ichi2.anki.instantnoteeditor.clozePattern import com.ichi2.anki.libanki.testutils.AnkiTest import com.ichi2.anki.progress.ViewModelProgress import org.junit.Assert.assertEquals @@ -148,6 +149,14 @@ class InstantEditorViewModelTest : RobolectricTest() { assertEquals("Word", result) } + @Test + fun `buildClozeText does not use a cloze number for text without a word`() = + runViewModelTest { + assertEquals("🙂", buildClozeText("🙂")) + + assertEquals("{{c1::test}}", buildClozeText("test")) + } + @Test fun testExtractWordsIncludingClozes() = runViewModelTest { @@ -168,6 +177,80 @@ class InstantEditorViewModelTest : RobolectricTest() { assertEquals(expectedWords, extractedWords) } + @Test + fun `words keep text touching a cloze`() = + runViewModelTest { + setClozeFieldText("at {{c1::20}}°C or €{{c2::100}}") + val expectedWords = listOf("at", "{{c1::20}}°C", "or", "€{{c2::100}}") + assertEquals(expectedWords, getWordsFromFieldText()) + } + + @Test + fun `touching clozes are separate words`() = + runViewModelTest { + setClozeFieldText("{{c1::a}}-{{c2::b}}{{c3::c}}") + val expectedWords = listOf("{{c1::a}}-", "{{c2::b}}", "{{c3::c}}") + assertEquals(expectedWords, getWordsFromFieldText()) + } + + @Test + fun `a later cloze containing a space stays one word`() = + runViewModelTest { + setClozeFieldText("{{c1::born in}}-{{c2::New York}}") + val expectedWords = listOf("{{c1::born in}}-", "{{c2::New York}}") + assertEquals(expectedWords, getWordsFromFieldText()) + } + + @Test + fun `editing another chip preserves a nested chemical formula`() = + runViewModelTest { + setClozeFieldText("{{c1::H2{{c2::O}}}} water") + val words = getWordsFromFieldText().toMutableList() + val waterIndex = words.indexOf("water") + words[waterIndex] = buildClozeText(words[waterIndex]) + setClozeFieldText(words.joinToString(" ")) + assertEquals("{{c1::H2{{c2::O}}}} " + words[waterIndex], actualClozeFieldText.value) + } + + @Test + fun `nested cloze does not split its outer markup into incomplete chips`() = + runViewModelTest { + setClozeFieldText("{{c1::H2{{c2::O}}}} water") + assertEquals(listOf("{{c1::H2{{c2::O}}}}", "water"), getWordsFromFieldText()) + } + + @Test + fun `removing a cloze after re-reading the words keeps the text`() = + runViewModelTest { + setClozeFieldText("at 20°C") + val words = getWordsFromFieldText().toMutableList() + toggleClozeDeletion(words, 1) + setClozeFieldText(words.joinToString(" ")) + + // the words are read again, e.g. when switching back from the edit mode + val rereadWords = getWordsFromFieldText().toMutableList() + toggleClozeDeletion(rereadWords, 1) + + assertEquals("at 20°C", rereadWords.joinToString(" ")) + } + + @Test + fun `changing first chip number preserves second cloze number`() = + runViewModelTest { + setClozeFieldText("{{c1::Paris}}ỲÜí{{c2::France}}") + val words = getWordsFromFieldText().toMutableList() + + // EditorChips performs this update after a long press on the first chip. + words[0] = updateClozeNumber(words[0], 3) + setClozeFieldText(words.joinToString(" ")) + + assertEquals( + "renumbering the first chip must preserve the other card", + listOf(3, 2), + clozePattern.findAll(actualClozeFieldText.value!!).map { it.groupValues[2].toInt() }.toList(), + ) + } + @Test fun testGetCleanClozeWords() = runViewModelTest {