AnkiWeb
- Rating
- 0 (π 0 Β· π 0)
- Updated
- 2026-08-28
- Anki versions
- 25.09~
- Description language
- en
AnkiWeb addon 333760925
Adds 37 bulk, image-occlusion, audit, backup, sync, and undo-safe actions to an Anki bridge on port 8766 for AI-assisted collection automation at scale.
Open on AnkiWeb GitHub Ask about alternatives
active
| Min Anki | Max Anki | Updated |
|---|---|---|
| 23.10 | 25.09+ | 2026-08-28 |
Loadingβ¦
Syncs Notion pages, databases, rich blocks, images, and sub-pages into nested Anki decks with reversed toggles, image occlusions, and fast background operation.
Generates optimal image occlusions in one click for Anki's built-in feature, ignoring existing occlusions, with 25 free monthly generations for Limbiks users.
Provides a local authenticated bridge between Mnemosyne and Anki Desktop for card export, updates, review history, native image occlusion, without AnkiConnect or outbound requests; Anki handles scheduling.
Creates image occlusion flashcards in bulk from PDF slides by drawing boxes, with keyboard shortcuts, cloze support, and configurable masks.
Renders Markdown flashcards with Shiki syntax highlighting, 300+ languages, 60+ themes, code annotations, math, and an AI agent skill via AnkiConnect.
Creates screenshot-based occlusion cards that reveal hidden boxes or groups gradually, supporting ordered recall, diagrams, tables, and long answers.
A personal fork of AnkiConnect adding bulk note and scheduler actions, image-occlusion, image-crop, card-render, slim-note-read, media-thumbnail, media-membership-probe, bulk-media-store, revlog, backup, deck-export (fail-closed against silent filtered-deck omission), deck-integrity-audit, field find-and-replace, undo-stack read, deck-rename, card-flag, tag-rename, filtered-deck create/rebuild/report/empty, empty-card report/delete, AnkiWeb-sync (syncStatus/syncNow), and AnkiHub-suggestion actions (incl. staged, human-submitted optional-tag publication) β 37 new actions β served on port 8766.
Derived from AnkiConnect by Alex Yatskov (FooSoft Productions) β https://foosoft.net/projects/anki-connect/ (source: https://git.sr.ht/~foosoft/anki-connect), licensed under the GNU GPLv3. This fork remains GPLv3 β see LICENSE.
Copy or symlink the connect_plus/ folder into Anki's add-on directory as exactly connect_plus (the folder name is load-bearing β config is keyed to it):
ln -s <repo>/connect_plus "~/Library/Application Support/Anki2/addons21/connect_plus"
(or copy connect_plus/ there). Restart Anki.
Runs alongside stock AnkiConnect (add-on 2055492159) in the same Anki: stock on port 8765, Plus on 8766. All upstream actions are also served on 8766. Configs and permission stores are fully independent. Environment overrides are ANKICONNECT_PLUS_BIND_ADDRESS / ANKICONNECT_PLUS_CORS_ORIGIN. The empty-body banner on 8766 reads Agent Connect v.6 β clients that sniff for the exact string AnkiConnect v.6 (Yomitan-style) should point at stock on 8765 instead.
Same JSON-RPC-over-HTTP protocol as upstream, on port 8766:
curl localhost:8766 -d '{"action":"plusInfo","version":6}'
| Action | Summary |
|---|---|
bulkAddNotes |
Add many notes with one undo entry, fast duplicate pre-check, and per-note error reporting. Params: notes (upstream addNotes shape), atomic (default true), allowDuplicates (default false; per-note options.allowDuplicate overrides), suspend (default: config suspendNewCards, which ships false β stock behavior), dryRun (default false β see dry-run note below; returns {wouldAdd: <count>, wouldSuspend: <bool>, skipped, undoEntry: null}). Suspended-draft mode (opt-in): with suspend: true (or config suspendNewCards: true) the cards this batch creates are left suspended β in the same undo entry as the adds, so one Undo removes notes and cards β and listed in suspended. See "Suspension control" below. Returns {added, suspended, skipped: [{index, reason}], undoEntry}. |
bulkUpdateNoteFields |
Update fields and/or tags on many notes (notes: [{id, fields?, tags?}]; tags replaces the whole tag list). One undo entry; same atomic contract. Entries whose requested fields AND tags already byte-match the note are not written and are reported in unchanged (same only-write-what-changed rule as bulkAddTags); updated lists only notes actually written, and undoEntry is null when nothing was. dryRun (default false) returns {wouldUpdate: [noteIds], unchanged, skipped, undoEntry: null}; adding diff: true (dry-run only β on a real run it errors [invalid_param]) also returns preview: [{noteId, field, before, after}] β one entry per changed field plus one per changed tag list under the reserved field name __tags__ (space-joined values, emitted after that note's field rows), unchanged fields omitted, capped at maxPreview (default 20) β plus previewTruncated. Returns {updated, unchanged, skipped, suspensionPreserved, schedulingPreserved, undoEntry} β the two booleans are per-call post-checks (SPEC Β§31.2): the written notes' cards' (queue, due, ivl) are snapshotted before the writes and re-read after, so "a field edit never moves a card" is a verified fact, not a promise (false = alarm; always present on the real response, absent on dry runs). Tags are compared and previewed CANONIFIED β anki sorts, strips, case-insensitively de-duplicates and registry-matches tags on save, so after in a __tags__ preview row is what will really be stored, and a byte-identical repeat of a non-canonical request ("gamma delta", ["b","a"], ["x","X"]) now lands in unchanged instead of silently re-writing the note. |
bulkAddTags |
Add tags (str split on whitespace, or list) to many notes by id. Only notes actually changed are written and reported in updated. dryRun (default false) returns {wouldUpdate: [noteIds], skipped, undoEntry: null} β already-fully-tagged notes appear in neither list, same as real mode. Returns {updated, skipped, undoEntry}. |
addImageOcclusionNote |
Create a native (built-in) Image Occlusion note from an image {path} or {data, filename} plus occlusions (native string, or array of normalized 0β1 rects with optional ordinal), header, backExtra, tags, deckName, hideAllGuessOne. Returns {noteId, cardIds, undoEntry}. |
getImageOcclusionNote |
Read an IO note: {imageFilename, occlusions[] (one entry per shape with ordinal; rects as floats, other shapes as raw properties), header, backExtra, tags, occludeInactive}. |
updateImageOcclusionNote |
Update any subset of occlusions / header / backExtra / tags on an IO note (omitted params are kept exactly). Returns {undoEntry} (the actual undo entry name; was null before the round-2 undoLabel upgrade; null again when the update is a no-op β every requested value already matched the note, so nothing undoable was written and no entry was created). The image itself cannot be changed here β cropImageOcclusionImage is the supported way to change (crop) it. |
cropImage |
Crop a media image into a new media file (the original is kept). Params: filename (bare media filename), rect {left, top, width, height} as normalized 0β1 floats (clamped to the image, never padded), optional noteIds (every occurrence of the old filename in those notes' fields is rewritten to the new one, one undo entry). Returns {newFilename, width, height, notesUpdated, undoEntry} (undoEntry: null when no notes were rewritten β the media write alone is not undoable). Not for IO base images β see semantics below. |
cropImageOcclusionImage |
Crop a native IO note's base image and remap every occlusion rect into the cropped frame, atomically (one undo restores both the image and the rects). Params: noteId, rect (same normalized shape). Rects falling fully outside the crop are dropped; straddling rects are clipped to the crop edge; dropping all rects is refused. Returns {newFilename, occlusionsKept, occlusionsClipped, occlusionsDropped, cardIds, undoEntry}. |
queryRevlog |
Read-only review-history query filtered by cardIds / noteIds (both deduplicated) / deckName (incl. subdecks) / sinceMs (inclusive) / untilMs (exclusive), limit default 5000, offset default 0. Returns {rows, total, truncated, nextOffset} β total is the count of distinct matching rows, truncated: true means more rows remain beyond this page, and nextOffset (else null) resumes the walk, so hitting exactly limit rows is never a silent cutoff. |
createBackup |
Trigger a .colpkg backup into the profile's backups/ folder. {force} default true. Returns {created: bool} β false means nothing changed since the last backup, not a failure. |
plusInfo |
Version/action/docs metadata for this add-on, including actionDocs β per-action {summary, params, returns}: a one-line description, the wrapper's real parameter signature with JSON-style defaults (e.g. stripHtml=true), and a shape sketch of what the action returns, so callers can discover both ends of every call without reading this table. Also errorCodes β the full error vocabulary as {code: {retryable, reachable, meaning}}, so a client builds its retry table at runtime instead of hardcoding one β errorPrefixNote (which errors carry a code and which do not), and recipes, a list of named {name, description, example} call patterns (raw field projection, verified-sync contract, dry-run-then-write, undo-label convention, suspended-draft workflow and safe deck export β the two recipes that document where this fork deliberately does not behave like Anki β lean deck sweep, reading errors, empty-cards cleanup). Also version and specRevision: specRevision is the SPEC revision this build implements (15 and up), and the version's minor moves whenever default behavior changes, so a client that caches this response can detect that in a field instead of in prose. Works with no profile open, and during a sync. |
renderCard |
Render cards' question/answer exactly as Anki's template pipeline produces them. Params: cardIds, format (default "html" β verbatim rendered HTML, including any <script>/<style> blocks the card template itself contains; "body" removes those blocks; "text" returns visible text only, same conventions as notesSlim β usually what an LLM caller wants), cssMode (default null = format-dependent: "perCard" for html/body, "omit" for text; "byNotetype" hoists the stylesheet into one top-level cssByNotetype: {notetype: css} and drops the per-card css). Returns {cards: [{cardId, question, answer, css?, deckName, modelName, notetype, ord}]} β one entry per input id in input order; bad ids and per-card render failures become per-item {cardId, error} entries, never a hard failure. question/answer exclude the notetype-CSS <style> wrapper; [sound:...] tags render as [anki:play:...] markers. The CSS knob is the payload lever: a notetype stylesheet repeated once per card measured 90β95% of a multi-card response (20 cards: 92.2% smaller with byNotetype, 97.1% with the new text default). Read-only, undo stack untouched. |
notesSlim |
Compact, paginated, HTML-stripped note reader (built for LLM consumption). Params: exactly one of query (Anki search, verbatim; "" = all) / noteIds (caller order kept), fields (name filter, default all), stripHtml (default true; media filenames and [sound:...] kept; cloze markup like {{c1::France}} passes through verbatim, no bracketed-hint conversion; false returns raw field HTML byte-exact), maxFieldLength (default 400, 0 = off, cut with β¦), offset, limit (default 200, clamped to 2000). Raw-fidelity field projection: fields=[...] + stripHtml: false + maxFieldLength: 0 reads the chosen fields' exact stored HTML β the read-before-edit primitive. Also omitEmptyFields (default false; true drops fields whose emitted value is empty β on a 19-field notetype with 4 populated fields that is ~half the payload, and it runs faster). Returns {total, notes: [{noteId, modelName, tags, fields, truncatedFields}], missing, nextOffset} (nextOffset: null on the last page); truncatedFields explicitly names the fields cut by maxFieldLength ([] when none β the β¦ marker alone is ambiguous). Query path is ascending-noteId order. Breaking change (round 3): under noteIds, total now counts the ids found (it used to count the ids requested, silently including dead ones) and the dead ids are listed in missing, so len(noteIds) == total + len(missing); nextOffset is null when no surviving id remains past the page. Cost note: total/missing are window-independent, so every page re-scans the whole noteIds list β a full paged pass is O(NΒ²/L) (measured: ~103 ms added at N=5,000 / limit: 200, ~1.7 s at N=20,000). Read total/missing off the first page and carry them; they cannot change mid-pass. Read-only. |
mediaThumbnails |
Base64 thumbnails of collection media images β aspect-preserved, never upscaled (small images return at native size). Params: filenames (bare media names), maxDim (default 320, clamped to 1024), format ("jpeg" default or "png"), quality (JPEG 0β100, default 70). Returns {thumbnails: [{filename, data, width, height}]} with per-item {filename, error} entries; input order. JPEG flattens transparency β request png to keep alpha. Pure read. |
mediaExists |
Cheap read-only membership probe: do these bare media filenames exist in the media folder? Params: filenames ([str]; non-string entries are a hard parameter error). Returns {results: [{filename, exists, actualName}]} in input order; a malformed or path-carrying name is simply exists: false, never an error. actualName is the true stored spelling when the filesystem matched case-insensitively (macOS/Windows) and null otherwise β a name that only differs in case passes exists locally but 404s on AnkiWeb/Linux/iOS. Use this instead of diffing getMediaFilesNames (which returns the entire listing) for membership tests. |
storeMediaFilesBulk |
Store many media files in one call. Params: files: [{filename, data | path}] β per item a bare filename plus exactly one of data (base64) or path (absolute; ~ expanded). Returns {stored: [...]} in input order: {requested, actual} on success β actual is the filename Anki actually stored, surfacing its dedup/rename decision (same name + same bytes dedups to the same name; same name + different bytes renames to dup-<sha1>.<ext>; originals are never overwritten) β or {requested, error} per-item failures that never abort the batch. Media writes are not undoable (no undo entry). Stock storeMediaFile is unchanged. |
bulkSuspend |
Suspend or unsuspend many cards with one undo entry (Agent Connect: Bulk Suspend). Params: cardIds (deduplicated; unknown ids silently dropped), suspend (default true; false uses the backend restore op, which also unburies buried cards). Returns {changed, changedIds, undoEntry} (changedIds = the cards actually written, deduplicated, unknown ids dropped); changed: 0 β changedIds: [], undoEntry: null and the undo stack is untouched. |
bulkSetDueDate |
Set the due date on many cards with one undo entry (Agent Connect: Bulk Due Date). Params: cardIds (deduplicated; unknown ids dropped), days string β "0" due today, "5" in 5 days, "1-7" uniform-random per card in the range, "3!" also forces the interval to 3 days β preserveSuspended (default: config preserveSuspendedOnReschedule, which ships true), dryRun (default false; returns {wouldChange, wouldChangeIds, wouldUnsuspend, wouldUnbury, wouldResuspend, undoEntry: null} with zero writes). The grammar ([0-9]+(-[0-9]+)?!?) is validated before the undo entry is created, so a bad string errors with the undo stack genuinely untouched. Applies to every existing card regardless of state (new cards become review cards). β Anki's own set_due_date RESURRECTS suspended and buried cards β rescheduling a selection that includes suspended leeches silently brings them back; the revived ids are reported in unsuspended / unburied. β DELIBERATE DEVIATION FROM ANKI: by default this add-on then puts the suspensions back, inside the same undo entry, and lists them in resuspended (buried cards are deliberately not re-buried); pass preserveSuspended: false (or flip the config key) for stock behavior. Cards left in review = unsuspended β resuspended. It also always writes (no no-op suppression: "1-7" is nondeterministic by design), so a byte-identical repeat still creates an undo entry. Returns {changed, changedIds, unsuspended, unburied, resuspended, undoEntry}. |
exportDeckApkg |
Export one deck and its subdecks to an .apkg file. Params: deckName, outPath (default ~/Downloads/<sanitized-deck>-<YYYY-MM-DD>.apkg; ~ expanded; must be a file path β an existing directory or trailing slash is rejected; parent dir must exist), includeScheduling (default true), includeMedia (default true; false still writes an empty media zip member), allowFilteredOmission (default false β round 4's one deliberate behavior change, SPEC Β§Β§17/29.3). Fails closed on filtered-deck damage: a deck-scoped export silently OMITS every note whose cards are all sitting in out-of-scope filtered decks, ships partially-filtered notes scheduling-reset (a real class deck nearly went out missing 141 cards / 96 notes β zero warnings from Anki), and ships FOREIGN notes whenever a filtered deck nested inside the export subtree holds cards homed outside it (scheduling-reset, filter recreated as a regular deck). If either set is non-empty, the default REFUSES with [cards_in_filtered_decks] naming counts + filtered-deck names; run emptyFilteredDeck and re-export, or pass allowFilteredOmission: true to export anyway with the damage itemized in warnings β {code: "cards_in_filtered_decks", count, decks, notesOmitted} and/or {code: "foreign_cards_in_scope_filters", count, decks}. warnings is always present ([] when clean β additive key). The check runs before any filesystem work, so a refusal leaves no file and burns no collision suffix. Never overwrites: -2, -3, β¦ appended before the extension (report.apkg β report-2.apkg). Deck presets are never exported (fixed with_deck_configs=False, matching Anki's own dialog default); modern (non-legacy) package format. Returns {path, sizeBytes, notesExported, warnings}. No undo entry; collection unchanged. |
syncNow |
Start a normal AnkiWeb sync as a background job and return immediately ({started: true, mediaSync} or {started: false, reason}); poll syncStatus for the outcome. Normal sync only, zero dialogs: a required full sync is refused (job error full_sync_required). Media syncing follows the profile setting and is watched to completion (media_sync_failed in job.error.code on failure). |
syncStatus |
Read-only AnkiWeb sync probe: {loggedIn, job, mediaSyncing, mediaSecondsSinceLastSync, lastSyncMs, modMs, required, serverChecked}. required comes from a server status round-trip (timeoutSecs default 8) unless localOnly: true (local dirtiness only, and then full_sync_required when the schema changed). serverChecked is true only when this call really completed a network round-trip β Anki's backend answers locally whenever the collection is dirty, so false means "not verified by this call", never "the server says no". Never starts a sync, never clears stored auth, never opens dialogs. Verified-synced iff job.state == "done" && required == "no_changes" && mediaSyncing == false. The syncing state is guaranteed to end: the completion handler cannot leave it stuck, and a job still syncing an hour after it started is reaped into a terminal error β so polling syncStatus until the state leaves syncing, and the retryable [sync_in_progress] that tells you to, always terminate. |
ankihubStatus |
Read-only AnkiHub add-on probe: {installed, enabled, loggedIn, addonVersion, testedAddonVersion, appUrl, decks (with isAnkingDeck), compatible} plus problems when feature detection finds drift. Never network, never raises for a missing/disabled add-on. |
ankihubSuggestNoteUpdate |
Submit ONE change suggestion for an existing AnkiHub note through the installed AnkiHub add-on's own pipeline. Params: note (Anki note id), changeType (wire value, e.g. updated_content), rationale (β€1023 chars), source (only where the dialog shows one β required for content changes on the AnKing deck), autoAccept. Returns {result, comment}. |
ankihubSuggestNewNote |
Suggest a brand-new note to an AnkiHub deck. Params: note, rationale, optional source/deckId (default: resolved from the notetype), autoAccept, resubmitAsChangeOnDuplicate (default true β a duplicate-anki-id conflict is resubmitted as a change suggestion, mirroring the add-on's conflict dialog). Returns {result, resubmittedAsChange}. |
ankihubStageOptionalTagSuggestion |
Stage an AnkiHub optional-tag suggestion β a human submits; this action never touches AnkiHub's code or servers (their ToS prohibits scripted posting, and even opening their dialog from code fires network calls, so the action stops at the Browser selection; see SPEC Β§33). Params: tag (canonical AnkiHub_Optional::<TagGroup>::<Tag> shape, β₯3 non-empty :: segments β enforced, because the AnkiHub dialog silently ignores anything else), noteIds (β€500 unique ids; all-or-nothing: one missing note refuses the whole call, and every note must sit on exactly ONE AnkiHub deck β a local add-on-db read), dryRun, undoLabel. The real run tags the notes locally as ONE undoable batch (Agent Connect: Stage Optional Tag), opens the Browser on exactly those notes, and stops β the human right-clicks the selection β AnkiHub β "Suggest Optional Tags", reviews, and presses Submit in AnkiHub's own dialog (both AnkiHub-touching clicks are the human's). GUI-coupled: needs the Anki window for the Browser; dryRun runs the full validation chain (add-on compatibility, login, notes, single deck) and predicts the tag write with nothing written and nothing opened. Requires the AnkiHub add-on installed + logged in. Returns {tagged, alreadyTagged, ankihubDeckId, browserOpened, nextStep, undoEntry} β re-staging an already-tagged set is a reported no-op write that still reopens the Browser selection; re-running is always safe (already-tagged notes are skipped). |
checkDeckIntegrity |
Read-only audit of a deck and its subdecks. Params: deckName, includeOrphanMedia (default false), orphanMediaLimit (default 100, 0 = count only). Returns {missingMedia: [{noteId, field, filename}], unbalancedCloze: [{noteId, field}], clozeCardMismatch: [{noteId, expectedOrds, actualOrds}], clozeNotesWithoutCloze: [noteId], orphanMediaCollectionWide, orphanMediaCount, orphanMediaTruncated, notesChecked}. Missing media = per-field references (img/audio/object/[sound:...]) whose file is absent (latex-generated images exempt β regenerated on demand); unbalanced cloze = {{cN:: opens vs }} closes per field (simple balance check β literal }} text with no cloze opens is also flagged); cloze/card mismatch = cloze-type notes whose fields' cloze numbers no longer match their existing card ordinals β except a cloze note with zero effective cloze numbers carrying only Anki's own placeholder card (ord 0), which is Anki's maintained state, not drift: those note ids are reported in clozeNotesWithoutCloze instead (an authoring smell β a "cloze" note that will never cloze anything). Breaking change (round 3): orphanMedia is renamed orphanMediaCollectionWide β every other list here is deck-scoped, this one is collection-wide by nature (a file unreferenced by this deck may be used elsewhere), and on a real collection it returned 37,243 entries / 1.6 MB that read as if they belonged to the deck. It is null unless requested, capped at orphanMediaLimit with the full size always in orphanMediaCount and a orphanMediaTruncated flag: media-dir files referenced by no note field or notetype template anywhere, excluding _-prefixed static files and dotfiles. No undo entry; collection unchanged. |
bulkReplaceInFields |
Find/replace on the raw HTML of one named field across many notes, one undo entry (Agent Connect: Replace in Fields). Params: exactly one of query/noteIds (ids deduplicated), field, find (non-empty), replace, isRegex (default false β python re semantics, no backtracking-bomb protection; a non-compiling pattern is a parameter error; in literal mode replace is inserted verbatim, in regex mode \1-style templates expand), caseSensitive (default true), dryRun (default false), atomic (default true), maxPreview (default 20). Real run returns {changed, matchesTotal, unchanged, skipped: [{noteId, reason}], suspensionPreserved, schedulingPreserved, undoEntry} (the same per-call scheduling/suspension post-check as bulkUpdateNoteFields β SPEC Β§31.2); notes where nothing matched β or every match replaced itself byte-identically β land in unchanged and are never written. dryRun returns {wouldChange, matchesTotal, unchanged, skipped, preview: [{noteId, before, after}] (capped at maxPreview), previewTruncated, undoEntry: null} with zero writes β preview a batch before committing it. |
undoStatus |
Read-only view of Anki's own undo stack: {undo, redo, lastStep} β what a single Undo / Redo would do right now (null when there is nothing), plus anki's monotonic step counter, read straight off the backend. This is the observed truth behind every action's undoEntry (which is only the API's own report of what it set): snapshot lastStep, run a write, and check that the entry appeared. lastStep is monotonic within a session β clearing the stack (Check Database, a schema mod) keeps the counter rather than rewinding it. No params, no writes. |
renameDeck |
Rename a deck in place β the whole subtree follows in one undoable op, and the options-preset assignments, per-deck descriptions and collapse state all survive (deck ids are stable; the createDeck+changeDeck+deleteDecks workaround silently resets subdeck presets to Default, changing scheduling with no error). Params: oldName (must exist; matched case-insensitively), newName (full :: path; renaming under an existing parent is a move; missing parents auto-created; byte-identical β no-op), dryRun (default false β predicted {wouldRename, configWillBePreserved: true, cardsAffected, undoEntry: null}; configWillBePreserved is a static contract statement about the in-place rename path, not a post-check β the real run's configPreserved is the post-check), undoLabel. A newName (or implied descendant name) already taken by any deck other than the one being renamed onto it β the subtree's own members included β is refused with [duplicate]; anki's own rename would silently land on Occupied+ instead (a case-only respelling stays legal, and newName must be normalized: empty or whitespace-padded :: components are [invalid_param]). Returns {renamed: [{from, to}] (re-read from the collection AFTER the op), configPreserved (an actual post-check of every preset id, not an assumption), cardsAffected (cards homed in or visiting the subtree), undoEntry}. |
bulkSetFlag |
Set or clear (flag: 0) the colored flag on many cards, one undo entry (Agent Connect: Bulk Flag). Flags are the humanβagent channel β a human flags cards in review, the agent fixes them and finally clears the flag; stock's only route (setSpecificValueOfCard) clobbers the whole flags byte with no undo entry. Params: cardIds (deduplicated; unknown ids dropped), flag (0β7, pre-validated), dryRun (default false β {wouldUpdate, unchanged, undoEntry: null}), undoLabel. Returns {updated, unchanged, undoEntry} β unchanged = cards that already carried the requested flag (read from their real pre-op values), so a repeat call is a reported no-op with nothing written; on a (never-observed) backend/precheck disagreement updated is re-read from the post-op flags. |
renameTag |
Rename/move a tag and its :: subtree with anki's own segment-aware op: lab1 β lab01 rewrites lab1 and lab1::* but never lab10, matched case-insensitively (stock replaceTagsInAllNotes matches whole tags only β it strands the children, and it writes with no undo entry at all). Renaming onto an existing tag merges the trees β disclosed in merged. Params: oldTag/newTag (single tags, no spaces; a oldTag matching nothing raises [not_found] rather than "succeeding" at renaming nothing), dryRun (default false β {notesUpdated, wouldRewrite: [{from, to}], merged, undoEntry: null}, the preview that proves the prefix safety before anything is written; dry notesUpdated counts the notes carrying the affected tags, a zero-write prediction of the real run's backend count), undoLabel. Returns {notesUpdated (the backend's own count), tagsRewritten: [{from, to}] (re-read from the post-op tag registry β on a merge the existing spelling wins and the pair says so), merged, undoEntry}. Ghost tags (registered but carried by no note) are not renamed by the backend; an all-ghost match reports the no-write shape with the undo stack untouched. |
filteredDeckReport |
Read-only census of filtered decks: one name-sorted row per filtered deck with cardCount and homeDecks ({home deck name: count}, from the cards' odid). Params: deckName (default null = every filtered deck, empty ones included). Naming a regular deck scopes to cards whose home lies in that subtree (rows holding none are dropped) β the pre-export probe: totalCards is then the home-side count exportDeckApkg's fail-closed check trips on (the check also flags nested filters holding foreign-homed cards; the unscoped report shows those), and the rows name the decks to empty. Naming a filtered deck returns just that deck's row. Returns {filteredDecks: [{filteredDeck, filteredDeckId, cardCount, homeDecks}], totalCards}. The census half of the filtered-deck lifecycle β createFilteredDeck/rebuildFilteredDeck are the build half, emptyFilteredDeck the remediation. |
emptyFilteredDeck |
Send every card in ONE filtered deck back to its home deck β the API equivalent of the filtered deck's own Empty button (col.sched.empty_filtered_deck; cards return to did = odid with scheduling intact), and the remediation step exportDeckApkg's refusal points at. Params: exactly one of deckName/deckId, dryRun (default false β {wouldReturn, homeDecks, undoEntry: null}), undoLabel. One undo entry (Agent Connect: Empty Filtered Deck); a single undo puts the cards back in the filter. Emptying an already-empty filter is a reported no-op with nothing written. A regular deck is refused [validation_error]. Returns {returned (pre-op residency cross-checked against the post-op count), homeDecks (where they went, read before the op), undoEntry}. createFilteredDeck/rebuildFilteredDeck are the build half of the same filtered-deck lifecycle. |
createFilteredDeck |
Create and build a filtered (cram) deck from a search in one undoable op (Agent Connect: Create Filtered Deck) β the API version of Tools β Create Filtered Deck: "cram everything tagged PI9 that's due" is {name: "PI9 cram", searchQuery: "tag:PI9 is:due"}. Params: name (must be normalized β padded/empty :: components are [invalid_param] β and untaken: a taken name is refused [duplicate] instead of inheriting Anki's silent name+; missing parents are created as regular decks inside the same entry), searchQuery (validated and normalized by Anki's own parser; empty is refused β say deck:* out loud if you mean the whole collection), limit (default 100; 1β4294967295), order (default "random"; one of oldestReviewedFirst, random, intervalsAscending, intervalsDescending, lapses, added, due, reverseAdded, retrievabilityAscending, retrievabilityDescending β validated here because the backend accepts garbage silently), secondFilter ({searchQuery[, limit, order]}, defaults 20/"due"; two terms max β Anki saves a third but never gathers it), reschedule (default true), dryRun, undoLabel. Defaults mirror the GUI's own new-filtered-deck template. Suspended cards, buried cards, and cards already in another filtered deck are never gathered (Anki's own rule); gathering zero cards is refused [validation_error] with nothing created (the GUI's own refusal); a filtered parent is [validation_error]. dryRun sizes the deck without creating anything: {wouldCreate, wouldGather, exact, wouldGatherMin, wouldGatherMax, name, terms, undoEntry: null} β exact for a single filter (probe-verified min(limit, eligible)); with a second filter overlapping the first under a binding limit the split is genuinely order-dependent (RANDOM is nondeterministic), so the bounds bracket every outcome and wouldGather is the upper bound. Returns {deckId, name (read back post-op), cardsGathered (post-op count), terms ({search, limit, order, eligible} each), undoEntry}; a single undo deletes the deck and returns every card. Anki's build selects the new deck as current (GUI parity, disclosed in preserves). |
rebuildFilteredDeck |
Empty-then-regather ONE filtered deck by its saved search terms β the deck's own Rebuild button (col.sched.rebuild_filtered_deck), one undoable op (Agent Connect: Rebuild Filtered Deck). Params: exactly one of deckName/deckId, dryRun, undoLabel. Returns {cardsGathered (post-op residency), returnedFirst (pre-op residency β the cards the rebuild first sent home), undoEntry} β both halves observed, never echoed. Rebuild-to-zero is legal (deck left empty β Anki's behavior, not an error); rebuilding an empty deck whose saved terms would also gather nothing is a reported no-op ({cardsGathered: 0, returnedFirst: 0, undoEntry: null}, nothing written). A regular deck is refused [validation_error]; missing β [deck_not_found]. dryRun returns {wouldReturn, wouldGather, exact, wouldGatherMin, wouldGatherMax, terms, termsIgnored (saved terms beyond the two Anki actually gathers), undoEntry: null} β the deck's own cards count as re-gatherable; a saved term that no longer parses (external writer) is [validation_error] naming the term, on both paths. Rebuild preserves the current-deck selection (a build does not). |
getEmptyCards |
Read-only β Anki's Tools β Empty Cards report as data: per note, the empty cards' ordinals (ords), exactly which card ids deleteEmptyCards would delete (willDeleteCards), and protectedCard β non-null iff EVERY card of the note is empty, in which case that first card is kept (deletion never removes a note's last card, anki's own dialog rule). Params: deckName (default null = collection-wide; a deck scopes to notes with β₯1 empty card homed in that subtree, odid-aware β a listed note still reports all its empty cards). checkDeckIntegrity's clozeCardMismatch detects the condition; this is the actionable report. Returns {notes: [{noteId, ords, willDeleteCards, protectedCard}], total}. |
deleteEmptyCards |
Delete empty cards as ONE undoable batch (Agent Connect: Delete Empty Cards), honoring the same protection as Anki's own dialog with its shipped "keep notes" default: an all-empty note keeps its first card (listed in protected: [{noteId, cardId}]) and the note itself is NEVER deleted β notesPreserved is an actual post-check of that promise, not an assumption. Params: noteIds (default null = everything the live report finds; explicit ids not in the report land in skipped with "no empty cards" / "note was not found"), dryRun (default false β {wouldDelete: [cardId], notesAffected, protected, skipped, undoEntry: null}), undoLabel. Nothing deletable β data no-op, nothing written. Returns {cardsDeleted, deletedCardIds, notesAffected, protected, notesPreserved, skipped, undoEntry}. |
Suspension control β the one place this fork deliberately does NOT behave like Anki (SPEC Β§27). Two defaults differ from stock, both switchable, both on out of the box:
bulkSetDueDate puts suspensions back. Anki's own set_due_date turns every targeted card into a review card, which silently clears suspension β measured: 5 suspended cards rescheduled, all 5 came back at queue 2 with no signal at all. One deck-wide reschedule can therefore revive every leech you ever benched. With preserveSuspended (config preserveSuspendedOnReschedule, ships true) the cards the call revived are re-suspended, merged into the same undo entry so a single Ctrl+Z restores the exact pre-call state instead of leaving them rescheduled-but-live.bulkAddNotes can leave new cards suspended (suspend, config suspendNewCards, ships false β opt in), also inside the batch's own undo entry β so a generated batch lands as a draft: write suspended β a human reads them β that human unsuspends. Nothing enters review because a script said so.Reading the response: unsuspended is what Anki revived during the call, resuspended is what this add-on put back, so cards left in review = unsuspended β resuspended (and bulkAddNotes reports the card ids it suspended in suspended). Both lists are always present. Buried cards are deliberately not re-buried β burial hides a card for today and you just moved its due date β but they are still disclosed in unburied. Preview either action with dryRun: true (wouldSuspend / wouldResuspend).
Switching it off: per call, "suspend": false / "preserveSuspended": false (an explicit parameter always beats config); permanently, set "suspendNewCards": false / "preserveSuspendedOnReschedule": false in the add-on's config (Tools β Add-ons β Agent Connect β Config). Either restores stock Anki behavior exactly. A config file predating these keys keeps working β the shipped defaults apply β and a non-boolean config value is ignored in favor of the documented default, so a config typo never fails a write. Don't guess which default is in force on a given install: plusInfo.effectiveConfig reports both knobs RESOLVED at call time β {value, source: "user_config"|"shipped_default"} each β through the same code path the writes use (SPEC Β§31.3). source is probed from your saved add-on config (meta.json), not the shipped-defaults-merged view β with one honest caveat: saving Anki's config dialog once stores every key, after which both legitimately report user_config.
What a write does NOT touch is documented and, where it matters most, verified (SPEC Β§31). Every side-effectful action's plusInfo actionDocs entry carries a preserves line β what it leaves alone among scheduling, suspension, flags, tags, note ids, GUIDs, deck assignment, with the genuine non-preservations named (e.g. bulkSetDueDate evicts cards from filtered decks; a cloze-adding field edit grows the card set). The two field writers additionally prove their scheduling/suspension claims per call (suspensionPreserved/schedulingPreserved, Β§31.2), and plusInfo.effectiveConfig reports the resolved SPEC-27 defaults so no caller has to infer them from prose.
Round-3 breaking changes (FOUR, all deliberate β this list said "exactly two" until the round-3 review counted the error strings): (1) notesSlim.total under noteIds now counts the note ids that were found, not the ids that were requested, and the dead ones are listed in the new missing array β the old number silently counted notes it could not return ([real, fake, real, fake] reported total: 4 with two notes), and an all-dead page even handed back a nextOffset pointing at another empty page. Invariant now: len(noteIds) == total + len(missing). (2) checkDeckIntegrity.orphanMedia is renamed orphanMediaCollectionWide (with new orphanMediaCount / orphanMediaTruncated / orphanMediaLimit) because it is the only collection-wide array in an otherwise deck-scoped report. The key is not aliased β a stale caller gets a KeyError instead of silently reading null. Plus two error STRINGS that changed in the same round: (3) the dispatcher's unknown-action reply "unsupported action" is now "[unknown_action] unsupported action", and (4) argument-binding errors are rewritten to drop this add-on's internal class name β "PlusMixin.renderCard() missing 1 required positional argument: 'cardIds'" is now "[invalid_param] renderCard() missing required argument: cardIds". Both break a client that string-matches them; read errorCode instead. Everything else in round 3 is additive, with one edge: renderCard with format: "text" no longer returns css by default (cssMode: "perCard" restores it).
Stable error codes, on the wire as data: every error reply is
{"result": null, "error": "[collection_unavailable] collection is not available",
"errorCode": "collection_unavailable", "retryable": true}
errorCode and retryable are always present. Read them; do not parse the string. retryable: true means the identical call may succeed later with no change by you β in practice collection_unavailable (open a profile), sync_in_progress (poll syncStatus), and network_error/rate_limited (wait, then retry). The vocabulary is closed (not_found, invalid_param, deck_not_found, duplicate (reachable since round 4: renameDeck's occupied-name refusal), cards_in_filtered_decks (round 4: exportDeckApkg's fail-closed refusal), unsupported_format, batch_reverted, collection_unavailable, sync_in_progress, not_logged_in, auth_failed, network_error, rate_limited, permission_denied, validation_error, incompatible_ankihub_addon, source_required, rationale_invalid, unknown_action, internal, plus three reserved codes nothing raises) and plusInfo.errorCodes serves the whole thing at runtime with retryable/reachable/meaning, so you never have to hardcode this list. AnkiHub errors keep their CODE: taxonomy inside the message (two stable layers).
Prefixing boundary β the one gotcha: the "[<code>] " prefix and a non-null errorCode cover the 37 Agent Connect actions plus the unknown-action error. Everything else comes back verbatim with errorCode: null, retryable: null β the ~90 upstream AnkiConnect actions, the dispatcher's api-key refusal ("valid api key must be provided", the first error a misconfigured client hits), and malformed-request/schema failures β so error.split("] ", 1)[0] is safe only when errorCode is non-null, and errorCode: null does not prove the failure came from an upstream action. Inside multi each sub-response is formatted independently: a failing sub-action gets the full four-key envelope, a succeeding one gets {result, error: null} β and if that sub-action omitted "version" it gets the bare result with no envelope at all (the handler defaults version to 4). Test sub.get("error") is non-null before reading errorCode, and check per sub-response, because the outer reply reports error: null even when every sub-action failed. Per-item error strings inside a successful result (skipped[].reason, thumbnails[].error, stored[].error, β¦) are never prefixed and carry no code. See SPEC Β§25 for the full table.
The [<code>] prefix was one of the round-2 upgrade's two deliberate breaking contract changes β the other is updateImageOcclusionNote's return, which changed from the upstream update-action null to {undoEntry} (see the table above; callers checking for null on that action must switch to reading undoEntry, which is itself null only for a no-op update). The errorCode/retryable fields themselves are purely additive: the error string is byte-for-byte what it was.
Atomic/undo contract (bulk actions): each bulk action creates a single named undo entry (e.g. Agent Connect: Bulk Add) so one Undo in Anki reverts the whole batch. Every undo-creating action (bulkAddNotes, bulkUpdateNoteFields, bulkAddTags, bulkSuspend, bulkSetDueDate, bulkReplaceInFields, cropImage, cropImageOcclusionImage, addImageOcclusionNote, updateImageOcclusionNote, renameDeck, bulkSetFlag, renameTag, emptyFilteredDeck, createFilteredDeck, rebuildFilteredDeck, deleteEmptyCards) also accepts an optional undoLabel: the entry is then named Agent Connect: <label> (whitespace collapsed, capped at 80 chars) so batches stay distinguishable in the Undo menu, and the response's undoEntry always reports the actual final name (null when nothing undoable was written). Omitting undoLabel keeps every default name unchanged. With atomic: true (default), any unexpected hard error reverts everything already written and raises an error whose message includes failedIndex, the underlying error, addedBeforeRevert, and skipped as JSON. With atomic: false, hard errors are recorded per-note in skipped and processing continues. Validation skips (duplicate, empty first field, missing model/deck/field/note) always go to skipped in either mode and never abort the batch. Exception (suspension control, SPEC Β§27): if the trailing suspend step of bulkAddNotes fails, the whole batch is reverted and [batch_reverted] is raised in both atomic modes, with a report keyed failedStep: "suspend" instead of failedIndex β returning added-but-live notes under a success response would be exactly the silent divergence this fork refuses to ship. And because "reverted" is itself a claim, it is verified: in the one case where the rollback cannot run (the suspend op succeeded and only its undo-merge failed, so Anki's own entry sits above the batch's), the error is [internal] "... (batch NOT reverted)" and names what is still committed (addedStillCommitted/addedIds, or rescheduledStillCommitted/stillUnsuspended for bulkSetDueDate) rather than telling you a retry is safe when it would duplicate the writes.
Dry-run mode (dryRun: true on the three bulk note actions, plus bulkReplaceInFields, which has its own dry-run shape with a before/after preview β see its row β and bulkSetDueDate, whose dry run predicts wouldChange/wouldChangeIds/wouldUnsuspend/wouldUnbury/wouldResuspend; bulkUpdateNoteFields adds a per-changed-field preview with diff: true β see its row): previews a batch with zero writes β no notes added or updated, no media stored, no undo entry (not even an empty one; undo_status() is bit-identical before/after). The dry path runs the exact same validation code as the real path and short-circuits at the zero-write boundary, so skipped entries and reasons match a real run. The success key is renamed because its semantics change: bulkAddNotes returns wouldAdd as a count (note ids do not exist until a real add) plus wouldSuspend as a bool (the resolved suspend decision β see "Suspension control" below), while bulkUpdateNoteFields/bulkAddTags return wouldUpdate as the list of note ids that would be written (bulkUpdateNoteFields also mirrors its unchanged no-op list). bulkSetDueDate's dry keys are renamed for a stronger reason: its real unsuspended/unburied are re-read from the post-op state, while the would* versions are a prediction of what Anki will do. undoEntry is always null. Limitation: dry runs skip upstream media embedding (embedding stores files), so notes carrying audio/video/picture keys are validated on their fields as submitted, without media-filename substitution β in principle the real run's substituted fields could differ for first-field-emptiness/duplicate checks. Hard parameter errors still raise; write-time hard errors (the atomic: false skipped entries) cannot be predicted by a dry run. On bulkSetDueDate, dryRun must be a real JSON boolean: dryRun: "false" is [invalid_param], never a silent real reschedule, and dryRun: [] is [invalid_param], never a silent write.
Duplicate detection (bulkAddNotes): Anki-native semantics β same notetype + same stripped first field, collection-wide (checksum precheck confirmed against stripped text). Per-note options.duplicateScope / options.duplicateScopeOptions are accepted but ignored in v1; per-note options.allowDuplicate is honored.
queryRevlog field semantics: interval / lastInterval positive = days, negative = seconds. factor = SM-2 ease permille (0 for learning/manual rows; not scheduling-relevant under FSRS). type: 0 learning, 1 review, 2 relearning, 3 filtered/cram, 4 manual/forget, 5 rescheduled β stats-worthy rows are type NOT IN (4, 5). noteId is null for orphan rows whose card was deleted. Caveat: the deck filter reflects each card's current deck (home deck for cards in a filtered deck), not the deck at review time.
Image occlusion ordinals: shapes sharing an ordinal mask together on one card; ordinal: 0 is annotation-only (generates no card); omitted ordinals are assigned 1..N in array order. getImageOcclusionNote does not return image bytes β use upstream retrieveMediaFile.
Deck placement / IO deviations: IO cards are moved to the requested deckName as part of the same undo step; createBackup may return {created: false} even with force: true when the collection is unchanged; updateImageOcclusionNote has no image parameter.
Sync blocks everything (syncNow) β now enforced, not just documented: during the collection phase of a sync the Anki backend holds the collection lock, so every collection-touching action on both ports (stock 8765 and Agent Connect 8766) would block until the sync finishes. Rather than block, Agent Connect actions called while a syncNow job is in state syncing now fail fast with the retryable [sync_in_progress] (retryable: true). Four actions are deliberately exempt and always answer: syncStatus (which also skips collection access while syncing β poll this to wait the sync out), syncNow (which reports busy states as data: {started: false, reason: "already_syncing"}), plusInfo, and ankihubStatus. Only the syncing phase is guarded β during media_syncing the collection is free, exactly as in stock Anki. Stock AnkiConnect on 8765 is unaffected and still blocks.
AnkiHub media rename side effect: both suggest actions reuse the AnkiHub add-on's own submission pipeline, which content-hash renames newly-added media files across the whole collection (raw SQL inside the add-on, not undoable) before uploading them to AnkiHub in the background. This is the add-on's standard behavior for every suggestion, inherited as-is.
Crop semantics: both crop actions write the result as a new media file and never delete or overwrite the original (use deleteMediaFile for cleanup). Do not point cropImage's noteIds at an image-occlusion note's base image: the filename is rewritten but the occlusion rects are NOT remapped, so every mask misaligns β cropImageOcclusionImage is the IO-safe path. Empty-card gotcha: if cropImageOcclusionImage drops every shape of some ordinal, the backend does not delete that ordinal's now-empty card; its id still appears in cardIds, and Tools β Empty Cards is the cleanup path.
The server runs single-threaded on the Qt main thread: createBackup (with completion-wait) and very large bulk batches freeze the Anki UI for their duration. Typical bulk batches (hundreds of notes) complete in tens of milliseconds.
The add-on never issues raw SQL writes; revlog access is read-only SELECT.