Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
154 changes: 102 additions & 52 deletions README.md

Large diffs are not rendered by default.

241 changes: 241 additions & 0 deletions extensions/anki/anki.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// Talking to Anki through AnkiConnect, and the shared views.

import {
type Action,
action,
close,
type Context,
copy,
exec,
field,
form,
type FormSpec,
Icons,
type Item,
open,
pop,
storage,
toast,
} from "@backslash/api";
import {
ANKICONNECT_CODE,
ANKICONNECT_URL,
BASIC,
buildDecks,
CLOZE,
type Deck,
type DeckStats,
ENDPOINT,
installHint,
noteFields,
noteParams,
orderModels,
parseTags,
request,
REVERSED,
toHtml,
unwrap,
visibleDecks,
} from "./lib.ts";

/** Anki is closed, or AnkiConnect isn't installed. */
export class OfflineError extends Error {}

export async function anki<T>(
ctx: Pick<Context, "preferences">,
action: string,
params: Record<string, unknown> = {},
signal?: AbortSignal,
): Promise<T> {
const key = String(ctx.preferences.apiKey ?? "").trim() || undefined;
let res: Response;
try {
res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request(action, params, key)),
signal: signal ?? AbortSignal.timeout(action === "sync" ? 120_000 : 10_000),
});
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") throw e;
throw new OfflineError("Anki isn't running, or AnkiConnect isn't installed");
}
if (!res.ok) throw new Error(`AnkiConnect answered ${res.status}`);
return unwrap<T>(await res.json());
}

export async function loadDecks(ctx: Pick<Context, "preferences">, signal?: AbortSignal): Promise<Deck[]> {
const names = await anki<Record<string, number>>(ctx, "deckNamesAndIds", {}, signal);
const stats = await anki<Record<string, DeckStats>>(ctx, "getDeckStats", { decks: Object.keys(names) }, signal);
return visibleDecks(buildDecks(names, stats));
}

// --- Opening Anki ------------------------------------------------------------

const HOME = Deno.env.get("HOME") ?? "";

/** Where Anki is installed, in the order tried; the same paths as `[permissions] read`. */
const LAUNCHERS = [
"/usr/bin/anki",
"/usr/local/bin/anki",
`${HOME}/.local/bin/anki`,
"/var/lib/flatpak/exports/bin/net.ankiweb.Anki",
`${HOME}/.local/share/flatpak/exports/bin/net.ankiweb.Anki`,
"/snap/bin/anki",
];

export async function findAnki(): Promise<string | undefined> {
for (const path of LAUNCHERS) {
if (await Deno.stat(path).then((s) => s.isFile, () => false)) return path;
}
return undefined;
}

/** Brings Anki to the front, or starts it in the background (it outlives this command). */
export async function openAnki(ctx: Pick<Context, "preferences">): Promise<string> {
try {
await anki(ctx, "guiDeckBrowser");
return "Anki opened";
} catch (e) {
if (!(e instanceof OfflineError)) throw e;
}
const path = await findAnki();
if (!path) throw new Error("Anki isn't installed: get it from apps.ankiweb.net or Flathub (net.ankiweb.Anki)");
await exec("systemd-run", ["--user", "--collect", "--quiet", `--unit=anki-${Date.now()}`, path]);
return "Starting Anki…";
}

export async function sync(ctx: Pick<Context, "preferences">): Promise<string> {
await anki(ctx, "sync");
return "Anki synced with AnkiWeb";
}

// --- Empty state -------------------------------------------------------------

