diff --git a/AnkiDroid/src/main/assets/scripts/js-api.js b/AnkiDroid/src/main/assets/scripts/js-api.js index a33010525f22..b569c060069e 100644 --- a/AnkiDroid/src/main/assets/scripts/js-api.js +++ b/AnkiDroid/src/main/assets/scripts/js-api.js @@ -72,6 +72,8 @@ const jsApiList = { ankiSttStart: "sttStart", ankiSttStop: "sttStop", ankiAddTagToNote: "addTagToNote", + ankiSetNoteTags: "setNoteTags", + ankiGetNoteTags: "getNoteTags", }; class AnkiDroidJS { @@ -119,12 +121,40 @@ class AnkiDroidJS { Object.keys(jsApiList).forEach(method => { if (method === "ankiAddTagToNote") { AnkiDroidJS.prototype[method] = async function (noteId, tag) { + console.warn("ankiAddTagToNote is deprecated. Use ankiSetNoteTags instead."); const endpoint = jsApiList[method]; const data = JSON.stringify({ noteId, tag }); return await this.handleRequest(endpoint, data); }; return; } + if (method === "ankiSetNoteTags") { + AnkiDroidJS.prototype[method] = async function (noteId, tags) { + let hasSpaces = false; + for (let i = 0; i < tags.length; i++) { + tags[i] = tags[i].trim(); + if (tags[i].includes(" ") || tags[i].includes("\u3000")) { + tags[i] = tags[i].replace(" ", "_").replace("\u3000", "_"); + hasSpaces = true; + } + } + if (hasSpaces) { + console.warn("Tags with spaces will have them converted to underscores."); + } + const endpoint = jsApiList[method]; + const data = JSON.stringify({ noteId, tags }); + return await this.handleRequest(endpoint, data); + }; + return; + } + if (method === "ankiGetNoteTags") { + AnkiDroidJS.prototype[method] = async function (noteId) { + const endpoint = jsApiList[method]; + const data = JSON.stringify({ noteId }); + return await this.handleRequest(endpoint, data); + }; + return; + } if (method === "ankiTtsSpeak") { AnkiDroidJS.prototype[method] = async function (text, queueMode = 0) { const endpoint = jsApiList[method]; diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt index f4ec2dc7d032..8a8fe531da01 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt @@ -45,14 +45,17 @@ import com.ichi2.annotations.NeedsTest import com.ichi2.libanki.Card import com.ichi2.libanki.Collection import com.ichi2.libanki.Decks +import com.ichi2.libanki.Note import com.ichi2.libanki.SortOrder import com.ichi2.utils.NetworkUtils +import com.ichi2.utils.stringIterable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json +import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import timber.log.Timber @@ -203,6 +206,7 @@ open class AnkiDroidJsAPI( * @param returnDefaultValues `true` if default values should be returned (if non-[Reviewer]) * @return */ + @NeedsTest("setNoteTags: Test that tags are set for all edge cases") open suspend fun handleJsApiRequest( methodName: String, bytes: ByteArray, @@ -373,6 +377,41 @@ open class AnkiDroidJsAPI( getColUnsafe.updateNote(note) convertToByteArray(apiContract, true) } + + "setNoteTags" -> { + val jsonObject = JSONObject(apiParams) + val noteId = jsonObject.getLong("noteId") + val tags = jsonObject.getJSONArray("tags") + withCol { + fun Note.setTagsFromList(tagList: List) { + val sanitizedTags = tagList.map { it.trim() } + val spaces = "\\s|\u3000".toRegex() + if (sanitizedTags.any { it.contains(spaces) }) { + throw IllegalArgumentException("Tags cannot contain spaces") + } + val tagsAsString = this@withCol.tags.join(sanitizedTags) + setTagsFromStr(this@withCol, tagsAsString) + } + + val note = + getNote(noteId).apply { + setTagsFromList(tags.stringIterable().toList()) + } + updateNote(note) + } + convertToByteArray(apiContract, true) + } + + "getNoteTags" -> { + val jsonObject = JSONObject(apiParams) + val noteId = jsonObject.getLong("noteId") + val noteTags = + withCol { + getNote(noteId).tags + } + convertToByteArray(apiContract, JSONArray(noteTags).toString()) + } + "sttSetLanguage" -> convertToByteArray(apiContract, speechRecognizer.setLanguage(apiParams)) "sttStart" -> { val callback = diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt index 4221277b3046..546970da969b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt @@ -27,6 +27,7 @@ import com.ichi2.utils.BASIC_MODEL_NAME import net.ankiweb.rsdroid.withoutUnicodeIsolation import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat +import org.json.JSONArray import org.json.JSONObject import org.junit.Ignore import org.junit.Test @@ -417,6 +418,31 @@ class AnkiDroidJsAPITest : RobolectricTest() { assertEquals(CardType.New, cardAfterReset.type, "Card type after reset") } + @Test + fun ankiGetNoteTagsTest() = + runTest { + val n = + addBasicNote("Front", "Back").update { + tags = mutableListOf("tag1", "tag2", "tag3") + } + + val reviewer: Reviewer = startReviewer() + waitForAsyncTasksToComplete() + + val jsapi = reviewer.jsApi + + // test get tags for note + val expectedTags = n.tags + val response = getDataFromRequest("getNoteTags", jsapi, jsonObjectOf("noteId" to n.id)) + val jsonResponse = JSONObject(response) + val actualTags = JSONArray(jsonResponse.getString("value")) + + assertEquals(expectedTags.size, actualTags.length()) + for (i in 0 until actualTags.length()) { + assertEquals(expectedTags[i], actualTags.getString(i)) + } + } + companion object { fun jsApiContract(data: String = ""): ByteArray = JSONObject() @@ -450,5 +476,21 @@ class AnkiDroidJsAPITest : RobolectricTest() { jsAPI .handleJsApiRequest(methodName, jsApiContract(apiData), false) .decodeToString() + + suspend fun getDataFromRequest( + methodName: String, + jsAPI: AnkiDroidJsAPI, + apiData: JSONObject, + ): String = + jsAPI + .handleJsApiRequest(methodName, jsApiContract(apiData.toString()), false) + .decodeToString() } } + +private fun jsonObjectOf(vararg pairs: Pair): JSONObject = + JSONObject().apply { + for ((key, value) in pairs) { + put(key, value) + } + } diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/TestClass.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/TestClass.kt index 011237a9a934..f794d1342745 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/TestClass.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/TestClass.kt @@ -241,6 +241,13 @@ interface TestClass { col.decks.save(deckConfig) } + /** Helper method to update a note */ + fun Note.update(block: Note.() -> Unit): Note { + block(this) + col.updateNote(this) + return this + } + /** Helper method to all cards of a note */ fun Note.updateCards(update: Card.() -> Unit): Note { cards().forEach { it.update(update) }