Skip to content
Merged
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 @@ -21,6 +21,7 @@ package com.ichi2.anki.tests
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.database.Cursor
import android.database.CursorWindow
import android.net.Uri
import anki.notetypes.StockNotetype
Expand Down Expand Up @@ -49,12 +50,15 @@ import com.ichi2.libanki.getStockNotetype
import com.ichi2.libanki.sched.Scheduler
import com.ichi2.testutils.common.assertThrows
import com.ichi2.utils.emptyStringArray
import kotlinx.serialization.json.Json
import net.ankiweb.rsdroid.exceptions.BackendNotFoundException
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.greaterThan
import org.hamcrest.Matchers.greaterThanOrEqualTo
import org.hamcrest.Matchers.hasItem
import org.json.JSONObject.NULL
import org.junit.After
import org.junit.Assert.assertEquals
Expand All @@ -67,6 +71,7 @@ import org.junit.Rule
import org.junit.Test
import timber.log.Timber
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.junit.JUnitAsserter.assertNotNull

/**
Expand Down Expand Up @@ -1345,6 +1350,48 @@ class ContentProviderTest : InstrumentedTest() {
} ?: fail("query returned null")
}

@Test
fun testMediaFilesAddedCorrectlyInReviewInfo() {
val imageFileName = "img.jpg"
val audioFileName = "test.mp3"
addNoteUsingBasicNoteType("""Hello <img src="$imageFileName"> [sound:$audioFileName]""")
.firstCard(col)
.update {
queue = QueueType.New
due = col.sched.today
}

queryReviewInfo { cursor ->
val media =
cursor
.getString(cursor.getColumnIndex(FlashCardsContract.ReviewInfo.MEDIA_FILES))
.let { Json.decodeFromString<List<String>>(it) }

assertThat(
"media files returned",
media,
allOf(
hasItem(imageFileName),
hasItem(audioFileName),
),
)
}
}

private fun queryReviewInfo(block: (Cursor) -> Unit) {
contentResolver
.query(
FlashCardsContract.ReviewInfo.CONTENT_URI,
null,
null,
null,
null,
)?.use { cursor ->
assertTrue("has rows") { cursor.moveToFirst() }
block(cursor)
}
}

@Test
fun emptyCards_noCardsInCollection() {
assertThat("deleted when collection empty", emptyCards(notetypes.cloze), equalTo(0))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ abstract class InstrumentedTest {
}
}

@DuplicatedCode("This should be refactored into a shared library later")
fun Card.update(block: Card.() -> Unit): Card {
block(this)
col.updateCard(this)
return this
}

@DuplicatedCode("This is copied from RobolectricTest. This will be refactored into a shared library later")
protected fun Card.moveToReviewQueue() {
this.queue = QueueType.Rev
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1137,7 +1137,7 @@ class CardContentProvider : ContentProvider() {
FlashCardsContract.ReviewInfo.NEXT_REVIEW_TIMES -> rb.add(nextReviewTimesJson.toString())
FlashCardsContract.ReviewInfo.MEDIA_FILES ->
rb.add(
JSONArray(col.media.filesInStr(currentCard.question(col) + currentCard.answer(col))),
JSONArray(col.media.filesInStr(currentCard.renderOutput(col))),
)
else -> throw UnsupportedOperationException("Queue \"$column\" is unknown")
}
Expand Down
69 changes: 59 additions & 10 deletions AnkiDroid/src/main/java/com/ichi2/libanki/Media.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ package com.ichi2.libanki
import androidx.annotation.WorkerThread
import anki.media.CheckMediaResponse
import com.google.protobuf.kotlin.toByteString
import com.ichi2.libanki.Media.Companion.htmlMediaRegexps
import com.ichi2.libanki.TemplateManager.TemplateRenderContext.TemplateRenderOutput
import com.ichi2.libanki.exception.EmptyMediaException
import com.ichi2.libanki.utils.LibAnkiAlias
import com.ichi2.libanki.utils.NotInLibAnki
import timber.log.Timber
import java.io.File

Expand Down Expand Up @@ -64,18 +67,36 @@ open class Media(
/**
* Extract media filenames from an HTML string.
*
* @param string The string to scan for media filenames ([sound:...] or <img...>).
* @return A list containing all the sound and image filenames found in the input string.
* @param currentCard The card to scan for media filenames ([sound:...] or <img...>).
* @return A distinct, unordered list containing all the sound and image filenames found in the card.
*/
fun filesInStr(string: String): List<String> =
col.backend
.extractAvTags(string, true)
.avTagsList
.filter {
it.hasSoundOrVideo()
}.map {
it.soundOrVideo
@LibAnkiAlias("files_in_str")
fun filesInStr(
renderOutput: TemplateRenderOutput,
includeRemote: Boolean = false,
): List<String> {
val files = mutableListOf<String>()
val processedText = LaTeX.mungeQA(renderOutput.questionText + renderOutput.answerText, col, true)

for (pattern in htmlMediaRegexps) {
val matches = pattern.findAll(processedText)
for (match in matches) {
val fname = pattern.extractFilename(match) ?: continue
val isLocal = !Regex("(?i)https?|ftp://").containsMatchIn(fname)
if (isLocal || includeRemote) {
files.add(fname)
}
}
}

// not in libAnki: the rendered output no longer contains [sound:] tags
files.addAll(
(renderOutput.questionAvTags + renderOutput.answerAvTags)
.filterIsInstance<SoundOrVideoTag>()
.map { it.filename },
)
return files.distinct()
}

fun findUnusedMediaFiles(): List<File> = check().unusedList.map { File(dir, it) }

Expand Down Expand Up @@ -123,4 +144,32 @@ open class Media(

@LibAnkiAlias("restore_trash")
fun restoreTrash() = col.backend.restoreTrash()

companion object {
/**
* Given a [media regex][htmlMediaRegexps] and a match, return the captured media filename
*/
@NotInLibAnki
private fun Regex.extractFilename(match: MatchResult): String? {
val index =
when (htmlMediaRegexps.indexOf(this)) {
0, 2 -> 3
1, 3 -> 2
else -> throw IllegalStateException(pattern)
}
return match.groups[index]?.value
}

val htmlMediaRegexps =
listOf(
// src element quoted case (img/audio)
Regex("(?i)(<(?:img|audio)\\b[^>]* src=(['\"])([^>]+?)\\2[^>]*>)"), // Group 3 = fname
// unquoted src (img/audio)
Regex("(?i)(<(?:img|audio)\\b[^>]* src=(?!['\"])([^ >]+)[^>]*?>)"), // Group 2 = fname
// quoted data attribute (object)
Regex("(?i)(<object\\b[^>]* data=(['\"])([^>]+?)\\2[^>]*>)"), // Group 3 = fname
// unquoted data attribute (object)
Regex("(?i)(<object\\b[^>]* data=(?!['\"])([^ >]+)[^>]*?>)"), // Group 2 = fname
)
}
}