export async function offlineItems(ctx: Pick<Context, "preferences">): Promise<Item[]> {
const installed = await findAnki();
const osRelease = await Deno.readTextFile("/etc/os-release").catch(() => "");
const steps = [
"## Connect Anki",
"",
"Backslash talks to Anki through the **AnkiConnect** add-on, while Anki is open.",
"",
"1. In Anki: **Tools → Add-ons → Get Add-ons…**",
`2. Paste the code **${ANKICONNECT_CODE}** and press OK`,
"3. Restart Anki, and keep it open (it can stay minimized)",
"",
"If you set an API key in AnkiConnect's config, enter it in **Settings → Extensions → Anki**.",
];
const items: Item[] = [{
id: "offline",
title: installed ? "Anki isn't running, or AnkiConnect isn't installed" : "Anki isn't installed",
subtitle: installed ? `Add-on code ${ANKICONNECT_CODE}` : "Install Anki, then the AnkiConnect add-on",
icon: { source: Icons.plug, tint: "orange" },
detail: {
markdown: installed
? steps.join("\n")
: ["## Install Anki", "", ...installHint(osRelease), "", ...steps.slice(2)].join("\n"),
},
actions: [
...(installed
? [action("Open Anki", async () => close(await openAnki(ctx)), { icon: Icons.external })]
: [open("https://apps.ankiweb.net", "Download Anki", { icon: Icons.download })]),
open(ANKICONNECT_URL, "AnkiConnect on AnkiWeb", { icon: Icons.globe }),
copy(ANKICONNECT_CODE, "Copy Add-on Code", { shortcut: "<Ctrl><Shift>c" }),
],
}];
return items;
}

// --- Adding cards ------------------------------------------------------------

export async function lastDeck(): Promise<string | undefined> {
const d = await storage.get("lastDeck").catch(() => undefined);
return typeof d === "string" ? d : undefined;
}

async function lastModel(): Promise<string | undefined> {
const d = await storage.get("lastModel").catch(() => undefined);
return typeof d === "string" ? d : undefined;
}

export async function addCard(
ctx: Pick<Context, "preferences">,
o: { deck: string; model: string; front: string; back: string; tags: string[]; rememberModel?: boolean },
): Promise<number> {
const names = await anki<string[]>(ctx, "modelFieldNames", { modelName: o.model });
const fields = noteFields(names, toHtml(o.front), toHtml(o.back));
const id = await anki<number>(ctx, "addNote", noteParams({ deck: o.deck, model: o.model, fields, tags: o.tags }));
await storage.set("lastDeck", o.deck);
// Quick adds pick their type from the text: only the form's choice is remembered.
if (o.rememberModel) await storage.set("lastModel", o.model);
return id;
}

/** The deck to add to: the remembered one if it still exists, else the first one that isn't empty "Default". */
export function pickDeck(decks: string[], remembered: string | undefined): string {
if (remembered && decks.includes(remembered)) return remembered;
return decks.find((d) => d !== "Default") ?? decks[0] ?? "Default";
}

