## Linked issue
Closes#5374
## Summary
Move .github/scripts to tools/ so they are included in static checking
and testing.
## Steps to reproduce (before)
- Make a formatting/typing error in prepare_release.py and confirm
`ninja check` does not report that.
- Modify test_validate_version.py to fail tests and confirm it's not
caught by ninja check.
## How to test (after)
The scripts should now be included in the checks as the tools/ directory
is already included by the build system.
## Linked issue
Closes#5359
## Summary
The Windows installer searches for an old elevated install location at
the `Software\WOW6432Node\Anki` registry key and looks for
`uninstall.exe` there to decide if installation should be blocked. If
the registry key is missing though, the file search was falling back to
looking for any file named uninstall.exe in the root of every drive,
which was never intended.
See template diff:
5c1ced97ff
## Steps to reproduce (before)
1. Create an empty file at `C:\uninstall.exe` or any drive.
2. Try to install
[26.08.1](https://github.com/ankitects/anki/releases/tag/26.08.1) and
confirm it fails with the message "A previous Anki version needs to be
uninstalled first".
## How to test (after)
1. Build the installer with this PR: `./tools/ninja installer:package`.
2. Run `./out/installer/dist/anki-26.08.1-win-x64.msi` and confirm
installation is not blocked.
## Linked issue
Fixes#5355
## Summary / motivation
Two locations used `assert` statements inside broad `except` blocks,
flagged by SonarCloud rule
[python:S5779](https://sonarcloud.io/project/issues?rules=python%3AS5779&issueStatuses=OPEN%2CCONFIRMED&id=ankitects_anki).
This is problematic because:
- `AssertionError` is caught by the surrounding `except`, so the
assertion is silently swallowed instead of surfacing a meaningful error.
- Under `python -O`, `assert` statements are stripped entirely, so the
check disappears in optimized builds and the failure resurfaces later as
an opaque `AttributeError`.
Changes:
- **`qt/aqt/addons.py`** (`download_addon`): replace `assert match is
not None` with `if match is None: raise ValueError(...)` naming the
unexpected `content-disposition` header. The raise is still caught by
the existing handler and returned as a `DownloadError`, now with a
descriptive message.
- **`qt/aqt/editor_legacy.py`** (`setup_mask_editor`): replace `assert
self.note is not None` with a guard that warns the user
(`tr.browsing_no_selection()`) and returns early.
## How to test (required)
### Details
- `just lint` ✅
- `just test-py` ✅ — includes a new regression test,
`test_download_addon_rejects_bad_content_disposition`, covering the
malformed `content-disposition` path in `download_addon`.
- No test added for the `editor_legacy.py` guard: it defends an
effectively unreachable state (a missing note while editing an existing
note), and that Qt-side method isn't reachable from the web e2e harness.
## Before / after behavior
- **addons.py** — Before: malformed header → empty/opaque
`DownloadError` (or `AttributeError` under `-O`). After: `DownloadError`
carrying a `ValueError` that names the offending header.
- **editor_legacy.py** — Before: missing note → empty warning dialog
(blank `AssertionError` message). After: a clear warning, and early
return.
## Scope
- [x] This PR is focused on one change (no unrelated edits).
## Linked issue
Fixes#5353
## Summary / motivation
`_check_dynamic_request_permissions` in `qt/aqt/mediasrv.py` read the
`Content-type` header via direct indexing:
```python
if request.headers["Content-type"] != "application/binary":
```
When a request omits that header, `Headers.__getitem__` raises a
`KeyError`, which surfaces as an unhandled `500 Internal Server Error`
instead of the intended `abort(403)`. Flagged by SonarCloud rule
[python:S8371](https://sonarcloud.io/project/issues?rules=python%3AS8371&issueStatuses=OPEN%2CCONFIRMED&id=ankitects_anki)
(unsafe direct header access).
The fix uses `.get()`, which returns None for a missing header. Since
`None != "application/binary"`, a request without the header now
correctly falls through to the `403` rejection path — the desired
behavior for an opaque cross-origin request.
## Steps to reproduce
1. Send a non-GET request to a dynamic /_anki/ endpoint (e.g. a POST)
without a Content-type header.
2. Server hits request.headers["Content-type"] and raises KeyError.
3. Client receives 500 Internal Server Error instead of 403 Forbidden.
## How to test
### Details
- Ran `just fmt`, `just lint`, and `just test-py` — all pass.
- Added `TestCheckDynamicRequestPermissions` in
qt/tests/test_mediasrv.py, asserting a POST without a `Content-type`
header raises `Forbidden` (403) rather than `KeyError`.
## Before / after behavior
**Before**: request with no `Content-type` header → unhandled `KeyError`
→ `500`.
**After**: same request → `abort(403)` as intended.
## Linked issue (required)
Fixes https://github.com/ankitects/anki/issues/5327
## Summary / motivation (required)
Moving cards between decks used to clear the old FSRS data, without
recomputing the new FSRS data. This led to some unwanted behavior like
absence of memory states in card info, inaccurate searching and sorting
in the browser, etc.
## Steps to reproduce (required, use N/A if not applicable)
1. Move some cards from one deck to another
2. The browser doesn't show any memory states.
## How to test (required)
<!--- How to test: how you verified the change (checks, unit tests,
manual steps, edge cases — the "after" or general validation). --->
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or
behavior changed.
### Details
<!-- Commands, manual steps, edge cases, and what you observed -->
## Before / after behavior (optional)
<!-- For bugfixes: behavior before vs after. For other types: N/A or a
short note. -->
## Risk / compatibility / migration (optional)
<!-- Breaking changes, rollout notes, or N/A for small / low-risk PRs
-->
## UI evidence (required for visual changes; otherwise N/A)
<!-- Screenshot or short video -->
## Scope
- [x] This PR is focused on one change (no unrelated edits).
---------
Co-authored-by: Luc Mcgrady <lucmcgrady@gmail.com>
Co-authored-by: Fernando Lins <1887601+fernandolins@users.noreply.github.com>
## Linked issue (required)
Fixes#5364
## Summary / motivation (required)
CI (`check (linux)` → `Run cargo-deny check`) started failing on
[RUSTSEC-2026-0258](https://rustsec.org/advisories/RUSTSEC-2026-0258) —
"h2 unbounded empty DATA frames". No code change of ours introduced it:
cargo-deny fetches the advisory DB at run time, so the same commit began
failing once the advisory was published (2026-08-19).
Two vulnerable `h2` copies were in the tree:
- `h2 0.4.12` — the shipped stack (`hyper 1.x`). Bumped to the patched
`0.4.17`.
- `h2 0.3.27` — pulled in **only** by the internal `linkchecker` dev
tool, via `linkcheck → reqwest 0.11 → hyper 0.14 → h2 0.3`. The `h2 0.3`
series has **no fix** (patched only in `>= 0.4.16`).
As #5364 anticipated, the fix is to move our `linkcheck` fork off
`reqwest 0.11`. `ankitects/linkcheck` was bumped to `reqwest 0.12` /
`http 1` (no source changes required — the APIs used are unchanged), and
this PR pins `linkcheck` to the new `anchors` tip. That removes the old
subtree at the root instead of suppressing the advisory via a
`deny.toml` ignore (which would also force an explicit license
allow-list on the project, since cargo-deny switches to strict license
checking once a config file exists).
## Steps to reproduce (required, use N/A if not applicable)
1. Check out `main` at any recent commit.
2. Run `cargo deny check` (or push and let `check (linux)` run in CI).
3. It fails with `error[vulnerability]: h2 unbounded empty DATA frames`
(RUSTSEC-2026-0258) for both `h2 0.3.27` and `h2 0.4.12`.
## How to test (required)
### Details
- `cargo deny check` → `advisories ok, bans ok, licenses ok, sources
ok`.
- `cargo build -p linkchecker` succeeds against the updated `linkcheck`.
- Full `just check` (`./ninja check`) passes, including
`check:minilints` (`cargo/licenses.json` regenerated for the updated
tree).
## Before / after behavior
**Before**: `cargo deny check` fails on RUSTSEC-2026-0258 (two `h2`
copies), breaking
CI.
**After**: `h2` resolves to a single patched `0.4.17`; the `reqwest
0.11` subtree is gone; cargo-deny is clean.
## Risk / compatibility / migration
Low. Dependency-only change. `linkcheck` (used only by the `linkchecker`
test tool) now builds on `reqwest 0.12` / `http 1`, which the rest of
the workspace already uses; `Cargo.lock` shrinks as the duplicate old
subtree is dropped.
## Linked issue
https://community.ankihub.net/t/error/607154
## Summary
The browser adds its hooks (setupHooks) before the editor is initialized
(setupEditor). The operation_did_execute hook's handler assumes the
editor is already initialized and tries to access it, triggering an
AttributeError in rare cases.
Moving the setupHooks() call down one line should guard against this
(assuming it's not the case of some add-on patching `Browser.editor`).
## Steps to reproduce
No reliable way to reproduce it, but I managed to trigger it once by
installing the following add-ons and clicking the AnkiHub sync button
(or just calling `aqt.mw.reset()`):
```
1322529746 1102281552 1374772155 1556734708 1709973686 1730200873 1746010116 1771074083 1788670778 1810938259 2040501954 24411424 300884351 374005964 46611790 594329229 613684242 738807903
```
The `check:format:cog` and `format:cog` Ninja actions did not rerun
after changes to docs/, docs-site/. This fixes it.
---------
Co-authored-by: Fernando Lins <1887601+fernandolins@users.noreply.github.com>
## Linked issue (required)
Fixesankitects/anki#5173
## Summary / motivation (required)
This PR removed a hard coded font size value. That way the font size
changes dynamically, which is also important for A11Y. The issue had
been initally introduced in
[https://github.com/ankitects/anki/pull/5057](<https://github.com/ankitects/anki/pull/5057>).
@Luc-Mcgrady and @focushover, pinging you FYI.
## Steps to reproduce (required, use N/A if not applicable)
1. Open preferences.
2. Go to experiments tab.
3. See that font size is different from other tabs.
## How to test (required)
Tested with `./run --safemode`, then visual check.
### Checklist (minimum)
- [X] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or
behavior changed.
### Details
It has also been successfully tested against changes in "user interface
size" preference in Anki.
## Before / after behavior (optional)
Before: inconsistent font size <br>After: consistent font size
## Risk / compatibility / migration (optional)
None.
## UI evidence (required for visual changes; otherwise N/A)
With this PR:
<img
src="https://github.com/user-attachments/assets/cad67197-0793-4392-8b7e-a9d1f580c29b
" alt="A" width="951" data-linear-height="905" />
<img
src="https://github.com/user-attachments/assets/55144931-3b70-45c6-8616-2d0966dbe397
" alt="B" width="951" data-linear-height="905" />
## Scope
- [X] This PR is focused on one change (no unrelated edits).
---------
Co-authored-by: Luc Mcgrady <lucmcgrady@gmail.com>
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.21.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="0cd9cc3c58"><code>0cd9cc3</code></a>
7.5.21</li>
<li><a
href="631ae59121"><code>631ae59</code></a>
list: prevent unbounded recursion</li>
<li><a
href="ebbb720941"><code>ebbb720</code></a>
7.5.20</li>
<li><a
href="2f271963a7"><code>2f27196</code></a>
fix: fully disable and dispose of unzip when aborting parser</li>
<li><a
href="be440da64e"><code>be440da</code></a>
7.5.19</li>
<li><a
href="2812e93386"><code>2812e93</code></a>
add maxDecompressionRatio guard against explosive decompression</li>
<li><a
href="9ecd4d2956"><code>9ecd4d2</code></a>
7.5.18</li>
<li><a
href="9e78bf058b"><code>9e78bf0</code></a>
refuse to let header size be less than 0</li>
<li><a
href="e02a4e9e01"><code>e02a4e9</code></a>
pax: parse values according to known types</li>
<li><a
href="9cbdb31e5e"><code>9cbdb31</code></a>
7.5.17</li>
<li>Additional commits viewable in <a
href="https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.21">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~isaacs">isaacs</a>, a new releaser for tar
since your current version.</p>
</details>
<details>
<summary>Install script changes</summary>
<p>This version adds <code>prepare</code> script that runs during
installation. Review the package contents before updating.</p>
</details>
<br />
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/ankitects/anki/network/alerts).
</details>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Fernando Lins <1887601+fernandolins@users.noreply.github.com>
Bumps
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
from 3.2.4 to 3.2.6.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">vitest's
releases</a>.</em></p>
<blockquote>
<h2>v3.2.6</h2>
<h3> 🐞 Bug Fixes</h3>
<ul>
<li>Pin last supported vite-node version - by <a
href="https://github.com/sheremet-va"><code>@sheremet-va</code></a> <a
href="https://github.com/vitest-dev/vitest/commit/16f120d05"><!-- raw
HTML omitted -->(16f12)<!-- raw HTML omitted --></a></li>
</ul>
<h5> <a
href="https://github.com/vitest-dev/vitest/compare/v3.2.5...v3.2.6">View
changes on GitHub</a></h5>
<h2>v3.2.5</h2>
<h3> 🚀 Features</h3>
<ul>
<li><strong>api</strong>: Add <code>allowWrite</code> and
<code>allowExec</code> options to <code>api</code> [backport to v3] -
by <a href="https://github.com/hi-ogawa"><code>@hi-ogawa</code></a> and
<strong>Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10445">vitest-dev/vitest#10445</a>
<a href="https://github.com/vitest-dev/vitest/commit/af88b1f5d"><!-- raw
HTML omitted -->(af88b)<!-- raw HTML omitted --></a></li>
</ul>
<h3> 🐞 Bug Fixes</h3>
<ul>
<li><strong>browser</strong>: Disable client <code>cdp</code> API when
<code>allowWrite/allowExec: false</code> [backport to v3] - by <a
href="https://github.com/hi-ogawa"><code>@hi-ogawa</code></a> and
<strong>Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10456">vitest-dev/vitest#10456</a>
<a href="https://github.com/vitest-dev/vitest/commit/385a1aefd"><!-- raw
HTML omitted -->(385a1)<!-- raw HTML omitted --></a></li>
</ul>
<h5> <a
href="https://github.com/vitest-dev/vitest/compare/v3.2.4...v3.2.5">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b6d56f8171"><code>b6d56f8</code></a>
chore: release v3.2.6</li>
<li><a
href="16f120d058"><code>16f120d</code></a>
fix: pin last supported vite-node version</li>
<li><a
href="2cbad0a923"><code>2cbad0a</code></a>
chore: release v3.2.5</li>
<li><a
href="385a1aefd4"><code>385a1ae</code></a>
fix(browser): disable client <code>cdp</code> API when
<code>allowWrite/allowExec: false</code> [ba...</li>
<li><a
href="af88b1f5d8"><code>af88b1f</code></a>
feat(api): add <code>allowWrite</code> and <code>allowExec</code>
options to <code>api</code> [backport to v3]...</li>
<li>See full diff in <a
href="https://github.com/vitest-dev/vitest/commits/v3.2.6/packages/vitest">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for vitest since your current version.</p>
</details>
<br />
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
You can trigger a rebase of this PR by commenting `@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/ankitects/anki/network/alerts).
</details>
> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Fernando Lins <1887601+fernandolins@users.noreply.github.com>
## Linked issue
Closes#5304
## Summary
This configures the official VS Code extension for mypy to run in daemon
mode.
## How to test
- Install the `ms-python.mypy-type-checker` extension and **switch to
the pre-release version** (This is important for daemon mode to work
according to my tests).
- Copy .vscode.dist/settings.json to .vscode/
- Restart the MyPy server using the "MyPy: Restart server" command.
- Edit any Python code to add a typing error and confirm the error is
visually reported. (Note: initial mypy run might take a few seconds).
<img width="1305" height="361" alt="image"
src="https://github.com/user-attachments/assets/e8f431b0-b2ae-483d-b305-cf19c9dd013d"
/>
## Linked issue (required)
Closes#5177
## Summary / motivation (required)
See #5177, as well as
https://github.com/ankitects/anki-core-i18n/pull/18
## Steps to reproduce (required, use N/A if not applicable)
N/A
## How to test (required)
1. Run the newly added tests (`cargo test -p ftl string::tests`)
2. Run the following:
```sh
mkdir -p /tmp/ftl-demo/en
cat > /tmp/ftl-demo/en/errors.ftl <<'EOF'
errors-collection-too-new = This collection requires a newer version of Anki to open.
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.
errors-invalid-input-empty = Invalid input.
EOF
cargo run -p ftl -- string copy /tmp/ftl-demo /tmp/ftl-demo errors-collection-too-new errors-demo-key
cat /tmp/ftl-demo/en/errors.ftl
```
Expected output — the new key lands above the deprecated section, not
inside it.
### Checklist (minimum)
- [X] I ran `./ninja check` or an equivalent relevant check locally.
- [X] I added or updated tests when the change is non-trivial or
behavior changed.
### Details
See "how to test"
## Before / after behavior (optional)
BEFORE: Strings were appended to the file, sometimes landing them in the
deprecated section.
AFTER: Strings are added after the initial global comments if they
exist, ensuring new strings do not land in a deprecated section.
## Risk / compatibility / migration (optional)
Currently the deprecated section markers are all over the place;
suggesting a one-off followup PR in the i18n repo to clean them, then
another change to lint for the standardized deprecation titling to
prevent drift, and/or some other way of keeping that section highly
specific.
## UI evidence (required for visual changes; otherwise N/A)
N/A
## Scope
- [X] This PR is focused on one change (no unrelated edits).
## Linked issue
A small follow-up to #5320
## Summary
#5320 fixed autoplay for attached files and recordings but missed
pasted/dropped files.
## How to test
Paste and drag & drop of audio files is currently broken (#5203) so
there's no easy way to test this right now.
## Linked issue (required)
Refs: #5203
## Summary / motivation (required)
The new editor falls back to DataTransfer.files for images but not for
audio, which this pr fixes
## Steps to reproduce (required, use N/A if not applicable)
See linked issue
## How to test (required)
Copypasting/drag-dropping an audio file into a field in the new editor
### Checklist (minimum)
- [x] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or
behavior changed.
## Scope
- [x] This PR is focused on one change (no unrelated edits).
closes#3521
Previously when a card was moved between decks, it would be possible
that the FSRS data could be cleared from the card so that it wouldn't
display when the card stats were shown.
With this pr the memory state is attempted to be calculated when the
stats screen is opened if the state doesn't already exist.
## Testing steps
1. Create and review a card
2. Move it to a different deck
3. This card will now lack a memory state
4. Open the card stats
5. You will see the memory state there.
## Drawbacks
Notably means that the cards in the deck browser will still be missing
the memory state related columns after they're moved. It also introduces
an operation that modifies the card to the card_info function which
seems like a weird thing to do, although a similar thing is also done
when the user try's to simulate cards which are missing memory states.
This changed is predicated on what Dae said in the aformentioned issue:
> Deck changing is a common operation, and we don't want expensive
calculations every time it happens.
>
> Keeping the old memory state would require some extra flag so we know
to discard it at study time. And it would mean your stats are still
wrong - they'd just contain stale data, instead of missing data.
However with the memory state speed improvements introduced in #4335.
Maybe it is worth calculating the state when the cards are moved decks
rather than in this way?
## Linked issue (required)
Closes#5321
## Summary / motivation (required)
In the old Qt-based editor, attaching an audio file (paperclip button or
the mic recording button) played the file immediately, before the card
was saved. This was handled by `Editor.fnameToLink()` in
`qt/aqt/editor.py`, which called `av_player.play_file_with_caller()`
right after building the `[sound:...]` tag.
That method was removed in 4dd402334 ("Remove legacy editor code") once
the editor moved to the Svelte/TS implementation. Its replacement,
`filenameToLink()` in
`ts/routes/editor/rich-text-input/data-transfer.ts`, only builds the
`[sound:...]` string and never carried the playback call forward. So
today, attaching audio inserts the tag silently and you only hear it
after saving and reopening the card (or opening the Cards preview).
This restores the immediate playback, using the same `av_player`
mechanism the rest of the app uses (so it respects the configured audio
backend/volume), by:
- Adding a `PlayFile` RPC to `FrontendService` in `frontend.proto`,
following the same pattern as the existing Python-only
`RecordAudio`/`OpenMedia` RPCs (no Rust implementation needed,
`FrontendService` is filtered out of Rust codegen in
`rslib/rust_interface.rs`).
- Implementing `play_file()` in `qt/aqt/mediasrv.py`. It resolves the
path relative to the media folder the same way
`open_media`/`show_in_media_folder` do, then plays it on the main
thread. When the active window is the editor, it uses
`av_player.play_file_with_caller(path, window.editor.editorMode)`
instead of a plain `play_file()`, the same pattern already used by
`open_cards_dialog`/`open_fields_dialog` in the same file. This ties
playback to `Editor.cleanup()`'s existing
`av_player.stop_and_clear_queue_if_caller(self.editorMode)` call, so the
sound is stopped if the editor closes mid-playback.
- Calling the new `playFile()` binding from `attachPath()` in
`TemplateButtons.svelte` right after the media is inserted, only when
the attached file is audio (added an `isAudio()` helper next to
`filenameToLink()`, reusing the existing `audioSuffixes` list).
## Steps to reproduce (required, use N/A if not applicable)
1. Open the Add or Edit dialog for a note.
2. Click the paperclip (attach) button in the field toolbar.
3. Pick an audio file.
4. Notice nothing plays. The `[sound:...]` tag is inserted silently, and
you only hear the audio after saving/reviewing or opening the Cards
preview.
## How to test (required)
### Checklist (minimum)
- [x] I ran `./ninja check` or an equivalent relevant check locally.
- [x] I added or updated tests when the change is non-trivial or
behavior changed.
### Details
Ran `just check` locally, everything passed, including a new vitest
covering `isAudio()` in `data-transfer.test.ts`. Manually verified in
`just run`: opened Add dialog, attached an audio file via the paperclip
button and it plays as soon as it's inserted into the field, before
saving. Also tested the mic recording button (F5), which goes through
the same `attachPath()` function; recording and playback both work as
expected.
## Before / after behavior (optional)
Before: attaching audio via the paperclip inserts the `[sound:...]` tag
silently.
After: the audio plays immediately after being inserted, matching the
old Qt editor's behavior.
## Risk / compatibility / migration (optional)
Low risk. New RPC is additive (no changes to existing `FrontendService`
methods), and playback only triggers for files already classified as
audio/video by the existing suffix list used elsewhere in the same file.
## UI evidence (required for visual changes; otherwise N/A)
N/A, behavior-only change (audio playback), nothing visual to show.
## Scope
- [x] This PR is focused on one change (no unrelated edits).
## Linked issue
Related:
https://github.com/ankitects/anki/pull/5102#issuecomment-4923922894
## Summary / motivation
This adds a new Cargo profile (`ci`) for use in all Rust build commands
on CI. The goal is to reduce unnecessary recompilation of the same
crates in dev/release profiles.
## Steps to reproduce (before)
View the logs of the last CI run on main and notice that some crates are
getting compiled with the `release` profile, e.g. compilation ends with
"Finished `release` profile [optimized]".
## How to test (after)
View the logs of the last CI run in this PR and confirm all Rust
compilation commands end with "Finished `ci` profile [unoptimized]",
indicating that only a single profile is being used.
### Checklist (minimum)
- [x] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or
behavior changed.
## Scope
- [x] This PR is focused on one change (no unrelated edits).
## Linked issue (required)
Closes#5308
### Checklist (minimum)
- [x] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or
behavior changed.
## Scope
- [x] This PR is focused on one change (no unrelated edits).
---------
Co-authored-by: Abdo <abdo@abdnh.net>
Two build fixes found while getting the build working on FreeBSD.
1. **Remove hardcoded `#!/bin/bash` paths and use /usr/bin/env instead
/**
2. **env_remove("YARN_BINARY")**
Tested on FreeBSD 16.0-CURRENT amd64.
A second series adds FreeBSD support with `platform` variants,
environment overrides, PyQt6 handling and docs.
Closes#5301
## Linked issue
Follow-up fixes to #5267
## Summary
#5267 didn't work reliably as can be seen in #5158 due to two issues:
1. The `files` option is not set, which made `any_changed` always
evaluate to `true`.
2. The `*.md` and `*.mdx` patterns only matched root files.
## How to test
Hard to verify locally, but here's a POC written by Claude. Save as
`micromatch-files-ignore-poc.cjs` in the root directory and run using:
`npm install micromatch && node micromatch-files-ignore-poc.cjs`.
<details>
<summary>POC</summary>
```js
// POC: tj-actions/changed-files' `files_ignore`-only config never filters
// anything, because it hands micromatch an array of ONLY negated patterns.
//
// Run with:
// node micromatch-files-ignore-poc.cjs
//
// Requires the `micromatch` package to be resolvable (the version actually
// used by tj-actions/changed-files@v47.0.6 is 4.0.8). If you don't have it
// globally, run this from inside a project that depends on it (e.g. the
// anki repo root), or `npm install micromatch` next to this file first.
const mm = require("micromatch");
function assert(cond, msg) {
if (!cond) {
throw new Error("FAILED: " + msg);
}
console.log("ok - " + msg);
}
// This mirrors ci.yml's `Check for non-documentation changes` step, which
// sets only `files_ignore` (no `files`), and the 14 files actually changed
// in https://github.com/ankitects/anki/pull/5158 ("docs: fix typos").
const changedFiles = [
"docs-site/addons/support.mdx",
"docs-site/developers/development.mdx",
"docs/development.md",
"rslib/backend/lib.rs", // stand-in for a real, non-doc source file
];
// Copied verbatim from .github/workflows/ci.yml's files_ignore list.
const filesIgnoreOnly = [
"!**.md",
"!**.mdx",
"!docs/**/*.png",
"!docs/**/*.svg",
"!docs-site/**/*.png",
"!docs-site/**/*.jpg",
"!docs-site/**/*.svg",
"!docs-site/**/*.mp4",
];
// This is the exact call tj-actions/changed-files makes internally
// (getFilteredChangedFiles in dist/index.js) to decide which changed files
// "count" for the `any_changed` output.
const matchOpts = { dot: true, windows: false, noext: true };
console.log("--- Bug 1: files_ignore with no positive `files` pattern ---");
const filteredIgnoreOnly = mm(changedFiles, filesIgnoreOnly, matchOpts);
console.log("input files: ", changedFiles);
console.log("filesIgnore: ", filesIgnoreOnly);
console.log("matched (kept):", filteredIgnoreOnly);
assert(
filteredIgnoreOnly.length === changedFiles.length,
"an ignore-only pattern array matches EVERY file, including the ones " +
"it was supposed to exclude (docs/*.md, docs-site/*.mdx) -- any_changed " +
"is always 'true'",
);
console.log();
console.log("--- Bug 2: adding a positive base, but keeping bare `**.md` ---");
const filesWithBadGlobstar = [
"**", // positive base pattern, as tj-actions' own README recommends pairing
...filesIgnoreOnly,
];
const filteredBadGlobstar = mm(changedFiles, filesWithBadGlobstar, matchOpts);
console.log("patterns: ", filesWithBadGlobstar);
console.log("matched (kept):", filteredBadGlobstar);
assert(
filteredBadGlobstar.includes("docs/development.md"),
"bare `**.md` (globstar with no following '/') fails to exclude a " +
"nested path once it's negated inside an array match -- " +
"'docs/development.md' incorrectly survives",
);
console.log();
console.log("--- Fix: positive `**` base + `**/*.md` (slash before the star) ---");
const filesFixed = [
"**",
"!**/*.md",
"!**/*.mdx",
"!docs/**/*.png",
"!docs/**/*.svg",
"!docs-site/**/*.png",
"!docs-site/**/*.jpg",
"!docs-site/**/*.svg",
"!docs-site/**/*.mp4",
];
const filteredFixed = mm(changedFiles, filesFixed, matchOpts);
console.log("patterns: ", filesFixed);
console.log("matched (kept):", filteredFixed);
assert(
filteredFixed.length === 1 && filteredFixed[0] === "rslib/backend/lib.rs",
"with a positive `**` base and `**/*.ext` patterns, only the real " +
"non-doc file survives -- any_changed correctly becomes 'false' for " +
"docs-only PRs like #5158",
);
console.log();
console.log("All assertions passed: files_ignore-only config in ci.yml never");
console.log("filters anything, so minilints can never actually be skipped.");
```
</details>
closes#5160
## Summary / motivation (required)
This PR fixes typos, misspelings and (in one place) formatting in files
under `docs-site/` and one file under `docs/`.
I've found these using `codespell` and `typos` CLI utilities (+ spotted
a few myself).
## Scope
- [x] This PR is focused on one change (no unrelated edits).
***
Note: I've installed mdbook following the updated installation steps
(a4d6fb39a7),
but now i face a new error:
```rust
$ mdbook build
2026-07-15 14:48:28 [ERROR] (mdbook::utils): Error: Couldn't open SUMMARY.md in "/home/user/anki-source/docs-site/src" directory
2026-07-15 14:48:28 [ERROR] (mdbook::utils): Caused By: No such file or directory (os error 2)
```
I don't have time right now to figure out what is the difference between
`ankitects/anki-manual` and `ankitects/anki` directory structure and how
to merge them to make the mdbook build, but *I think that my changes are
trivial enough* that no thorough testing is needed.
Co-authored-by: Abdo <abdo@abdnh.net>
closes#5149
`simulate_workload` spawns ~30 threads so might be a little expensive
for a test in that regard. It seems to work ok though.
---------
Co-authored-by: Fernando Lins <fernandolins@users.noreply.github.com>
Closes#5273
## Summary
- Replaces `.github/workflows/prepare-release.yml` with
`.github/scripts/prepare_release.py`, a script run locally by the
maintainer
- Removes the need for the `RELEASE_TOKEN` personal access token
- Script performs the same steps: version validation, CI status check,
duplicate tag/release check, translation sync, and version commit + push
---------
Co-authored-by: Abdo <abdo@abdnh.net>
<!--
Title (for the Pull Request title field at the top):
Use a short prefix so the change type is obvious. You do not need to
repeat it in the body below.
Examples:
- fix: — bugfix
- feat: — feature
- refactor: — internal change without user-facing feature
- docs: — documentation only
- chore: — tooling, CI, deps, build housekeeping
- test: — tests only
-->
## Linked issue (required)
Fixes#5265
<!-- Fixes#123 / Closes#123 / Refs #123 -->
## Summary / motivation (required)
The document only changes are under the CC BY-SA 4 license, therefore no
need to enforce BSD 3 license contribution agreement.
<!-- What this PR does and why. For larger changes, add enough context
for reviewers. -->
## Steps to reproduce (required, use N/A if not applicable)
N/A
<!-- Steps to reproduce: how to trigger the bug in the broken state (the
"before").
- Mainly for bugfixes;
- For bugs: numbered steps before the fix. For non-bugs: write N/A.
- use N/A for features, refactors, docs, chore, etc.
-->
## How to test (required)
End-to-end local GH action:
1. Install the [act](https://github.com/nektos/act) tool to run GH
action locally
2. Install ubuntu container: `podman pull
ghcr.io/catthehacker/ubuntu:act-24.04`
3. Create a clean working tree from this branch: `git checkout -b
test-docs`
4. Add a doc only change: `echo x >> README.md && git add README.md`
5. Commit the changes: `git -c user.email=nobody@example.com commit -m
"docs only"`
6. Run the minilints step: `act -j minilints --container-daemon-socket
unix:///tmp/podman.sock -P
ubuntu-24.04=ghcr.io/catthehacker/ubuntu:act-24.04`
7. The log should contain the "Run minilints" step.
<!--- How to test: how you verified the change (checks, unit tests,
manual steps, edge cases — the "after" or general validation). --->
### Checklist (minimum)
- [x] I ran `./ninja check` or an equivalent relevant check locally.
- [x] I added or updated tests when the change is non-trivial or
behavior changed.
### Details
<!-- Commands, manual steps, edge cases, and what you observed -->
## Before / after behavior (optional)
Before: The documentation only changes trigger the contribution
agreement check.
After: If the changes are documentation only, it workflow skips the
contribution check.
<!-- For bugfixes: behavior before vs after. For other types: N/A or a
short note. -->
## Risk / compatibility / migration (optional)
N/A
<!-- Breaking changes, rollout notes, or N/A for small / low-risk PRs
-->
## UI evidence (required for visual changes; otherwise N/A)
N/A
<!-- Screenshot or short video -->
## Scope
- [x] This PR is focused on one change (no unrelated edits).
## Linked issue
Fixes#5302
## Summary
This updates the VS Code settings:
- Set Ruff as a Python formatter (`python.formatting.provider` is no
longer used).
- Replace references to the old `.bazel` with `out`.
- Remove unused `python.linting.mypyEnabled` (mypy is no longer part of
the base Python extension).
- Add `unifiedjs.vscode-mdx` to recommended extensions for MDX
highlighting.
- Update `rust-analyzer.files.excludeDirs` to
`rust-analyzer.files.exclude`.
- Set `python-envs.workspaceSearchPaths` to help the Python Environments
extension detect `out/pyenv`.
## How to test
Copy .vscode.dist/settings.json to .vscode/
## What
- Change dependabot `interval` from `monthly` to `quarterly` for all
ecosystems (Cargo, npm, Python, GitHub Actions)
- Add separate groups for major bumps (`rust-major`, `npm-major`,
`python-major`) so they arrive as a single grouped PR instead of
individual ones
- Add `semver-major-days: 30` cooldown for major bumps on all versioned
ecosystems
## Why
Version update PRs were accumulating faster than the team could review
them. Two issues were causing this:
1. Minor/patch grouped PRs were opened monthly. Try moving it to
quarterly may match the actual review cadence better
2. Major bumps fell outside the minor/patch group and opened as
individual PRs per package, adding noise
Security updates are unaffected: they still open immediately regardless
of the schedule, as they bypass the interval setting by design.
## Linked issue (required)
Closes#5167
## Motivation
The `.apkg` media uses `os.path.commonprefix`, which compares
characters, not path components. A media filename resolving to a
*sibling* directory that shares the media folder's name prefix passes
the check, so a malicious `.apkg` can write outside `collection.media`
(path traversal / arbitrary file write).
A second commit removes the legacy importer entirely. The legacy import
option was removed in #3536, leaving pylib/anki/importing,
qt/aqt/importing.py and their dialog forms unreachable.
The third commit removes the legacy exporter and adds a realpath check
instead of the commonprefix.
## Steps to reproduce
1. Make an `.apkg` whose media maps a file into a sibling directory
whose name begins with the media folder's name.
2. Import it.
3. Before: written outside `collection.media`. After: "Invalid file".
## Testing performed
Tested by importing .apkg and .csv decks via File > Import on the main
branch and on the PR branch.
Cards import completes without problems.
Also tested a deck and collection export with support to older Anki
versions enabled. It works fine.
## Linked issue
Closes#5293
## Summary / motivation
GitHub API errors were handled by the `From<&reqwest::Error>` impl,
which is intended for AnkiWeb. This adds a basic error handler for
GitHub API requests (showing the raw error messages for most errors).
## Steps to reproduce
The issue was showing when GitHub rate-limits our requests - `403
Forbidden` is returned in that case, and the default error handler
interprets that as an AnkiWeb authentication error and displays "Email
or password was incorrect; please try again.".
See
https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api?apiVersion=2026-03-10#rate-limit-errors
## Linked issue
Noted in
https://github.com/ankitects/anki/pull/5105#pullrequestreview-4861060987
## Summary
RTL languages are tracked in two separate places
(pylib/anki/lang.py:is_rtl and ts/lib/tslib/i18n/utils.ts:direction) but
they are out of sync.
## Steps to reproduce (before)
- Run with Uyghur set as language: `./run -l ug`.
- Open the Deck Options screen and notice the page's direction is
left-to-right.
## How to test (after)
Confirm the Deck Options screen is displayed right-to-left for Uyghur.
### Checklist (minimum)
- [x] I ran `./ninja check` or an equivalent relevant check locally.
- [x] I added or updated tests when the change is non-trivial or
behavior changed.
### Details
A better solution is to keep a single list in the backend or detects the
directionality of the locale name (see [example in
AnkiDroid](eb22e1e6ad/AnkiDroid/src/main/java/com/ichi2/anki/LanguageUtils.kt (L47))).
Both requires moving anki.lang.langs to the backend, which I don't think
is worth the effort.
## UI evidence
### Before
<img width="1406" height="654" alt="image"
src="https://github.com/user-attachments/assets/e9e8aa75-e6b1-4a27-85d5-cfda16c16314"
/>
### After
<img width="1425" height="777" alt="image"
src="https://github.com/user-attachments/assets/48994c54-8209-4c6b-821e-c734c85e96e2"
/>
## Scope
- [x] This PR is focused on one change (no unrelated edits).
## Linked issue (required)
Fixes#5289
## Summary / motivation (required)
Updates the Browse Add-ons link to use the current `/shared/addons` URL
directly instead of the legacy `/shared/addons/2.1` URL, which redirects
to it.
## Steps to reproduce (required, use N/A if not applicable)
1. Open Tools → Add-ons.
2. Click Get Add-ons…
3. Click Browse Add-ons.
4. Observe that `/shared/addons/2.1` is opened and redirects to
`/shared/addons`.
## How to test (required)
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or
behavior changed.
### Details
Manually verified that `/shared/addons/2.1` redirects to
`/shared/addons`,
and that `/shared/addons` loads directly. No local build or automated
checks
were run; the change was made using GitHub's web editor.
## Before / after behavior (optional)
Before: Browse Add-ons opens the legacy `/shared/addons/2.1` URL and
relies
on a redirect.
After: Browse Add-ons opens `/shared/addons` directly.
## Risk / compatibility / migration (optional)
N/A. This is a one-line URL change.
## UI evidence (required for visual changes; otherwise N/A)
N/A
## Scope
- [x] This PR is focused on one change (no unrelated edits).
Anki's build scripts (ninja) set CARGO_TARGET_DIR for their own
execution. But, when running cargo commands directly (like cargo check),
those commands don't inherit that environment variable and use the
default target/ directory instead, leading to duplicate builds.
After this change, all cargo commands (check, build, test, etc.) will
use the same cache, saving storage space.
## Linked issue (required)
Fixes#5215
## Summary / motivation (required)
For a non-regex Find & Replace, the search term is escaped via
`regex::escape` but the replacement was passed through unchanged. The
regex engine interprets `$` in a replacement as a capture-group
reference (e.g. `$1`, `$name`, `${n}`), so a literal replacement such as
`$5` expanded to the (empty) capture group 5, silently dropping the text
instead of inserting `$5`.
The fix escapes `$` to `$$` for non-regex replacements so it is inserted
verbatim.
## Steps to reproduce (required, use N/A if not applicable)
1. Select notes and open Find & Replace with "Treat input as regular
expression" **off**.
2. Replace some text with a literal replacement containing `$`, e.g.
`$5`.
3. Observe the `$5` is dropped/mangled instead of inserted literally.
## How to test (required)
### Checklist (minimum)
- [x] I ran `./ninja check` or an equivalent relevant check locally.
- [x] I added or updated tests when the change is non-trivial or
behavior changed.
### Details
Adds a regression test for literal `$` in non-regex replacements. Full
CI (`check` on Linux/macOS/Windows, `format`, `minilints`) run against
this exact commit on my fork:
https://github.com/krMaynard/anki-fork/actions/runs/30287938613 — all
green except the "Upload SARIF results for complexipy" step on the Linux
job, which fails on every fork run with "Resource not accessible by
integration" (a permissions limitation of running CodeQL uploads outside
the upstream repo, unrelated to this change); the "Build, lint, and
test" step passed on all three `check` jobs.
## Before / after behavior (optional)
Before: `$5` (non-regex) expands to an empty capture group. After: `$5`
is inserted literally.
## Risk / compatibility / migration (optional)
Low risk; only affects non-regex replacement escaping.
## UI evidence (required for visual changes; otherwise N/A)
N/A
## Scope
- [x] This PR is focused on one change (no unrelated edits).
## Linked issue
Closes#5275
## Summary
Inline `bridgeCommand` links are no longer allowed after
a809304e38
Initially I replaced the inline JS with an event listener, but it turned
out CSP might be too intrusive for add-ons adding their bridge command
handlers to Svelte pages, so I decided to limit CSP to the new editor.
## Steps to reproduce (before)
- Open a deck with no due cards.
- Click on the "custom study" and "unbury" links (if shown) in the text
and notice nothing happens.
## How to test (after)
- Confirm the congrats screen's links work.
Open the new editor and try running inline JS, e.g. `<button
onclick="alert('test')">click</button>` and confirm no user script
execution happens.
## Linked issue
Closes#5276
## Summary
Fix broken OK button in help modals (e.g. in the Deck Options screen).
## Steps to reproduce (before)
1. Open the Deck Options screen.
2. Click on any of the "?" icons.
3. Click "OK" and notice the modal is not closed.
## How to test (after)
Confirm the modal is closed.