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("&", "&").replace("<", "<").replace(">", ">") 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 }