export async function addCardForm(
ctx: Pick<Context, "preferences">,
o: { deck?: string; front?: string; standalone?: boolean } = {},
): Promise<FormSpec> {
const [decks, models, remembered, model] = await Promise.all([
anki<string[]>(ctx, "deckNames"),
anki<string[]>(ctx, "modelNames").then(orderModels, () => [BASIC, REVERSED, CLOZE]),
lastDeck(),
lastModel(),
]);
decks.sort((a, b) => a.localeCompare(b));
return form({
title: "Add Card",
submitTitle: "Add Card",
fields: [
field.dropdown("deck", "Deck", decks.map((d) => ({ value: d, title: d.replace(/::/g, " › ") })), {
value: o.deck ?? pickDeck(decks, remembered),
}),
field.dropdown("model", "Note type", models, {
value: model && models.includes(model) ? model : models[0],
}),
field.textArea("front", "Front", {
required: true,
placeholder: "Question, or a cloze: The capital of France is {{c1::Paris}}",
value: o.front ?? "",
}),
field.textArea("back", "Back", { placeholder: "Answer (Back Extra for a cloze)" }),
field.text("tags", "Tags", { placeholder: "french vocabulary" }),
],
async submit(values) {
const front = String(values.front ?? "").trim();
if (!front) throw new Error("The front of the card is empty");
const modelName = String(values.model || BASIC);
if (/cloze/i.test(modelName) && !/\{\{c\d+::/.test(front)) {
throw new Error("A cloze card needs a deletion: {{c1::hidden text}}");
}
const deck = String(values.deck || pickDeck(decks, remembered));
await addCard(ctx, {
deck,
model: modelName,
front,
back: String(values.back ?? ""),
tags: parseTags(String(values.tags ?? "")),
rememberModel: true,
});
await toast(`Card added to ${deck.replace(/::/g, " › ")}`, { style: "success" });
return o.standalone ? close(`Added to ${deck.replace(/::/g, " › ")}`) : pop();
},
});
}

export function ankiActions(ctx: Pick<Context, "preferences">): Action[] {
return [
action("Sync", async () => close(await sync(ctx)), {
icon: Icons.refresh,
shortcut: "<Ctrl><Shift>s",
section: "Anki",
}),
action("Open Anki", async () => close(await openAnki(ctx)), { icon: Icons.external, section: "Anki" }),
];
}
66 changes: 66 additions & 0 deletions extensions/anki/backslash.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
[extension]
id = "anki"
title = "Anki"
description = "Your Anki decks with today's new, learning and review counts, quick card creation and sync, through AnkiConnect"
version = "1.0.0"
author = "Backslash"
icon = "bs-layers"
license = "MIT"

# AnkiConnect listens on 127.0.0.1:8765 while Anki runs. When Anki is
# closed, "Open Anki" starts it in the background with systemd-run; the read
# paths are only checked to find where Anki is installed (package, official
# installer, pipx, Flatpak system or user, Snap).
[permissions]
net = ["127.0.0.1:8765"]
run = ["systemd-run"]
read = [
"/etc/os-release",
"/usr/bin/anki",
"/usr/local/bin/anki",
"~/.local/bin/anki",
"/var/lib/flatpak/exports/bin/net.ankiweb.Anki",
"~/.local/share/flatpak/exports/bin/net.ankiweb.Anki",
"/snap/bin/anki",
]
env = ["HOME"]

[[commands]]
name = "decks"
title = "Anki Decks"
description = "New, learning and due cards per deck; study, add cards, sync"
icon = "bs-layers"
keywords = ["anki", "flashcards", "decks", "review", "study", "srs", "cartes", "réviser", "paquets", "mémoriser"]

[[commands]]
name = "add"
title = "Add Anki Card"
description = "Deck, note type (Basic, reversed, Cloze), front, back and tags"
icon = "bs-plus"
keywords = ["anki", "card", "add", "new", "flashcard", "cloze", "carte", "ajouter", "nouvelle"]

[[commands]]
name = "quick-add"
title = "Quick Add Anki Card"
description = "“front :: back #tag” into the last deck used; a {{c1::cloze}} makes a Cloze card"
icon = "bs-bolt"
mode = "no-view"
keywords = ["anki", "quick", "add", "card", "capture", "carte", "rapide", "ajouter"]
[[commands.arguments]]
name = "card"
placeholder = "bonjour :: hello #french"
required = true

[[commands]]
name = "sync"
title = "Sync Anki"
description = "Sync your collection with AnkiWeb"
icon = "bs-refresh"
mode = "no-view"
keywords = ["anki", "sync", "ankiweb", "synchroniser"]

[[preferences]]
name = "apiKey"
title = "AnkiConnect API key"
description = "Only if you set “apiKey” in AnkiConnect's config (Tools → Add-ons → AnkiConnect → Config)"
type = "secret"
11 changes: 11 additions & 0 deletions extensions/anki/commands/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { type Context, list } from "@backslash/api";
import { addCardForm, OfflineError, offlineItems } from "../anki.ts";

export default async (ctx: Context) => {
try {
return await addCardForm(ctx, { front: ctx.query.trim() || undefined, standalone: true });
} catch (e) {
if (!(e instanceof OfflineError)) throw e;
return list({ items: await offlineItems(ctx) });
}
};
Loading
Loading