Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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")
}

/**
Expand All @@ -289,25 +291,41 @@ class InstantEditorViewModel :
fun getWordsFromFieldText(): List<String> {
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<String>()
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<String> {
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<String>): List<String> {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<IntRange> {
val ranges = mutableListOf<IntRange>()
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()
Original file line number Diff line number Diff line change
Expand Up @@ -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("🙂", "🙂"),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading