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/DictionaryClient.kt (5644 bytes)

1 package space.subread.anki
2 
3 import android.content.Context
4 import android.database.Cursor
5 import android.net.Uri
6 import androidx.core.net.toUri
7 
8 /**
9  * Asks SubRead Dictionary for the terms of a text, through its content provider
10  * `content://space.subread.dictionary.lookup` (see docs/provider-api.md of that app).
11  *
12  * `terms?text=…` gives one row for each term of each dictionary, the longest term first. The
13  * rows of one term and reading make one [Entry]. `audio?expression=…&reading=…` gives one row
14  * with the file of the first source that has the audio, and `audio/<file>` its bytes.
15  */
16 object DictionaryClient {
17 
18     const val PACKAGE = "space.subread.dictionary"
19 
20     /** The release authority, then the one of a debug build, which installs next to the release. */
21     val AUTHORITIES = listOf("space.subread.dictionary.lookup", "space.subread.dictionary.debug.lookup")
22 
23     /** The authority of the installed dictionary that answers, or null. */
24     fun authority(context: Context): String? =
25         AUTHORITIES.firstOrNull { context.packageManager.resolveContentProvider(it, 0) != null }
26 
27     /** One term: the entries of every dictionary for it, joined. */
28     data class Entry(
29         val expression: String,
30         val reading: String,
31         /** How many characters of the text the term covers. */
32         val length: Int,
33         /** The deinflection from the text to the term, for example `past`. Empty for the dictionary form. */
34         val reasons: String,
35         /** HTML: the name of each dictionary and its glossary. */
36         val glossary: String,
37         val frequency: String,
38         val pitch: String,
39         /** Where the audio of the word is asked for, or null. */
40         val audio: Uri?,
41     ) {
42         val headword: String get() = if (reading.isEmpty() || reading == expression) expression else "$expression【$reading】"
43     }
44 
45     fun installed(context: Context): Boolean = runCatching {
46         context.packageManager.getPackageInfo(PACKAGE, 0)
47     }.isSuccess || authority(context) != null
48 
49     /** True when the installed dictionary has the provider: the versions before it do not. */
50     fun answers(context: Context): Boolean = authority(context) != null
51 
52     /**
53      * The terms that start at [offset] in [text], the longest first. Empty when the dictionary
54      * is not there or has no term there.
55      */
56     fun lookup(context: Context, text: String, offset: Int = 0): List<Entry> {
57         val authority = authority(context)
58         if (text.isEmpty() || offset !in text.indices || authority == null) return emptyList()
59         val uri = "content://$authority/terms".toUri().buildUpon()
60             .appendQueryParameter("text", text).appendQueryParameter("offset", offset.toString()).build()
61         val cursor = runCatching { context.contentResolver.query(uri, null, null, null, null) }.getOrNull() ?: return emptyList()
62         data class Row(
63             val expression: String, val reading: String, val length: Int, val reasons: String,
64             val dictionary: String, val glossary: String, val frequency: String, val pitch: String,
65         )
66         val rows = cursor.use { c ->
67             buildList {
68                 while (c.moveToNext()) add(
69                     Row(
70                         c.text("expression") ?: continue, c.text("reading").orEmpty(), c.long("length")?.toInt() ?: 0,
71                         c.text("reasons").orEmpty(), c.text("dictionary").orEmpty(),
72                         c.text("definition_html") ?: c.text("glossary").orEmpty(),
73                         c.text("frequency").orEmpty(), c.text("pitch").orEmpty(),
74                     ),
75                 )
76             }
77         }
78         return rows.groupBy { it.expression to it.reading }.map { (key, group) ->
79             val first = group.first()
80             Entry(
81                 expression = key.first,
82                 reading = key.second,
83                 length = first.length,
84                 reasons = first.reasons,
85                 glossary = group.joinToString("<br>") { row ->
86                     if (row.dictionary.isEmpty()) row.glossary else "<i>${escape(row.dictionary)}</i><br>${row.glossary}"
87                 },
88                 frequency = group.map { it.frequency }.firstOrNull { it.isNotEmpty() }.orEmpty(),
89                 pitch = group.map { it.pitch }.firstOrNull { it.isNotEmpty() }.orEmpty(),
90                 audio = "content://$authority/audio".toUri().buildUpon()
91                     .appendQueryParameter("expression", key.first).appendQueryParameter("reading", key.second).build(),
92             )
93         }
94     }
95 
96     /**
97      * The bytes of the audio of a term, or null when no source has it. The provider names the
98      * file in a row first, then gives the bytes at `audio/<file>`.
99      */
100     fun audio(context: Context, ask: Uri): ByteArray? = runCatching {
101         val name = context.contentResolver.query(ask, null, null, null, null)?.use { c ->
102             if (c.moveToFirst()) c.text("file") else null
103         } ?: return null
104         val file = "content://${ask.authority}/audio/$name".toUri()
105         context.contentResolver.openInputStream(file)?.use { it.readBytes() }?.takeIf { it.isNotEmpty() }
106     }.getOrNull()
107 
108     private fun escape(text: String) = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
109 
110     private fun Cursor.text(column: String): String? {
111         val i = getColumnIndex(column)
112         return if (i < 0 || isNull(i)) null else getString(i)
113     }
114 
115     private fun Cursor.long(column: String): Long? {
116         val i = getColumnIndex(column)
117         return if (i < 0 || isNull(i)) null else getLong(i)
118     }
119 }