Recently Written · git

subread-anki

One tap makes an Anki card on Android: word, definition, sentence, screenshot, audio. Works with any app, with SubRead Dictionary and SubRead Overlay.

git clone https://github.com/equwal/subread-anki

Log | Files | Refs


app/src/main/kotlin/space/subread/anki/AnkiClient.kt (11106 bytes)

1 package space.subread.anki
2 
3 import android.content.ContentValues
4 import android.content.Context
5 import android.content.Intent
6 import android.content.pm.PackageManager
7 import android.net.Uri
8 import android.os.Build
9 import space.subread.anki.core.Fields
10 import space.subread.anki.core.NoteType
11 import space.subread.anki.core.Source
12 import java.io.File
13 
14 /**
15  * AnkiDroid, through its content provider: the deck, the note type, the media files and the
16  * notes. The authority, the paths and the columns are the FlashCardsContract of AnkiDroid. The
17  * API library of AnkiDroid is not used: it comes from JitPack alone, and F-Droid builds from
18  * Maven Central and source alone.
19  */
20 class AnkiClient(private val context: Context) {
21 
22     /** A note of the collection: its id, its fields in order, and its tags. */
23     class Note(val id: Long, val fields: Array<String>, val tags: Set<String>)
24 
25     private val resolver get() = context.contentResolver
26     private val store = Store(context)
27 
28     /** The decks of the collection, by id. Empty when AnkiDroid does not answer. */
29     fun decks(): Map<Long, String> = runCatching {
30         resolver.query(DECKS, arrayOf("deck_id", "deck_name"), null, null, null)?.use { c ->
31             buildMap {
32                 while (c.moveToNext()) put(c.getLong(0), c.getString(1))
33             }
34         }
35     }.getOrNull().orEmpty()
36 
37     /** The note types of the collection, by id. */
38     fun models(): Map<Long, String> = runCatching {
39         resolver.query(MODELS, arrayOf("_id", "name"), null, null, null)?.use { c ->
40             buildMap {
41                 while (c.moveToNext()) put(c.getLong(0), c.getString(1))
42             }
43         }
44     }.getOrNull().orEmpty()
45 
46     /** The fields of a note type, in order. Empty when the note type is gone. */
47     fun fields(modelId: Long): List<String> = runCatching {
48         resolver.query(Uri.withAppendedPath(MODELS, modelId.toString()), arrayOf("field_names"), null, null, null)?.use { c ->
49             if (c.moveToFirst()) c.getString(0).split(SEPARATOR) else null
50         }
51     }.getOrNull().orEmpty()
52 
53     /** Makes a deck with this name. Returns its id, or null when AnkiDroid refused. */
54     fun addDeck(name: String): Long? =
55         resolver.insert(DECKS, ContentValues().apply { put("deck_name", name) })?.lastPathSegment?.toLongOrNull()
56 
57     /** The deck the cards go to. The chosen one, else "SubRead", made when it is not there. */
58     fun ensureDeck(): Long {
59         val decks = decks()
60         val chosen = store.deckId
61         if (chosen != Store.NONE && decks.containsKey(chosen)) return chosen
62         val id = decks.entries.firstOrNull { it.value == DECK_NAME }?.key
63             ?: addDeck(DECK_NAME)
64             ?: throw IllegalStateException("AnkiDroid did not make the deck")
65         store.deckId = id
66         return id
67     }
68 
69     /** The note type of the cards. The chosen one, else the app's own, made when it is not there. */
70     fun ensureModel(deckId: Long): Long {
71         val models = models()
72         val chosen = store.modelId
73         if (chosen != Store.NONE && models.containsKey(chosen)) return chosen
74         val id = models.entries.firstOrNull { it.value == NoteType.NAME }?.key
75             ?: addModel(deckId)
76             ?: throw IllegalStateException("AnkiDroid did not make the note type")
77         store.modelId = id
78         store.mapping = NoteType.MAPPING
79         return id
80     }
81 
82     /**
83      * Makes the note type of the app: first the note type with its fields, then the template
84      * of each card. Returns its id, or null when AnkiDroid refused.
85      */
86     private fun addModel(deckId: Long): Long? {
87         val values = ContentValues().apply {
88             put("name", NoteType.NAME)
89             put("field_names", NoteType.FIELDS.joinToString(SEPARATOR))
90             put("num_cards", NoteType.CARD_NAMES.size)
91             put("css", NoteType.CSS)
92             put("deck_id", deckId)
93             put("sort_field_index", 0)
94         }
95         val model = resolver.insert(MODELS, values) ?: return null
96         val templates = Uri.withAppendedPath(model, "templates")
97         NoteType.CARD_NAMES.forEachIndexed { i, card ->
98             val template = ContentValues().apply {
99                 put("card_template_name", card)
100                 put("question_format", NoteType.QUESTION_FORMATS[i])
101                 put("answer_format", NoteType.ANSWER_FORMATS[i])
102             }
103             resolver.update(Uri.withAppendedPath(templates, i.toString()), template, null, null)
104         }
105         return model.lastPathSegment?.toLongOrNull()
106     }
107 
108     /** The note type of the cards when it exists already, or null. Makes nothing: for a look before the first card. */
109     fun existingModel(): Long? {
110         val models = models()
111         val chosen = store.modelId
112         if (chosen != Store.NONE && models.containsKey(chosen)) return chosen
113         return models.entries.firstOrNull { it.value == NoteType.NAME }?.key
114     }
115 
116     /** The name of the deck that the cards go to. */
117     fun deckName(): String = decks()[store.deckId] ?: DECK_NAME
118 
119     /**
120      * True when a note of the note type has [expression] in its first field, and that field
121      * holds the word. False when the note type does not exist yet.
122      */
123     fun inAnki(expression: String): Boolean {
124         val modelId = existingModel() ?: return false
125         val fields = fields(modelId)
126         if (fields.isEmpty() || mapping(modelId, fields)[fields.first()] != Source.EXPRESSION) return false
127         return isDuplicate(modelId, expression)
128     }
129 
130     /** What fills each field of [modelId]: the mapping the user set, else a guess from the names. */
131     fun mapping(modelId: Long, fields: List<String>): Map<String, Source> {
132         val set = store.mapping
133         return if (set.isNotEmpty() && set.keys.containsAll(fields)) set else Fields.guess(fields)
134     }
135 
136     /**
137      * Copies a file into the media folder of AnkiDroid and returns its name there. [kind] is
138      * `audio` or `image`; AnkiDroid takes the kind from the file. AnkiDroid reads the file
139      * through [MediaProvider], so the read is granted to it for the copy.
140      */
141     @Suppress("UNUSED_PARAMETER")
142     fun addMedia(file: File, kind: String): String? {
143         val uri = Media.uriFor(context, file)
144         val anki = packageName(context) ?: return null
145         context.grantUriPermission(anki, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
146         try {
147             val values = ContentValues().apply {
148                 put("file_uri", uri.toString())
149                 put("preferred_name", file.nameWithoutExtension.replace(' ', '_'))
150             }
151             val stored = runCatching { resolver.insert(MEDIA, values) }.getOrNull() ?: return null
152             // The path of the answer is the name of the file in the media folder, after a slash.
153             return stored.path?.trimStart('/')?.takeIf { it.isNotEmpty() }
154         } finally {
155             context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
156         }
157     }
158 
159     /** True when a note of [modelId] has [key] as its first field. */
160     fun isDuplicate(modelId: Long, key: String): Boolean {
161         if (key.isBlank()) return false
162         return runCatching {
163             val model = models()[modelId] ?: return@runCatching false
164             val first = fields(modelId).firstOrNull() ?: return@runCatching false
165             // The selection of the notes path is an Anki search. The quotes make one term of
166             // "field:text", and the escapes stop Anki from reading _ and * as wildcards.
167             val search = "\"${escape(first)}:${escape(key)}\" \"note:${escape(model)}\""
168             resolver.query(NOTES, arrayOf("_id"), search, null, null)?.use { it.count > 0 } ?: false
169         }.getOrDefault(false)
170     }
171 
172     /** Adds a note and puts its cards in the deck. Null when AnkiDroid refused it. */
173     fun addNote(modelId: Long, deckId: Long, fields: Array<String>, tags: Set<String>): Long? {
174         val values = ContentValues().apply {
175             put("mid", modelId)
176             put("flds", fields.joinToString(SEPARATOR))
177             put("tags", tags.joinToString(" ") { it.replace(' ', '_') })
178         }
179         val note = resolver.insert(NOTES, values) ?: return null
180         val cards = Uri.withAppendedPath(note, "cards")
181         resolver.query(cards, arrayOf("ord"), null, null, null)?.use { c ->
182             while (c.moveToNext()) {
183                 val deck = ContentValues().apply { put("deck_id", deckId) }
184                 resolver.update(Uri.withAppendedPath(cards, c.getString(0)), deck, null, null)
185             }
186         }
187         return note.lastPathSegment?.toLongOrNull()
188     }
189 
190     fun note(noteId: Long): Note? = runCatching {
191         resolver.query(Uri.withAppendedPath(NOTES, noteId.toString()), arrayOf("_id", "flds", "tags"), null, null, null)?.use { c ->
192             if (!c.moveToFirst()) {
193                 null
194             } else {
195                 Note(
196                     c.getLong(0),
197                     c.getString(1).split(SEPARATOR).toTypedArray(),
198                     c.getString(2).orEmpty().split(' ').filter { it.isNotBlank() }.toSet(),
199                 )
200             }
201         }
202     }.getOrNull()
203 
204     fun updateFields(noteId: Long, fields: Array<String>): Boolean = runCatching {
205         val values = ContentValues().apply { put("flds", fields.joinToString(SEPARATOR)) }
206         resolver.update(Uri.withAppendedPath(NOTES, noteId.toString()), values, null, null) > 0
207     }.getOrDefault(false)
208 
209     companion object {
210         const val DECK_NAME = "SubRead"
211         const val PERMISSION = "com.ichi2.anki.permission.READ_WRITE_DATABASE"
212 
213         private const val AUTHORITY = "com.ichi2.anki.flashcards"
214         private val ROOT: Uri = Uri.parse("content://$AUTHORITY")
215         private val NOTES: Uri = Uri.withAppendedPath(ROOT, "notes")
216         private val MODELS: Uri = Uri.withAppendedPath(ROOT, "models")
217         private val DECKS: Uri = Uri.withAppendedPath(ROOT, "decks")
218         private val MEDIA: Uri = Uri.withAppendedPath(ROOT, "media")
219 
220         /** Anki keeps the fields of a note in one string, with this character between them. */
221         private const val SEPARATOR = "\u001f"
222 
223         /** A text for an Anki search between quotes: \, ", _ and * get a backslash. */
224         private fun escape(text: String): String =
225             text.replace("\\", "\\\\").replace("\"", "\\\"").replace("_", "\\_").replace("*", "\\*")
226 
227         /** The package of AnkiDroid, or null when it is not installed. */
228         fun packageName(context: Context): String? {
229             val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
230                 context.packageManager.resolveContentProvider(AUTHORITY, PackageManager.ComponentInfoFlags.of(0))
231             } else {
232                 @Suppress("DEPRECATION")
233                 context.packageManager.resolveContentProvider(AUTHORITY, 0)
234             }
235             return info?.packageName
236         }
237 
238         fun hasPermission(context: Context): Boolean =
239             context.checkSelfPermission(PERMISSION) == PackageManager.PERMISSION_GRANTED
240     }
241 }