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/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 }