Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

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

Log | Files | Refs


commit 97de4b9471c2ee80c60ad9e8ca04982630e3bf42
truex <truex@equwal.com>
2026-09-26 13:49:52 -0700

Make a card in SubRead Anki from the plugin

The new plugin method ankiAdd starts space.subread.anki.action.ADD for
a result. It sends the word, the reading, the sentence, the text and
the source as string extras, and show as a boolean extra, only when the
caller gives them. SubRead Anki needs a word or a text, so the call
rejects without both.

RESULT_OK resolves added true with the note id. A cancel resolves added
false. ReturnWatch decides when SubRead Anki is closed, the same as for
a lookup. Without SubRead Anki, and on the web, the call resolves the
error not_installed. The manifest now queries the SubRead Anki package.

 android/app/src/main/AndroidManifest.xml           |  1 +
 .../java/com/equwal/scansubread/SubReadPlugin.java | 61 +++++++++++++++++++++-
 src/subread.ts                                     | 42 ++++++++++++++-
 test/subread.test.ts                               |  6 +++
 4 files changed, 107 insertions(+), 3 deletions(-)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 016c045..079262f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -42,6 +42,7 @@
         <package android:name="space.subread.overlay" />
         <package android:name="space.subread.overlay.debug" />
         <package android:name="space.subread.dictionary" />
+        <package android:name="space.subread.anki" />
         <!-- The player provider of SubRead Overlay: the release build and the debug build. -->
         <provider android:authorities="space.subread.overlay.player;space.subread.overlay.debug.player" />
         <!-- The dictionary apps: each app in the text selection menu. -->
diff --git a/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java b/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
index 0fbe0a6..b7f9f8f 100644
--- a/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
+++ b/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
@@ -45,6 +45,8 @@ import java.util.List;
  *   menu. The lookup resolves when the dictionary closes.
  * - The subtitle maker, through the intent API of the SubRead app
  *   (`space.subread.app.action.ALIGN`).
+ * - SubRead Anki, through `space.subread.anki.action.ADD`: a card for a
+ *   word and its sentence.
  * - The screen, which stays on during read-along.
  */
 @CapacitorPlugin(name = "SubRead")
