Skip to content

Commit 1dfdb55

Browse files
Haz3-joltdavid-allison
authored andcommitted
libanki: add notetype change methods and tests
From https://github.com/ankitects/anki/blob/630bdd31893d5113b81cd60612cef3ebf146d5b4/pylib/anki/models.py Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com>
1 parent bcf754c commit 1dfdb55

File tree

3 files changed

+159
-26
lines changed

3 files changed

+159
-26
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ import com.ichi2.libanki.Notetypes.Companion.NOT_FOUND_NOTE_TYPE
101101
import com.ichi2.libanki.exception.ConfirmModSchemaException
102102
import com.ichi2.libanki.getStockNotetype
103103
import com.ichi2.libanki.getStockNotetypeKinds
104-
import com.ichi2.libanki.restoreNotetypeToStock
105104
import com.ichi2.libanki.utils.append
106105
import com.ichi2.themes.Themes
107106
import com.ichi2.ui.FixedEditText
@@ -976,7 +975,7 @@ open class CardTemplateEditor :
976975
@NeedsTest("Notetype is restored to stock kind")
977976
private suspend fun restoreNotetypeToStock(kind: StockNotetype.Kind? = null) {
978977
val nid = notetypeId { ntid = tempModel.noteTypeId }
979-
undoableOp { restoreNotetypeToStock(nid, kind) }
978+
undoableOp { notetypes.restoreNotetypeToStock(nid, kind) }
980979
onModelSaved()
981980
showThemedToast(
982981
requireContext(),

AnkiDroid/src/main/java/com/ichi2/libanki/Notetypes.kt

Lines changed: 79 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ package com.ichi2.libanki
3434
import androidx.annotation.CheckResult
3535
import anki.collection.OpChanges
3636
import anki.collection.OpChangesWithId
37+
import anki.notetypes.ChangeNotetypeInfo
38+
import anki.notetypes.ChangeNotetypeRequest
3739
import anki.notetypes.Notetype
3840
import anki.notetypes.NotetypeId
3941
import anki.notetypes.NotetypeNameId
@@ -55,6 +57,7 @@ import com.ichi2.libanki.utils.insert
5557
import com.ichi2.libanki.utils.len
5658
import com.ichi2.libanki.utils.remove
5759
import net.ankiweb.rsdroid.RustCleanup
60+
import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException
5861
import net.ankiweb.rsdroid.exceptions.BackendNotFoundException
5962
import org.intellij.lang.annotations.Language
6063
import org.json.JSONArray
@@ -527,6 +530,82 @@ class Notetypes(
527530
save(notetype)
528531
}
529532

533+
/*
534+
* Changing notetypes of notes
535+
* ***********************************************************
536+
*/
537+
538+
/**
539+
* @return The ID of the single note type which all supplied notes are using; throws otherwise
540+
*
541+
* @throws BackendInvalidInputException notes from different note types were supplied
542+
* @throws BackendInvalidInputException an empty list was supplied
543+
* @throws BackendNotFoundException One of the provided IDs was invalid
544+
*/
545+
@CheckResult
546+
@LibAnkiAlias("get_single_notetype_of_notes")
547+
fun getSingleNotetypeOfNotes(noteIds: List<NoteId>): NoteTypeId = col.backend.getSingleNotetypeOfNotes(noteIds)
548+
549+
@CheckResult
550+
@LibAnkiAlias("change_notetype_info")
551+
fun changeNotetypeInfo(
552+
oldNoteTypeId: NoteTypeId,
553+
newNoteTypeId: NoteTypeId,
554+
): ChangeNotetypeInfo =
555+
this.col.backend.getChangeNotetypeInfo(
556+
oldNotetypeId = oldNoteTypeId,
557+
newNotetypeId = newNoteTypeId,
558+
)
559+
560+
/**
561+
* Assign a new notetype, optionally altering field/template order.
562+
*
563+
* To get defaults, use
564+
*
565+
* ```kotlin
566+
* val info = col.models.change_notetype_info(...)
567+
* val input = info.input
568+
* input.note_ids.extend([...])
569+
* ```
570+
*
571+
* The `newFields` and `newTemplates` lists are relative to the new notetype's
572+
* field/template count.
573+
*
574+
* Each value represents the index in the previous notetype.
575+
* -1 indicates the original value will be discarded.
576+
*/
577+
@LibAnkiAlias("change_notetype_of_notes")
578+
fun changeNotetypeOfNotes(input: ChangeNotetypeRequest): OpChanges {
579+
val opBytes = this.col.backend.changeNotetypeRaw(input.toByteArray())
580+
return OpChanges.parseFrom(opBytes)
581+
}
582+
583+
/**
584+
* Restores a notetype to its original stock kind.
585+
*
586+
* @param notetypeId id of the changed notetype
587+
* @param forceKind optional stock kind to be forced instead of the original kind.
588+
* Older notetypes did not store their original stock kind, so we allow the UI
589+
* to pass in an override to use when missing, or for tests.
590+
*/
591+
@CheckResult
592+
@LibAnkiAlias("restore_notetype_to_stock")
593+
fun restoreNotetypeToStock(
594+
notetypeId: NotetypeId,
595+
forceKind: StockNotetype.Kind? = null,
596+
): OpChanges {
597+
val msg =
598+
restoreNotetypeToStockRequest {
599+
this.notetypeId = notetypeId
600+
forceKind?.let { this.forceKind = forceKind }
601+
}
602+
return col.backend.restoreNotetypeToStock(msg).also {
603+
// not in libAnki:
604+
// Remove the specific notetype from cache to ensure consistency after restoration
605+
removeFromCache(notetypeId.ntid)
606+
}
607+
}
608+
530609
/*
531610
# Model changing
532611
##########################################################################
@@ -741,29 +820,5 @@ fun Collection.addNotetypeLegacy(json: ByteString): OpChangesWithId {
741820
fun Collection.getStockNotetype(kind: StockNotetype.Kind): NotetypeJson =
742821
NotetypeJson(fromJsonBytes(backend.getStockNotetypeLegacy(kind = kind)))
743822

744-
/**
745-
* Restores a notetype to its original stock kind.
746-
*
747-
* @param notetypeId id of the changed notetype
748-
* @param forceKind optional stock kind to be forced instead of the original kind.
749-
* Older notetypes did not store their original stock kind, so we allow the UI
750-
* to pass in an override to use when missing, or for tests.
751-
*/
752-
@CheckResult
753-
fun Collection.restoreNotetypeToStock(
754-
notetypeId: NotetypeId,
755-
forceKind: StockNotetype.Kind? = null,
756-
): OpChanges {
757-
val msg =
758-
restoreNotetypeToStockRequest {
759-
this.notetypeId = notetypeId
760-
forceKind?.let { this.forceKind = forceKind }
761-
}
762-
val result = backend.restoreNotetypeToStock(msg)
763-
// Remove the specific notetype from cache to ensure consistency after restoration
764-
notetypes.removeFromCache(notetypeId.ntid)
765-
return result
766-
}
767-
768823
@NotInLibAnki
769824
fun getStockNotetypeKinds(): List<StockNotetype.Kind> = StockNotetype.Kind.entries.filter { it != StockNotetype.Kind.UNRECOGNIZED }
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright (c) 2025 David Allison <davidallisongithub@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.libanki
18+
19+
import androidx.test.espresso.matcher.ViewMatchers.assertThat
20+
import androidx.test.ext.junit.runners.AndroidJUnit4
21+
import com.ichi2.testutils.JvmTest
22+
import com.ichi2.testutils.common.assertThrows
23+
import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException
24+
import net.ankiweb.rsdroid.exceptions.BackendNotFoundException
25+
import org.hamcrest.Matchers.equalTo
26+
import org.junit.Test
27+
import org.junit.runner.RunWith
28+
29+
@RunWith(AndroidJUnit4::class)
30+
class NotetypesTest : JvmTest() {
31+
@Test
32+
fun `getSingleNotetypeOfNotes - multiple`() {
33+
val notes = addNotes(2)
34+
val result = col.notetypes.getSingleNotetypeOfNotes(notes.map { it.id })
35+
assertThat(result, equalTo(notes.first().notetype.id))
36+
}
37+
38+
@Test
39+
fun `getSingleNotetypeOfNotes - valid`() {
40+
val note = addNotes(1).single()
41+
val result = col.notetypes.getSingleNotetypeOfNotes(listOf(note.id))
42+
assertThat(result, equalTo(note.notetype.id))
43+
}
44+
45+
@Test
46+
fun `getSingleNotetypeOfNotes - no input`() {
47+
val result = assertThrows<BackendInvalidInputException> { col.notetypes.getSingleNotetypeOfNotes(emptyList()) }
48+
assertThat(result.message, equalTo("no note id provided"))
49+
}
50+
51+
@Test
52+
fun `getSingleNotetypeOfNotes - invalid input`() {
53+
val noteIds = listOf<Long>(1)
54+
val result = assertThrows<BackendNotFoundException> { col.notetypes.getSingleNotetypeOfNotes(noteIds) }
55+
assertThat(
56+
result.message,
57+
equalTo("Your database appears to be in an inconsistent state. Please use the Check Database action. No such note: '1'"),
58+
)
59+
}
60+
61+
@Test
62+
fun `getSingleNotetypeOfNotes - one invalid`() {
63+
val noteIds = listOf(1, addNotes(1).single().id)
64+
val result = assertThrows<BackendNotFoundException> { col.notetypes.getSingleNotetypeOfNotes(noteIds) }
65+
assertThat(
66+
result.message,
67+
equalTo("Your database appears to be in an inconsistent state. Please use the Check Database action. No such note: '1'"),
68+
)
69+
}
70+
71+
@Test
72+
fun `getSingleNotetypeOfNotes - mixed`() {
73+
val basicNote = addNotes(1).single()
74+
val clozeNote = addClozeNote("{{c1::aa}}")
75+
val result =
76+
assertThrows<BackendInvalidInputException> { col.notetypes.getSingleNotetypeOfNotes(listOf(basicNote.id, clozeNote.id)) }
77+
assertThat(result.message, equalTo("Please select notes from only one note type."))
78+
}
79+
}

0 commit comments

Comments
 (0)