app/src/main/kotlin/space/subread/anki/Miner.kt (11430 bytes)
1 package space.subread.anki 2 3 import android.content.Context 4 import android.graphics.Bitmap 5 import android.icu.text.BreakIterator 6 import android.net.Uri 7 import space.subread.anki.core.Fields 8 import space.subread.anki.core.MediaNames 9 import space.subread.anki.core.MineRequest 10 import space.subread.anki.core.Note 11 import space.subread.anki.core.PlayerReport 12 import space.subread.anki.core.Scans 13 import space.subread.anki.core.Sentences 14 import space.subread.anki.core.Source 15 import java.io.File 16 17 /** 18 * Makes cards. [grab] keeps the media of the moment a text comes in: the picture of the 19 * screen and the sound of the sentence. [add] puts one card in AnkiDroid with that media and 20 * the term that the user chose in the pop-up. 21 */ 22 class Miner(private val context: Context) { 23 24 private val store = Store(context) 25 26 /** The media of one text, taken when it came in. One pop-up can add several cards with it. */ 27 class Grab( 28 val request: MineRequest, 29 val stem: String, 30 val picture: File?, 31 val sentenceAudio: File?, 32 /** How long the sentence sound is, in milliseconds, or null without one. */ 33 val soundMs: Long?, 34 /** Where the text is from: the sender, the player, or the app that sent it. */ 35 val source: String, 36 ) { 37 // The names in the media folder of AnkiDroid, once the first card copied the files. 38 internal var ankiPicture: String? = null 39 internal var ankiSound: String? = null 40 } 41 42 /** The parts of one card that the pop-up chose. */ 43 data class Card( 44 val expression: String, 45 val reading: String, 46 val definition: String, 47 val sentence: String, 48 /** The word as it is in the sentence, for the bold. */ 49 val selection: String, 50 val pitch: String, 51 val frequency: String, 52 /** Where the dictionary gives the audio of the word, or null. */ 53 val wordAudio: Uri?, 54 ) 55 56 sealed class Result { 57 class Added(val noteId: Long, val expression: String) : Result() 58 class Duplicate(val expression: String) : Result() 59 object Attached : Result() 60 class Failed(val why: String) : Result() 61 } 62 63 /** 64 * The media of the moment the text came in. [shot] is the screen from before the pop-up 65 * drew. [line] is the overlay when the text is its subtitle line: then the sound is that 66 * line, cut on the clock of the player. [grabbedAt] is `SystemClock.elapsedRealtimeNanos()` 67 * of the moment. Slow: not on the UI thread. 68 */ 69 fun grab(request: MineRequest, shot: Bitmap?, grabbedAt: Long, line: OverlayClient.Now?, source: String): Grab { 70 Media.cleanOld(context) 71 val stem = MediaNames.stem(request.expression ?: request.sentence ?: "card", System.currentTimeMillis()) 72 val picture = request.image?.let { Media.fetch(context, it, stem, Media.Kind.IMAGE) } 73 ?: shot?.let { Media.writeJpeg(it, Media.file(context, "$stem.jpg")) } 74 val (sound, ms) = sentenceAudio(request, line, stem, grabbedAt) 75 return Grab(request, stem, picture, sound, ms, source) 76 } 77 78 /** 79 * The sound of the sentence: cut from the file the request names, or fetched whole, or 80 * taken from the capture. From the capture, the clip is the subtitle line, on the clock of 81 * the player; without a line, the last seconds before the text came in. 82 */ 83 private fun sentenceAudio(request: MineRequest, line: OverlayClient.Now?, stem: String, grabbedAt: Long): Pair<File?, Long?> { 84 val audio = request.audio 85 if (audio != null) { 86 val start = request.audioStartMs 87 val end = request.audioEndMs 88 return if (start != null && end != null) { 89 AudioCut.cut(context, audio, start, end, Media.file(context, "$stem.m4a")) to (end - start) 90 } else { 91 Media.fetch(context, audio, "${stem}_sentence", Media.Kind.AUDIO) to null 92 } 93 } 94 val capture = CaptureService.instance ?: return null to null 95 // A sender that gives the position of its player, and no overlay: the tap is the position. 96 val report = line?.report ?: request.positionMs?.let { PlayerReport(it, grabbedAt, 1.0, false) } 97 val cue = line?.line 98 val cueStart = request.cueStartMs ?: cue?.let { it.startMs - it.offsetMs } 99 val cueEnd = request.cueEndMs ?: cue?.let { it.endMs - it.offsetMs } 100 val range = if (report != null && cueStart != null && cueEnd != null && cueEnd > cueStart) { 101 report.wallRange(cueStart, cueEnd, store.padMs.toLong()) 102 } else { 103 (grabbedAt - LAST_SECONDS * 1_000_000_000L)..grabbedAt 104 } 105 val clip = capture.clip(range.first, range.last, Media.file(context, "$stem.m4a")) 106 return clip to clip?.let { (range.last - range.first) / 1_000_000L } 107 } 108 109 /** Adds one card to AnkiDroid. Not on the UI thread. */ 110 fun add(grab: Grab, card: Card): Result { 111 val anki = AnkiClient(context) 112 val deckId = anki.ensureDeck() 113 val modelId = anki.ensureModel(deckId) 114 val fields = anki.fields(modelId) 115 if (fields.isEmpty()) return Result.Failed("the note type has no field") 116 val mapping = anki.mapping(modelId, fields) 117 if (store.skipDuplicates && card.expression.isNotEmpty() && mapping[fields.first()] == Source.EXPRESSION && 118 anki.isDuplicate(modelId, card.expression) 119 ) { 120 return Result.Duplicate(card.expression) 121 } 122 val sound = grab.ankiSound ?: grab.sentenceAudio?.let { anki.addMedia(it, "audio") }?.also { grab.ankiSound = it } 123 val picture = grab.ankiPicture ?: grab.picture?.let { anki.addMedia(it, "image") }?.also { grab.ankiPicture = it } 124 val stem = MediaNames.stem(card.expression.ifEmpty { "card" }, System.currentTimeMillis()) 125 val wordAudioFile = grab.request.wordAudio?.let { Media.fetch(context, it, "${stem}_word", Media.Kind.AUDIO) } 126 ?: card.wordAudio?.let { ask -> DictionaryClient.audio(context, ask)?.let { Media.write(context, it, "${stem}_word", Media.Kind.AUDIO) } } 127 val wordAudio = wordAudioFile?.let { anki.addMedia(it, "audio") }.orEmpty() 128 val note = Note( 129 expression = card.expression, 130 reading = card.reading, 131 definition = card.definition, 132 sentence = card.sentence, 133 sentenceAudioFile = sound.orEmpty(), 134 wordAudioFile = wordAudio, 135 imageFile = picture.orEmpty(), 136 source = grab.source, 137 pitch = card.pitch, 138 frequency = card.frequency, 139 selection = card.selection, 140 ) 141 val values = Fields.render(note, fields, mapping) 142 val tags = (store.tags.split(' ') + grab.request.tags).map { it.trim() }.filter { it.isNotEmpty() }.toSet() 143 val noteId = anki.addNote(modelId, deckId, values, tags) ?: return Result.Failed("AnkiDroid refused the note") 144 store.lastNoteId = noteId 145 store.lastNoteAt = System.currentTimeMillis() 146 return Result.Added(noteId, card.expression) 147 } 148 149 /** A picture shared on its own goes onto the last card of the hour. Not on the UI thread. */ 150 fun attachPicture(source: String): Result { 151 val noteId = store.lastNoteId 152 if (noteId == Store.NONE || System.currentTimeMillis() - store.lastNoteAt > 3_600_000L) { 153 return Result.Failed(context.getString(R.string.picture_no_card)) 154 } 155 val anki = AnkiClient(context) 156 val info = anki.note(noteId) ?: return Result.Failed(context.getString(R.string.picture_no_card)) 157 val modelId = anki.ensureModel(anki.ensureDeck()) 158 val fields = anki.fields(modelId) 159 val mapping = anki.mapping(modelId, fields) 160 val at = fields.indexOfFirst { mapping[it] == Source.IMAGE } 161 if (at < 0) return Result.Failed("the note type has no picture field") 162 val file = Media.fetch(context, source, MediaNames.stem("picture", System.currentTimeMillis()), Media.Kind.IMAGE) 163 ?: return Result.Failed("the picture cannot be read") 164 val name = anki.addMedia(file, "image") ?: return Result.Failed("AnkiDroid refused the picture") 165 val values = info.fields.copyOf() 166 values[at] = Fields.image(name) 167 return if (anki.updateFields(noteId, values)) Result.Attached else Result.Failed("AnkiDroid refused the change") 168 } 169 170 companion object { 171 /** Without a subtitle line, the clip is this much sound before the text came in. */ 172 const val LAST_SECONDS = 8 173 174 /** The longest selection that is a word. Longer, or with the end of a sentence in it, is a sentence. */ 175 const val MAX_WORD = 24 176 177 /** True for a selection that is a sentence, not a word. */ 178 fun looksLikeSentence(text: String): Boolean = 179 text.length > MAX_WORD || text.any { Sentences.isTerminator(it) } || text.trim().split(Regex("\\s+")).size > 3 180 181 /** 182 * The card for a term of the dictionary, or for the word alone when [entry] is null. The 183 * word starts at [start] of [text] and is [length] characters long; -1 when the word is 184 * not in the text. What the request gives wins over the dictionary. 185 */ 186 fun card(request: MineRequest, text: String, start: Int, length: Int, entry: DictionaryClient.Entry?): Card { 187 val inText = start >= 0 && length > 0 && start + length <= text.length 188 val selection = if (inText) text.substring(start, start + length) else "" 189 // A sender with its own definition names the word of the card. The text can hold 190 // that word in another form: 食べる of the sender is 食べた in the text. 191 val given = request.expression?.trim()?.takeIf { it.isNotEmpty() && request.definition != null } 192 return Card( 193 expression = entry?.expression ?: given ?: selection.ifEmpty { request.expression?.trim().orEmpty() }, 194 reading = request.reading ?: entry?.reading.orEmpty(), 195 definition = request.definition ?: entry?.glossary.orEmpty(), 196 sentence = if (inText) Scans.sentence(text, start, length) else request.sentence?.trim().orEmpty(), 197 selection = selection, 198 pitch = request.pitch ?: entry?.pitch.orEmpty(), 199 frequency = request.frequency ?: entry?.frequency.orEmpty(), 200 wordAudio = entry?.audio, 201 ) 202 } 203 204 /** 205 * The word at [index] of [text], without a dictionary. A text that the user [selected] 206 * as it is, and that is no sentence, is one word: the user chose its ends. Else the word 207 * boundaries of ICU, which also split Japanese and Chinese. 208 */ 209 fun wordAt(text: String, index: Int, selected: Boolean): IntRange { 210 if (text.isEmpty()) return IntRange.EMPTY 211 if (selected && !looksLikeSentence(text)) return text.indices 212 val at = index.coerceIn(0, text.length - 1) 213 val words = BreakIterator.getWordInstance() 214 words.setText(text) 215 val end = words.following(at).let { if (it == BreakIterator.DONE) text.length else it } 216 val start = words.preceding(end).let { if (it == BreakIterator.DONE) 0 else it } 217 return start until end 218 } 219 } 220 }