@@ -72,10 +74,20 @@ public class SubReadPlugin extends Plugin {
 
     private static final String SHARE_DIR = "share";
 
+    private static final String ANKI_PACKAGE = "space.subread.anki";
+    private static final String ACTION_ANKI_ADD = "space.subread.anki.action.ADD";
+    private static final String EXTRA_ANKI_WORD = "space.subread.anki.extra.WORD";
+    private static final String EXTRA_ANKI_READING = "space.subread.anki.extra.READING";
+    private static final String EXTRA_ANKI_SENTENCE = "space.subread.anki.extra.SENTENCE";
+    private static final String EXTRA_ANKI_TEXT = "space.subread.anki.extra.TEXT";
+    private static final String EXTRA_ANKI_SOURCE = "space.subread.anki.extra.SOURCE";
+    private static final String EXTRA_ANKI_SHOW = "space.subread.anki.extra.SHOW";
+    private static final String EXTRA_ANKI_NOTE_ID = "space.subread.anki.extra.NOTE_ID";
+
     /** The authority that answered last. It is tried first. */
     private String authority;
 
-    /** Tells when the dictionary is closed. */
+    /** Tells when the dictionary or SubRead Anki is closed. */
     private final ReturnWatch returns = new ReturnWatch();
 
     @Override
@@ -273,6 +285,53 @@ public class SubReadPlugin extends Plugin {
         call.resolve();
     }
 
+    // --- The card maker, through SubRead Anki ---
+
+    /** Puts the text option `name` in the extra `extra`, when the caller gave it. */
+    private static void putText(Intent intent, PluginCall call, String name, String extra) {
+        String value = call.getString(name);
+        if (value != null && !value.isEmpty()) intent.putExtra(extra, value);
+    }
+
+    /** Asks SubRead Anki to make a card. SubRead Anki needs a word or a text. */
+    @PluginMethod
+    public void ankiAdd(PluginCall call) {
+        Intent add = new Intent(ACTION_ANKI_ADD).setPackage(ANKI_PACKAGE);
+        putText(add, call, "word", EXTRA_ANKI_WORD);
+        putText(add, call, "reading", EXTRA_ANKI_READING);
+        putText(add, call, "sentence", EXTRA_ANKI_SENTENCE);
+        putText(add, call, "text", EXTRA_ANKI_TEXT);
+        putText(add, call, "source", EXTRA_ANKI_SOURCE);
+        if (!add.hasExtra(EXTRA_ANKI_WORD) && !add.hasExtra(EXTRA_ANKI_TEXT)) {
+            call.reject("word or text is required.");
+            return;
+        }
+        Boolean show = call.getBoolean("show");
+        if (show != null) add.putExtra(EXTRA_ANKI_SHOW, show.booleanValue());
+        if (add.resolveActivity(getContext().getPackageManager()) == null) {
+            call.resolve(errorObject("not_installed"));
+            return;
+        }
+        returns.launched();
+        startActivityForResult(call, add, "ankiResult");
+    }
+
+    /** SubRead Anki gave its result: the note is in Anki, or the user closed the card. */
+    @ActivityCallback
+    private void ankiResult(PluginCall call, ActivityResult result) {
+        if (call == null) return;
+        returns.result(() -> {
+            JSObject ret = new JSObject();
+            Intent data = result.getData();
+            boolean added = result.getResultCode() == Activity.RESULT_OK;
+            ret.put("added", added);
+            if (added && data != null && data.hasExtra(EXTRA_ANKI_NOTE_ID)) {
+                ret.put("noteId", data.getLongExtra(EXTRA_ANKI_NOTE_ID, 0));
+            }
+            call.resolve(ret);
+        });
+    }
+
     // --- The subtitle maker, through the SubRead app ---
 
     /** Writes `text` to cacheDir/share/<name> and returns its FileProvider Uri. */
diff --git a/src/subread.ts b/src/subread.ts
index 89e7884..4a3bc20 100644
--- a/src/subread.ts
+++ b/src/subread.ts
@@ -1,8 +1,8 @@
 // The bridge to the SubRead suite: the typed side of SubReadPlugin.java.
 //
 // On the web there is no suite. The fallback reports no overlay, copies a
-// lookup to the clipboard, and cannot make subtitles. It keeps the screen on
-// with the Screen Wake Lock API when the browser has it.
+// lookup to the clipboard, and cannot make subtitles or cards. It keeps the
+// screen on with the Screen Wake Lock API when the browser has it.
 
 import { Capacitor, registerPlugin } from '@capacitor/core';
 
@@ -28,6 +28,31 @@ export interface SubtitlesResult {
   error?: string;
 }
 
+/** A card for SubRead Anki. Give `word` or `text`. */
+export interface AnkiCard {
+  /** The word, in its dictionary form when the caller knows it. */
+  word?: string;
+  /** The reading of the word, in kana. */
+  reading?: string;
+  /** The sentence that holds the word, as plain text: see `sentenceAround`. */
+  sentence?: string;
+  /** A text with no word chosen: the user taps the word on the card. */
+  text?: string;
+  /** Where the sentence is from, for example the title of the book. */
+  source?: string;
+  /** True: show the card before it goes to Anki. */
+  show?: boolean;
+}
+
+export interface AnkiResult {
+  /** True when the note is in Anki. False when the user closed the card. */
+  added?: boolean;
+  /** The id of the new note in Anki, when `added` is true. */
+  noteId?: number;
+  /** SubRead Anki is not on the device. */
+  error?: 'not_installed';
+}
+
 export interface SubReadPlugin {
   playerState(): Promise<StateLine>;
   play(): Promise<StateLine>;
@@ -49,6 +74,16 @@ export interface SubReadPlugin {
   dictionaries(): Promise<{ apps: DictionaryApp[]; chosen: string }>;
   /** An empty component means: ask each time. */
   setDictionary(options: { component: string }): Promise<void>;
+  /**
+   * Makes a card in SubRead Anki (`space.subread.anki.action.ADD`). Only the
+   * fields that are given and not empty go to SubRead Anki; without `word`
+   * and `text` the call rejects. Resolves when SubRead Anki closes, the same
+   * as `lookup`: `{ added: true, noteId }` when the note is in Anki,
+   * `{ added: false }` when the user closed the card, and
+   * `{ error: 'not_installed' }` without SubRead Anki. On the web:
+   * `{ error: 'not_installed' }`.
+   */
+  ankiAdd(options: AnkiCard): Promise<AnkiResult>;
   makeSubtitles(options: {
     audio: string;
     bookText: string;
@@ -104,6 +139,9 @@ class SubReadWeb implements SubReadPlugin {
   setDictionary(): Promise<void> {
     return Promise.resolve();
   }
+  ankiAdd(): Promise<AnkiResult> {
+    return Promise.resolve({ error: 'not_installed' });
+  }
   makeSubtitles(): Promise<SubtitlesResult> {
     return Promise.resolve({ error: 'not_installed' });
   }
diff --git a/test/subread.test.ts b/test/subread.test.ts
index 1bac26e..4e6b8f2 100644
--- a/test/subread.test.ts
+++ b/test/subread.test.ts
@@ -47,6 +47,12 @@ describe('the web fallback of the plugin', () => {
     expect(locks[1]!.released).toBe(true);
   });
 
+  it('has no SubRead Anki', async () => {
+    await expect(SubRead.ankiAdd({ word: '猫', sentence: '吾輩は猫である。' })).resolves.toEqual({
+      error: 'not_installed',
+    });
+  });
+
   it('ignores a browser with no wake lock, and a refused request', async () => {
     vi.stubGlobal('navigator', {});
     await expect(SubRead.keepAwake({ on: true })).resolves.toBeUndefined();