Compare commits

...

30 commits
main ... show

Author SHA1 Message Date
Damien Elmes
29192d156a Skip ytdl flag on macOS ARM
Fixes sounds failing to play in the packaged build on macOS.

(cherry picked from commit d3d6bd8ce0)
2025-05-15 19:04:39 +10:00
Damien Elmes
7779fb06fc Bump version 2025-05-15 16:58:12 +10:00
Damien Elmes
6213c9b6f9 Disable YouTube DL in mpv
mpv looks for ytdl on the path, which includes the CWD on Windows.
A malicious shared deck could place an executable called yt-dlp.exe in the
media folder, which mpv would then helpfully invoke the first time
a YouTube link was encountered.

A big thank you to Michael Lappas for the report.

(cherry picked from commit 5080451829)
2025-05-15 16:57:38 +10:00
Adnane Taghi
f161c9ce01 Make URL schemes dialog more ergonomic (#4002)
(originally merged into a PR branch)

* Make URL schemes dialog more ergonomic

* add name to contributors list

* Title Case

* Tweak build instructions so Cursor picks them up

* Use a warning icon for the URL scheme pop-up

* Default to cancelling

(cherry picked from commit f96c8c2ac8)
2025-05-15 16:57:09 +10:00
Damien Elmes
172a4e9863 Add URL scheme whitelist (#3994)
* Add experimental Cursor rules

* Add the ability to customize URL schemes

Closes #3965

(cherry picked from commit 86c89907e7)
2025-05-15 16:56:20 +10:00
Kolby Moroz Liebl
f974c61fa3 Fix dockerimage, by bumping rust version (#3993)
(cherry picked from commit 6427ff3db5)
2025-05-15 16:54:57 +10:00
Damien Elmes
a5c33ad07d Bump version 2025-04-24 15:45:33 +10:00
Aristotelis
9387a85f02 Add add-on scripts to editor CSP (#3942)
(cherry picked from commit 79b19a17a3)
2025-04-24 15:30:45 +10:00
Damien Elmes
9cfd8af34e Fix add-on buttons not working in the editor (#3941)
* Fix add-on buttons not working in the editor

* Ensure old listeners are cleaned up

Thanks to iamllama: https://github.com/ankitects/anki/pull/3941#discussion_r2057066283

(cherry picked from commit 1e74e8e86e)
2025-04-24 15:30:41 +10:00
Damien Elmes
e249b92e48 Bump version again 2025-04-23 17:57:28 +10:00
Damien Elmes
9fc1bc86aa Declare dependency on typing_extensions
venv as things like black depended on it. When running in a packaged
build, it wasn't being included, and Anki was failing to start.

I've added it to the anki module instead of aqt, even though only
the latter is currently using it, so that we don't accidentally introduce
the same bug in the future when using typing_extensions from within
libanki.

(cherry picked from commit 72abb7ec5b)
2025-04-23 17:57:28 +10:00
Damien Elmes
cf97c925e2 Don't check collection size on sync to third-party server
Closes #3936

(cherry picked from commit dd0abfc200)
2025-04-23 17:26:15 +10:00
Damien Elmes
25d5cc5508 Bump version 2025-04-23 16:23:56 +10:00
Damien Elmes
bfc87c0427 Use CSP to block inline JS content in editor (#3939)
* Revert "Sanitize field content in editor"

This reverts commit 1c156905f8.

* Use CSP to block inline JS content in editor

This blocks inline scripts, scripts in the media folder, and
handlers like onclick in the editor. This is nicer than the previous
solution - it doesn't make any permanent changes, and leaves other
content like SVGs alone. Thanks to Nil Admirari for the suggestion.

(cherry picked from commit ddb8573e8d)
2025-04-23 16:23:18 +10:00
Aristotelis
e9dfb7a13d Fix AnkiWebPage not being initialized for default web view kinds (e.g. in add-ons) (#3933)
* add AnkiWebView subclasses for stats, empty cards and find dupes ui

* update ui files to use subclassed webviews instead

* remove superfluous calls to AnkiWebView.set_kind

* Avoid set_kind() race condition in legacy stats webview

Replacing the web view is a hacky workaround, but likely a reasonable compromise for a legacy view that we do not want to maintain a separate Qt form for.

* Slightly refactor AnkiWebView subclass creation and tweak inline comment

+ Extend create_ankiwebview_subclass() with the ability to set any
  init time AnkiWebView argument
+ Introduce some nice-to-haves in terms of static type checking support
  and IDE autocompletion
+ Mark helper function as private to discourage add-on use

* Drop `AnkiWebView.set_kind` completely

There no longer is an Anki-internal use case for changing the web view kind after initializing a web view, and add-ons almost certainly do not have any use for it either.

Given that setting the kind after web view construction can lead  to known race conditions with `domDone` signals, we should remove this method to discourage uses like this in both Anki code and add-on consumers.

There currenty only seems to be one add-on calling `set_kind()`, so this seem like a justifiable API change.

---------

Co-authored-by: llama <100429699+iamllama@users.noreply.github.com>
(cherry picked from commit 5b0f371791)
2025-04-23 16:23:02 +10:00
llama
fa1d6eae84 Fix flashing when opening the stats, empty cards or find dupes dialogs (#3928)
* add AnkiWebView subclasses for stats, empty cards and find dupes ui

* update ui files to use subclassed webviews instead

* remove superfluous calls to AnkiWebView.set_kind

* revert impl

* set page background colour after setPage in AnkiWebView.set_kind

(cherry picked from commit a74fd74631)
2025-04-18 00:10:14 +10:00
Damien Elmes
234fa0c2f4 Latest Rust CVEs
(cherry picked from commit 475fdf04a4)
2025-04-17 11:47:23 +10:00
Damien Elmes
f1b67a2005 Update tokio, crossbeam-channel and cargo-deny
(cherry picked from commit ffbc9a77b7)
2025-04-17 11:46:57 +10:00
Damien Elmes
fa3caa472e Silence a warning about ring
https://github.com/ankitects/anki/issues/3081
(cherry picked from commit b75f2798e6)
2025-04-17 11:46:52 +10:00
Damien Elmes
8d9c8c91b5 Check collection size when syncing
Currently we only check the size on a one-way sync, allowing users
to bypass the limits by incrementally syncing a lot of material.
To prevent this:

- The server now checks if the collection is already oversize,
and forces a one-way sync if it is
- The client checks if the local collection is oversize and refuses
to proceed, so they don't waste time uploading material that will
likely trigger the limit the next time they sync.

(cherry picked from commit 9b5da546be)
2025-04-17 11:42:54 +10:00
Damien Elmes
218757ca46 Update to Rust 1.85
Edition update to follow later

(cherry picked from commit 2727cf39b2)
2025-04-17 11:41:33 +10:00
Damien Elmes
832a1c2c3e Update n2 [action required]
Make sure to run tools/install-n2 after updating to this commit.
n2 have merged in some changes we were previously hosting in a fork,
but the parsing of the flags was altered.

(cherry picked from commit 8e13e6bfc1)
2025-04-17 11:41:11 +10:00
Damien Elmes
670c098af2 Update n2
The flickering on Windows has been improved

(cherry picked from commit 7f8420255d)
2025-04-17 11:40:48 +10:00
Damien Elmes
3f9f3b248e Avoid sending API key for remote resources
Thanks to Abdo for the report

(cherry picked from commit fbb4cf6124)
2025-04-17 11:19:17 +10:00
Damien Elmes
097f9bd138 Add some missed pages/endpoints (thanks to iamllama)
(cherry picked from commit b02111bb2c)
2025-04-17 11:19:11 +10:00
Aristotelis P
269fb073e9 Inject bridge script when profile set-up skipped
Some add-ons fully override AnkiWebPage.__init__ and thus depend on _setupBridge injecting the JS bridge script.

With this change we account for these cases, while giving add-ons the opportunity to look for solutions that do not require overriding AnkiWebPage.__init__ completely.

(cherry picked from commit 2a97b135ee)
2025-04-17 11:19:07 +10:00
Aristotelis P
0467f717ad Provide AnkiWebPage init defaults for existing add-on callers
(cherry picked from commit fd2c95a83e)
2025-04-17 11:18:59 +10:00
Damien Elmes
2fc6b72460 Require an auth token for API access
We were previously inspecting the referrer, but that is spoofable,
and doesn't guard against other processes on the machine.

To accomplish this, we use a request interceptor to automatically
add an auth token to webviews with the right context. Some related
changes were required:

- We avoid storing _page, which was leading to leaks & warning on exit
- At webview creation (or set_kind() invocation), we assign either
an authenticated or unauthenticated web profile.
- Some of our screens initialize the AnkiWebView when calling, e.g.,
aqt.forms.stats.Ui_Dialog(). They then immediately call .set_kind().
This reveals a race condition in our DOM handling code: the webview
initialization creates an empty page with the injected script, which
causes a domDone signal to be sent back. This signal arrives after
we've created another page with .set_kind(), causing our code to think
the DOM is ready when it's not. Then when we try to inject the dynamic
styling, we get an error, as the DOM is not ready yet. In the absence
of better solutions, I've added a hack to set_kind() to deal with this
for now.

(cherry picked from commit 24bca15fd3)
2025-04-17 11:17:47 +10:00
Damien Elmes
82f3778340 Sanitize field content in editor
The editor already strips script tags from fields, but was allowing
through Javascript in things like onclick handlers. We block this now,
as the editor context has access to internal APIs that we don't want to
expose to untrusted third-party code.

(cherry picked from commit 1c156905f8)
2025-04-17 11:16:57 +10:00
Damien Elmes
4bb1698b75 Bump version 2025-04-17 11:16:26 +10:00
56 changed files with 544 additions and 237 deletions

View file

@ -0,0 +1,2 @@
- To build and check the project, use ./check in the root folder (or check.bat on Windows)
- This will format files, then run lints and unit tests.

7
.cursor/rules/i18n.md Normal file
View file

@ -0,0 +1,7 @@
- We use the fluent system+code generation for translation.
- New strings should be added to rslib/core/. Ask for the appropriate file if you're not sure.
- Assuming a string addons-you-have-count has been added to addons.ftl, that string is accessible in our different languages as follows:
- Python: from aqt.utils import tr; msg = tr.addons_you_have_count(count=3)
- TypeScript: import * as tr from "@generated/ftl"; tr.addonsYouHaveCount({count: 3})
- Rust: collection.tr.addons_you_have_count(3)
- In Qt .ui files, strings that are marked as translatable will automatically use the registered ftl strings. So a QLabel with a title 'addons_you_have_count' that is marked as translatable will automatically use the translation defined in our addons.ftl file.

View file

@ -5,8 +5,11 @@
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/rustsec/advisory-db"]
ignore = [
# gix only used by burn-train
"RUSTSEC-2024-0350",
# pyoxidizer is stuck on an old ring version
"RUSTSEC-2025-0009",
"RUSTSEC-2025-0010",
# burn depends on an unmaintained package 'paste'
"RUSTSEC-2024-0436",
]
[licenses]

View file

@ -1 +1 @@
25.02
25.02.5

View file

@ -213,6 +213,21 @@ wackbyte <wackbyte@protonmail.com>
GithubAnon0000 <GithubAnon0000@users.noreply.github.com>
Mike Hardy <github@mikehardy.net>
Danika_Dakika <https://github.com/Danika-Dakika>
Mumtaz Hajjo Alrifai <mumtazrifai@protonmail.com>
Thomas Graves <fate@hey.com>
Jakub Fidler <jakub.fidler@protonmail.com>
Valerie Enfys <val@unidentified.systems>
Julien Chol <https://github.com/chel-ou>
ikkz <ylei.mk@gmail.com>
derivativeoflog7 <https://github.com/derivativeoflog7>
rreemmii-dev <https://github.com/rreemmii-dev>
babofitos <https://github.com/babofitos>
Jonathan Schoreels <https://github.com/JSchoreels>
JL710
Matt Brubeck <mbrubeck@limpet.net>
Yaoliang Chen <yaoliang.ch@gmail.com>
KolbyML <https://github.com/KolbyML>
Adnane Taghi <dev@soleuniverse.me>
********************

46
Cargo.lock generated
View file

@ -1288,9 +1288,9 @@ dependencies = [
[[package]]
name = "crossbeam-channel"
version = "0.5.13"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
@ -3377,7 +3377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
dependencies = [
"cfg-if",
"windows-targets 0.52.6",
"windows-targets 0.48.5",
]
[[package]]
@ -4166,9 +4166,9 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.70"
version = "0.10.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6"
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
dependencies = [
"bitflags 2.6.0",
"cfg-if",
@ -4198,9 +4198,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.105"
version = "0.9.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc"
checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07"
dependencies = [
"cc",
"libc",
@ -4768,9 +4768,9 @@ dependencies = [
[[package]]
name = "pyo3"
version = "0.23.4"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57fe09249128b3173d092de9523eaa75136bf7ba85e0d69eca241c7939c933cc"
checksum = "17da310086b068fbdcefbba30aeb3721d5bb9af8db4987d6735b2183ca567229"
dependencies = [
"cfg-if",
"indoc",
@ -4786,9 +4786,9 @@ dependencies = [
[[package]]
name = "pyo3-build-config"
version = "0.23.4"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cd3927b5a78757a0d71aa9dff669f903b1eb64b54142a9bd9f757f8fde65fd7"
checksum = "e27165889bd793000a098bb966adc4300c312497ea25cf7a690a9f0ac5aa5fc1"
dependencies = [
"once_cell",
"target-lexicon",
@ -4796,9 +4796,9 @@ dependencies = [
[[package]]
name = "pyo3-ffi"
version = "0.23.4"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab6bb2102bd8f991e7749f130a70d05dd557613e39ed2deeee8e9ca0c4d548d"
checksum = "05280526e1dbf6b420062f3ef228b78c0c54ba94e157f5cb724a609d0f2faabc"
dependencies = [
"libc",
"pyo3-build-config",
@ -4806,9 +4806,9 @@ dependencies = [
[[package]]
name = "pyo3-macros"
version = "0.23.4"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91871864b353fd5ffcb3f91f2f703a22a9797c91b9ab497b1acac7b07ae509c7"
checksum = "5c3ce5686aa4d3f63359a5100c62a127c9f15e8398e5fdeb5deef1fed5cd5f44"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
@ -4818,9 +4818,9 @@ dependencies = [
[[package]]
name = "pyo3-macros-backend"
version = "0.23.4"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43abc3b80bc20f3facd86cd3c60beed58c3e2aa26213f3cda368de39c60a27e4"
checksum = "f4cf6faa0cbfb0ed08e89beb8103ae9724eb4750e3a78084ba4017cbe94f3855"
dependencies = [
"heck",
"proc-macro2",
@ -6016,9 +6016,9 @@ dependencies = [
[[package]]
name = "target-lexicon"
version = "0.12.16"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a"
[[package]]
name = "tempfile"
@ -6202,9 +6202,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.40.0"
version = "1.44.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998"
checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48"
dependencies = [
"backtrace",
"bytes",
@ -6219,9 +6219,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.4.0"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",

View file

@ -110,7 +110,7 @@ prost-build = "0.13"
prost-reflect = "0.14"
prost-types = "0.13"
pulldown-cmark = "0.9.6"
pyo3 = { version = "0.23.4", features = ["extension-module", "abi3", "abi3-py39"] }
pyo3 = { version = "0.24", features = ["extension-module", "abi3", "abi3-py39"] }
rand = "0.8.5"
regex = "1.11.0"
reqwest = { version = "0.12.8", default-features = false, features = ["json", "socks", "stream", "multipart"] }

View file

@ -125,7 +125,7 @@ impl BuildAction for GenPythonProto {
build.add_outputs("", python_outputs);
}
fn hide_last_line(&self) -> bool {
fn hide_progress(&self) -> bool {
true
}
}

View file

@ -44,7 +44,7 @@ pub trait BuildAction {
true
}
fn hide_last_line(&self) -> bool {
fn hide_progress(&self) -> bool {
false
}

View file

@ -271,14 +271,14 @@ impl BuildStatement<'_> {
stmt.rule_variables.push(("pool".into(), pool.into()));
}
if have_n2 {
stmt.rule_variables.push((
"hide_success".into(),
(action.hide_success() as u8).to_string(),
));
stmt.rule_variables.push((
"hide_last_line".into(),
(action.hide_last_line() as u8).to_string(),
));
if action.hide_success() {
stmt.rule_variables
.push(("hide_success".into(), "1".into()));
}
if action.hide_progress() {
stmt.rule_variables
.push(("hide_progress".into(), "1".into()));
}
}
stmt

View file

@ -252,7 +252,7 @@ impl BuildAction for SvelteCheck {
build.add_output_stamp(format!("tests/svelte-check.{hash}"));
}
fn hide_last_line(&self) -> bool {
fn hide_progress(&self) -> bool {
true
}
}

View file

@ -146,7 +146,7 @@ impl BuildAction for PythonTypecheck {
build.add_output_stamp(format!("tests/python_typecheck.{hash}"));
}
fn hide_last_line(&self) -> bool {
fn hide_progress(&self) -> bool {
true
}
}
@ -259,7 +259,7 @@ impl BuildAction for PythonTest {
build.add_output_stamp(format!("tests/python_pytest.{hash}"));
}
fn hide_last_line(&self) -> bool {
fn hide_progress(&self) -> bool {
true
}
}

View file

@ -757,7 +757,7 @@
},
{
"name": "crossbeam-channel",
"version": "0.5.13",
"version": "0.5.15",
"authors": null,
"repository": "https://github.com/crossbeam-rs/crossbeam",
"license": "Apache-2.0 OR MIT",
@ -2665,7 +2665,7 @@
},
{
"name": "openssl",
"version": "0.10.70",
"version": "0.10.72",
"authors": "Steven Fackler <sfackler@gmail.com>",
"repository": "https://github.com/sfackler/rust-openssl",
"license": "Apache-2.0",
@ -2692,7 +2692,7 @@
},
{
"name": "openssl-sys",
"version": "0.9.105",
"version": "0.9.107",
"authors": "Alex Crichton <alex@alexcrichton.com>|Steven Fackler <sfackler@gmail.com>",
"repository": "https://github.com/sfackler/rust-openssl",
"license": "MIT",
@ -4087,7 +4087,7 @@
},
{
"name": "tokio",
"version": "1.40.0",
"version": "1.44.2",
"authors": "Tokio Contributors <team@tokio.rs>",
"repository": "https://github.com/tokio-rs/tokio",
"license": "MIT",
@ -4096,7 +4096,7 @@
},
{
"name": "tokio-macros",
"version": "2.4.0",
"version": "2.5.0",
"authors": "Tokio Contributors <team@tokio.rs>",
"repository": "https://github.com/tokio-rs/tokio",
"license": "MIT",

View file

@ -1,4 +1,4 @@
FROM rust:1.83.0-alpine3.20 AS builder
FROM rust:1.85.0-alpine3.20 AS builder
ARG ANKI_VERSION

View file

@ -1,4 +1,4 @@
FROM rust:1.83.0 AS builder
FROM rust:1.85.0 AS builder
ARG ANKI_VERSION

View file

@ -83,6 +83,15 @@ preferences-ankiweb-intro = AnkiWeb is a free service that lets you keep your fl
preferences-ankihub-intro = AnkiHub provides collaborative deck editing and additional study tools. A paid subscription is required to access certain features.
preferences-third-party-description = Third-party services are unaffiliated with and not endorsed by Anki. Use of these services may require payment.
## URL scheme related
preferences-url-schemes = URL Schemes
preferences-url-scheme-prompt = Allowed { preferences-url-schemes } (space-separated):
preferences-url-scheme-warning = Blocked attempt to open `{ $link }`, which may be a security issue.
If you trust the deck author and wish to proceed, you can add `{ $scheme }` to your allowed { preferences-url-schemes }.
preferences-url-scheme-allow-once = Allow Once
preferences-url-scheme-always-allow = Always Allow
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.
preferences-basic = Basic

View file

@ -6,3 +6,4 @@ protobuf>=4.21
requests[socks]
distro; sys_platform != "darwin" and sys_platform != "win32"
psutil; sys_platform == "win32"
typing_extensions

View file

@ -382,6 +382,10 @@ tomli==2.0.1 \
# via
# build
# pip-tools
typing-extensions==4.13.2 \
--hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \
--hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef
# via -r requirements.anki.in
urllib3==2.2.2 \
--hash=sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472 \
--hash=sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168

View file

@ -597,6 +597,7 @@ typing-extensions==4.12.2 \
--hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \
--hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8
# via
# -r requirements.anki.in
# astroid
# black
# fluent-syntax

View file

@ -14,7 +14,6 @@ from anki.collection import SearchNode
from anki.notes import NoteId
from aqt.qt import *
from aqt.qt import sip
from aqt.webview import AnkiWebViewKind
from ..operations import QueryOp
from ..operations.tag import add_tags_to_notes
@ -52,7 +51,6 @@ class FindDuplicatesDialog(QDialog):
self._dupes: list[tuple[str, list[NoteId]]] = []
# links
form.webView.set_kind(AnkiWebViewKind.FIND_DUPLICATES)
form.webView.set_bridge_command(self._on_duplicate_clicked, context=self)
form.webView.stdHtml("", context=self)

View file

@ -320,7 +320,6 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
label_element = cmd
title_attribute = shortcut(title_attribute)
cmd_to_toggle_button = "toggleEditorButton(this);" if toggleable else ""
id_attribute_assignment = f"id={id}" if id else ""
class_attribute = "linkb" if rightside else "rounded"
if not disables:
@ -328,11 +327,11 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
return f"""<button tabindex=-1
{id_attribute_assignment}
class="{class_attribute}"
class="anki-addon-button {class_attribute}"
type="button"
title="{title_attribute}"
onclick="pycmd('{cmd}');{cmd_to_toggle_button}return false;"
onmousedown="window.event.preventDefault();"
data-cantoggle="{int(toggleable)}"
data-command="{cmd}"
>
{image_element}
{label_element}

View file

@ -15,7 +15,6 @@ from anki.collection import EmptyCardsReport
from aqt import gui_hooks
from aqt.qt import QDialog, QDialogButtonBox, qconnect
from aqt.utils import disable_help_button, restoreGeom, saveGeom, tooltip, tr
from aqt.webview import AnkiWebViewKind
def show_empty_cards(mw: aqt.main.AnkiQt) -> None:
@ -47,7 +46,6 @@ class EmptyCardsDialog(QDialog):
self.setWindowTitle(tr.empty_cards_window_title())
disable_help_button(self)
self.form.keep_notes.setText(tr.empty_cards_preserve_notes_checkbox())
self.form.webview.set_kind(AnkiWebViewKind.EMPTY_CARDS)
self.form.webview.set_bridge_command(self._on_note_link_clicked, self)
gui_hooks.empty_cards_will_show(self)

View file

@ -30,7 +30,7 @@
<number>0</number>
</property>
<item>
<widget class="AnkiWebView" name="webview" native="true">
<widget class="EmptyCardsWebView" name="webview" native="true">
<property name="url" stdset="0">
<url>
<string notr="true">about:blank</string>
@ -81,7 +81,7 @@
</widget>
<customwidgets>
<customwidget>
<class>AnkiWebView</class>
<class>EmptyCardsWebView</class>
<extends>QWidget</extends>
<header location="global">aqt/webview</header>
<container>1</container>

View file

@ -73,7 +73,7 @@
<number>0</number>
</property>
<item>
<widget class="AnkiWebView" name="webView" native="true">
<widget class="FindDupesWebView" name="webView" native="true">
<property name="url" stdset="0">
<url>
<string notr="true">about:blank</string>
@ -98,7 +98,7 @@
</widget>
<customwidgets>
<customwidget>
<class>AnkiWebView</class>
<class>FindDupesWebView</class>
<extends>QWidget</extends>
<header location="global">aqt/webview</header>
<container>1</container>

View file

@ -17,7 +17,7 @@
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="focusPolicy">
<enum>Qt::StrongFocus</enum>
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
<property name="currentIndex">
<number>0</number>
@ -78,7 +78,7 @@
</sizepolicy>
</property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>
<enum>QComboBox::SizeAdjustPolicy::AdjustToMinimumContentsLengthWithIcon</enum>
</property>
</widget>
</item>
@ -260,7 +260,7 @@
<item>
<spacer name="verticalSpacer_9">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -451,6 +451,13 @@
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="url_schemes">
<property name="text">
<string>preferences_url_schemes</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
@ -466,7 +473,7 @@
<item>
<spacer name="verticalSpacer_12">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -518,10 +525,10 @@
<item>
<spacer name="verticalSpacer_7">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
<enum>QSizePolicy::Policy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -614,10 +621,10 @@
<item>
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Expanding</enum>
<enum>QSizePolicy::Policy::Expanding</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -739,7 +746,7 @@
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -827,7 +834,7 @@
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -840,7 +847,7 @@
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -918,10 +925,10 @@
<item>
<spacer name="verticalSpacer_5">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
<enum>QSizePolicy::Policy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -953,7 +960,7 @@
<item row="1" column="3">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -1020,7 +1027,7 @@
<item row="1" column="1">
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -1035,10 +1042,10 @@
<item>
<spacer name="verticalSpacer_6">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
<enum>QSizePolicy::Policy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -1080,7 +1087,7 @@
<item>
<spacer name="verticalSpacer_4">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -1128,10 +1135,10 @@
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Maximum</enum>
<enum>QSizePolicy::Policy::Maximum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -1207,7 +1214,7 @@
<item>
<spacer name="verticalspacer_13">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@ -1227,17 +1234,17 @@
<string>preferences_some_settings_will_take_effect_after</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Close|QDialogButtonBox::Help</set>
<set>QDialogButtonBox::StandardButton::Close|QDialogButtonBox::StandardButton::Help</set>
</property>
</widget>
</item>
@ -1266,6 +1273,7 @@
<tabstop>showEstimates</tabstop>
<tabstop>spacebar_rates_card</tabstop>
<tabstop>render_latex</tabstop>
<tabstop>url_schemes</tabstop>
<tabstop>pastePNG</tabstop>
<tabstop>paste_strips_formatting</tabstop>
<tabstop>useCurrent</tabstop>

View file

@ -30,7 +30,7 @@
<number>0</number>
</property>
<item>
<widget class="AnkiWebView" name="web" native="true">
<widget class="StatsWebView" name="web" native="true">
<property name="url" stdset="0">
<url>
<string notr="true">about:blank</string>
@ -146,7 +146,7 @@
</widget>
<customwidgets>
<customwidget>
<class>AnkiWebView</class>
<class>StatsWebView</class>
<extends>QWidget</extends>
<header location="global">aqt/webview</header>
<container>1</container>

View file

@ -7,7 +7,9 @@ import enum
import logging
import mimetypes
import os
import random
import re
import string
import sys
import threading
import traceback
@ -139,14 +141,17 @@ class MediaServer(threading.Thread):
) -> None:
self._legacy_pages[id] = LegacyPage(html, context)
def get_page(self, id: int) -> LegacyPage | None:
return self._legacy_pages.get(id)
def get_page_html(self, id: int) -> str | None:
if page := self._legacy_pages.get(id):
if page := self.get_page(id):
return page.html
else:
return None
def get_page_context(self, id: int) -> PageContext | None:
if page := self._legacy_pages.get(id):
if page := self.get_page(id):
return page.context
else:
return None
@ -698,7 +703,6 @@ def _extract_collection_post_request(path: str) -> DynamicRequest | NotFound:
def _check_dynamic_request_permissions():
if request.method == "GET":
return
context = _extract_page_context()
def warn() -> None:
show_warning(
@ -710,24 +714,17 @@ def _check_dynamic_request_permissions():
aqt.mw.taskman.run_on_main(warn)
abort(403)
if context in [
PageContext.NON_LEGACY_PAGE,
PageContext.EDITOR,
PageContext.ADDON_PAGE,
PageContext.DECK_OPTIONS,
]:
pass
elif context == PageContext.REVIEWER and request.path in (
# does page have access to entire API?
if _have_api_access():
return
# whitelisted API endpoints for reviewer/previewer
if request.path in (
"/_anki/getSchedulingStatesWithContext",
"/_anki/setSchedulingStates",
"/_anki/i18nResources",
"/_anki/congratsInfo",
):
# reviewer is only allowed to access custom study methods
pass
elif (
context == PageContext.PREVIEWER or context == PageContext.CARD_LAYOUT
) and request.path == "/_anki/i18nResources":
# previewers are only allowed to access i18n resources
pass
else:
# other legacy pages may contain third-party JS, so we do not
@ -746,29 +743,30 @@ def _handle_dynamic_request(req: DynamicRequest) -> Response:
def legacy_page_data() -> Response:
id = int(request.args["id"])
if html := aqt.mw.mediaServer.get_page_html(id):
return Response(html, mimetype="text/html")
page = aqt.mw.mediaServer.get_page(id)
if page:
response = Response(page.html, mimetype="text/html")
# Prevent JS in field content from being executed in the editor, as it would
# have access to our internal API, and is a security risk.
if page.context == PageContext.EDITOR:
port = aqt.mw.mediaServer.getPort()
csp_paths = (
f"http://127.0.0.1:{port}/_anki/",
f"http://127.0.0.1:{port}/_addons/",
)
response.headers["Content-Security-Policy"] = (
f"script-src {' '.join(csp_paths)}"
)
return response
else:
return _text_response(HTTPStatus.NOT_FOUND, "page not found")
def _extract_page_context() -> PageContext:
"Get context based on referer header."
from urllib.parse import parse_qs, urlparse
_APIKEY = "".join(random.choices(string.ascii_letters + string.digits, k=32))
referer = urlparse(request.headers.get("Referer", ""))
if referer.path.startswith("/_anki/pages/") or is_sveltekit_page(referer.path[1:]):
return PageContext.NON_LEGACY_PAGE
elif referer.path == "/_anki/legacyPageData":
query_params = parse_qs(referer.query)
query_id = query_params.get("id")
if not query_id:
return PageContext.UNKNOWN
id = int(query_id[0])
page_context = aqt.mw.mediaServer.get_page_context(id)
return page_context if page_context else PageContext.UNKNOWN
else:
return PageContext.UNKNOWN
def _have_api_access() -> bool:
return request.headers.get("Authorization") == f"Bearer {_APIKEY}"
# this currently only handles a single method; in the future, idempotent

View file

@ -30,6 +30,7 @@ from __future__ import annotations
import inspect
import json
import os
import platform
import select
import socket
import subprocess
@ -40,7 +41,7 @@ import time
from queue import Empty, Full, Queue
from shutil import which
from anki.utils import is_win
from anki.utils import is_mac, is_win
class MPVError(Exception):
@ -92,6 +93,9 @@ class MPVBase:
if is_win:
default_argv += ["--af-add=lavfi=[apad=pad_dur=0.150]"]
if not is_mac or platform.machine() != "arm64":
# our arm64 mpv build doesn't support this option (compiled out)
default_argv += ["--no-ytdl"]
def __init__(self, window_id=None, debug=False):
self.window_id = window_id

View file

@ -20,8 +20,10 @@ from aqt.profiles import VideoDriver
from aqt.qt import *
from aqt.sync import sync_login
from aqt.theme import Theme
from aqt.url_schemes import show_url_schemes_dialog
from aqt.utils import (
HelpPage,
add_ellipsis_to_action_label,
askUser,
disable_help_button,
is_win,
@ -150,6 +152,9 @@ class Preferences(QDialog):
form.monthly_backups.setValue(self.prefs.backups.monthly)
form.minutes_between_backups.setValue(self.prefs.backups.minimum_interval_mins)
add_ellipsis_to_action_label(self.form.url_schemes)
qconnect(self.form.url_schemes.clicked, show_url_schemes_dialog)
def update_collection(self, on_done: Callable[[], None]) -> None:
form = self.form

View file

@ -736,3 +736,17 @@ create table if not exists profiles
def ankihub_username(self) -> str | None:
return self.profile.get("thirdPartyAnkiHubUsername")
def allowed_url_schemes(self) -> list[str]:
return self.profile.get("allowedUrlSchemes", [])
def set_allowed_url_schemes(self, schemes: list[str]) -> None:
self.profile["allowedUrlSchemes"] = schemes
def always_allow_scheme(self, scheme: str) -> None:
schemes = self.allowed_url_schemes()
if scheme not in schemes:
schemes.append(scheme)
self.set_allowed_url_schemes(schemes)

View file

@ -141,7 +141,7 @@ class AVPlayer:
# audio be stopped?
interrupt_current_audio = True
# caller key for the current playback (optional)
current_caller = None
current_caller: Any = None
def __init__(self) -> None:
self._enqueued: list[AVTag] = []
@ -165,7 +165,7 @@ class AVPlayer:
self._enqueued = []
self._stop_if_playing()
def stop_and_clear_queue_if_caller(self, caller) -> None:
def stop_and_clear_queue_if_caller(self, caller: Any) -> None:
if caller == self.current_caller:
self.stop_and_clear_queue()
@ -177,7 +177,7 @@ class AVPlayer:
def play_file(self, filename: str) -> None:
self.play_tags([SoundOrVideoTag(filename=filename)])
def play_file_with_caller(self, filename: str, caller) -> None:
def play_file_with_caller(self, filename: str, caller: Any) -> None:
self.current_caller = caller
self.play_file(filename)
@ -388,6 +388,7 @@ class SimpleMpvPlayer(SimpleProcessPlayer, VideoPlayer):
"--keep-open=no",
"--input-media-keys=no",
"--autoload-files=no",
"--no-ytdl",
]
)

View file

@ -25,7 +25,7 @@ from aqt.utils import (
tooltip,
tr,
)
from aqt.webview import AnkiWebViewKind
from aqt.webview import LegacyStatsWebView
class NewDeckStats(QDialog):
@ -71,7 +71,6 @@ class NewDeckStats(QDialog):
maybeHideClose(self.form.buttonBox)
addCloseShortcut(self)
gui_hooks.stats_dialog_will_show(self)
self.form.web.set_kind(AnkiWebViewKind.DECK_STATS)
self.form.web.hide_while_preserving_layout()
self.show()
self.refresh()
@ -154,6 +153,9 @@ class DeckStats(QDialog):
self.name = "deckStats"
self.period = 0
self.form = aqt.forms.stats.Ui_Dialog()
# Hack: Switch out web views dynamically to avoid maintaining multiple
# Qt forms for different versions of the stats dialog.
self.form.web = LegacyStatsWebView(self.mw)
self.oldPos = None
self.wholeCollection = False
self.setMinimumWidth(700)
@ -232,7 +234,6 @@ class DeckStats(QDialog):
stats = self.mw.col.stats()
stats.wholeCollection = self.wholeCollection
self.report = stats.report(type=self.period)
self.form.web.set_kind(AnkiWebViewKind.LEGACY_DECK_STATS)
self.form.web.stdHtml(
f"<html><body>{self.report}</body></html>",
js=["js/vendor/jquery.min.js", "js/vendor/plot.js"],

71
qt/aqt/url_schemes.py Normal file
View file

@ -0,0 +1,71 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from markdown import markdown
from aqt.qt import QMessageBox, Qt, QUrl
from aqt.utils import MessageBox, getText, openLink, tr
def show_url_schemes_dialog() -> None:
from aqt import mw
default = " ".join(mw.pm.allowed_url_schemes())
schemes, ok = getText(
prompt=tr.preferences_url_scheme_prompt(),
title=tr.preferences_url_schemes(),
default=default,
)
if ok:
mw.pm.set_allowed_url_schemes(schemes.split(" "))
mw.pm.save()
def is_supported_scheme(url: QUrl) -> bool:
from aqt import mw
scheme = url.scheme().lower()
allowed_schemes = mw.pm.allowed_url_schemes()
return scheme in allowed_schemes or scheme in ["http", "https"]
def always_allow_scheme(url: QUrl) -> None:
from aqt import mw
scheme = url.scheme().lower()
mw.pm.always_allow_scheme(scheme)
def open_url_if_supported_scheme(url: QUrl) -> None:
from aqt import mw
if is_supported_scheme(url):
openLink(url)
else:
def on_button(idx: int) -> None:
if idx == 0:
openLink(url)
elif idx == 1:
always_allow_scheme(url)
openLink(url)
msg = markdown(
tr.preferences_url_scheme_warning(link=url.toString(), scheme=url.scheme())
)
MessageBox(
msg,
buttons=[
tr.preferences_url_scheme_allow_once(),
tr.preferences_url_scheme_always_allow(),
(tr.actions_cancel(), QMessageBox.ButtonRole.RejectRole),
],
parent=mw,
callback=on_button,
textFormat=Qt.TextFormat.RichText,
default_button=2,
icon=QMessageBox.Icon.Warning,
)

View file

@ -1193,7 +1193,7 @@ def disallow_full_screen() -> bool:
)
def add_ellipsis_to_action_label(*actions: QAction) -> None:
def add_ellipsis_to_action_label(*actions: QAction | QPushButton) -> None:
"""Pass actions to add '...' to their labels, indicating that more input is
required before they can be performed.

View file

@ -10,7 +10,9 @@ import re
import sys
from collections.abc import Callable, Sequence
from enum import Enum
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, Type, cast
from typing_extensions import TypedDict, Unpack
import anki
import anki.lang
@ -28,36 +30,52 @@ serverbaseurl = re.compile(r"^.+:\/\/[^\/]+")
if TYPE_CHECKING:
from aqt.mediasrv import PageContext
# Page for debug messages
##########################################################################
BridgeCommandHandler = Callable[[str], Any]
class AnkiWebPage(QWebEnginePage):
def __init__(self, onBridgeCmd: BridgeCommandHandler) -> None:
QWebEnginePage.__init__(self)
self._onBridgeCmd = onBridgeCmd
self._setupBridge()
self.open_links_externally = True
class AnkiWebViewKind(Enum):
"""Enum registry of all web views managed by Anki
def _setupBridge(self) -> None:
class Bridge(QObject):
def __init__(self, bridge_handler: Callable[[str], Any]) -> None:
super().__init__()
self.onCmd = bridge_handler
The value of each entry corresponds to the web view's title.
@pyqtSlot(str, result=str) # type: ignore
def cmd(self, str: str) -> Any:
return json.dumps(self.onCmd(str))
When introducing a new web view, please add it to the registry below.
"""
self._bridge = Bridge(self._onCmd)
DEFAULT = "default"
MAIN = "main webview"
TOP_TOOLBAR = "top toolbar"
BOTTOM_TOOLBAR = "bottom toolbar"
DECK_OPTIONS = "deck options"
EDITOR = "editor"
LEGACY_DECK_STATS = "legacy deck stats"
DECK_STATS = "deck stats"
PREVIEWER = "previewer"
CHANGE_NOTETYPE = "change notetype"
CARD_LAYOUT = "card layout"
BROWSER_CARD_INFO = "browser card info"
IMPORT_CSV = "csv import"
EMPTY_CARDS = "empty cards"
FIND_DUPLICATES = "find duplicates"
FIELDS = "fields"
IMPORT_LOG = "import log"
IMPORT_ANKI_PACKAGE = "anki package import"
self._channel = QWebChannel(self)
self._channel.registerObject("py", self._bridge)
self.setWebChannel(self._channel)
class AuthInterceptor(QWebEngineUrlRequestInterceptor):
_api_enabled = False
def __init__(self, parent: QObject | None = None, api_enabled: bool = False):
super().__init__(parent)
self._api_enabled = api_enabled
def interceptRequest(self, info):
from aqt.mediasrv import _APIKEY
if self._api_enabled and info.requestUrl().host() == "127.0.0.1":
info.setHttpHeader(b"Authorization", f"Bearer {_APIKEY}".encode("utf-8"))
def _create_bridge_script() -> QWebEngineScript:
qwebchannel = ":/qtwebchannel/qwebchannel.js"
jsfile = QFile(qwebchannel)
if not jsfile.open(QIODevice.OpenModeFlag.ReadOnly):
@ -90,10 +108,96 @@ class AnkiWebPage(QWebEnginePage):
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
script.setRunsOnSubFrames(False)
return script
_bridge_script = _create_bridge_script()
_profile_with_api_access: QWebEngineProfile | None = None
_profile_without_api_access: QWebEngineProfile | None = None
class AnkiWebPage(QWebEnginePage):
def __init__(
self,
onBridgeCmd: BridgeCommandHandler,
kind: AnkiWebViewKind = AnkiWebViewKind.DEFAULT,
parent: QObject | None = None,
) -> None:
profile = self._profileForPage(kind)
self._inject_user_script(profile, _bridge_script)
QWebEnginePage.__init__(self, profile, parent)
self._onBridgeCmd = onBridgeCmd
self._kind = kind
self._setupBridge()
self.open_links_externally = True
def _profileForPage(self, kind: AnkiWebViewKind) -> QWebEngineProfile:
have_api_access = kind in (
AnkiWebViewKind.DECK_OPTIONS,
AnkiWebViewKind.EDITOR,
AnkiWebViewKind.DECK_STATS,
AnkiWebViewKind.CHANGE_NOTETYPE,
AnkiWebViewKind.BROWSER_CARD_INFO,
AnkiWebViewKind.IMPORT_ANKI_PACKAGE,
AnkiWebViewKind.IMPORT_CSV,
AnkiWebViewKind.IMPORT_LOG,
)
global _profile_with_api_access, _profile_without_api_access
# Use cached profile if available
if have_api_access and _profile_with_api_access is not None:
return _profile_with_api_access
elif not have_api_access and _profile_without_api_access is not None:
return _profile_without_api_access
# Create a new profile if not cached
profile = QWebEngineProfile()
interceptor = AuthInterceptor(profile, api_enabled=have_api_access)
profile.setUrlRequestInterceptor(interceptor)
if have_api_access:
_profile_with_api_access = profile
else:
_profile_without_api_access = profile
return profile
def _setupBridge(self) -> None:
# Add-on compatibility: For existing add-on callers that override the init
# and invoke _setupBridge directly (e.g. in order to use a custom web profile),
# we need to ensure that the bridge script is injected into the profile scripts,
# if it has yet to be injected.
profile = self.profile()
assert profile is not None
scripts = profile.scripts()
assert scripts is not None
if not scripts.contains(_bridge_script):
print("add-on callers should not call _setupBridge directly")
self._inject_user_script(profile, _bridge_script)
class Bridge(QObject):
def __init__(self, bridge_handler: Callable[[str], Any]) -> None:
super().__init__()
self.onCmd = bridge_handler
@pyqtSlot(str, result=str) # type: ignore
def cmd(self, str: str) -> Any:
return json.dumps(self.onCmd(str))
self._bridge = Bridge(self._onCmd)
self._channel = QWebChannel(self)
self._channel.registerObject("py", self._bridge)
self.setWebChannel(self._channel)
def _inject_user_script(
self, profile: QWebEngineProfile, script: QWebEngineScript
) -> None:
scripts = profile.scripts()
assert scripts is not None
scripts.insert(script)
def javaScriptConsoleMessage(
@ -162,7 +266,9 @@ class AnkiWebPage(QWebEnginePage):
print("onclick handler needs to return false")
return False
# load all other links in browser
openLink(url)
from aqt.url_schemes import open_url_if_supported_scheme
open_url_if_supported_scheme(url)
return False
def _onCmd(self, str: str) -> Any:
@ -247,34 +353,6 @@ class WebContent:
##########################################################################
class AnkiWebViewKind(Enum):
"""Enum registry of all web views managed by Anki
The value of each entry corresponds to the web view's title.
When introducing a new web view, please add it to the registry below.
"""
DEFAULT = "default"
MAIN = "main webview"
TOP_TOOLBAR = "top toolbar"
BOTTOM_TOOLBAR = "bottom toolbar"
DECK_OPTIONS = "deck options"
EDITOR = "editor"
LEGACY_DECK_STATS = "legacy deck stats"
DECK_STATS = "deck stats"
PREVIEWER = "previewer"
CHANGE_NOTETYPE = "change notetype"
CARD_LAYOUT = "card layout"
BROWSER_CARD_INFO = "browser card info"
IMPORT_CSV = "csv import"
EMPTY_CARDS = "empty cards"
FIND_DUPLICATES = "find duplicates"
FIELDS = "fields"
IMPORT_LOG = "import log"
IMPORT_ANKI_PACKAGE = "anki package import"
class AnkiWebView(QWebEngineView):
allow_drops = False
_kind: AnkiWebViewKind
@ -286,12 +364,11 @@ class AnkiWebView(QWebEngineView):
kind: AnkiWebViewKind = AnkiWebViewKind.DEFAULT,
) -> None:
QWebEngineView.__init__(self, parent=parent)
self.set_kind(kind)
if title:
self.set_title(title)
self._page = AnkiWebPage(self._onBridgeCmd)
self._kind = kind
self.set_title(kind.value)
self.setPage(AnkiWebPage(self._onBridgeCmd, kind, self))
# reduce flicker
self._page.setBackgroundColor(theme_manager.qcolor(colors.CANVAS))
self.page().setBackgroundColor(theme_manager.qcolor(colors.CANVAS))
# in new code, use .set_bridge_command() instead of setting this directly
self.onBridgeCmd: Callable[[str], Any] = self.defaultOnBridgeCmd
@ -299,7 +376,6 @@ class AnkiWebView(QWebEngineView):
self._domDone = True
self._pendingActions: list[tuple[str, Sequence[Any]]] = []
self.requiresCol = True
self.setPage(self._page)
self._disable_zoom = False
self.resetHandlers()
@ -320,9 +396,8 @@ class AnkiWebView(QWebEngineView):
"""
)
def set_kind(self, kind: AnkiWebViewKind) -> None:
self._kind = kind
self.set_title(kind.value)
def page(self) -> AnkiWebPage:
return cast(AnkiWebPage, super().page())
@property
def kind(self) -> AnkiWebViewKind:
@ -357,7 +432,7 @@ class AnkiWebView(QWebEngineView):
return False
def set_open_links_externally(self, enable: bool) -> None:
self._page.open_links_externally = enable
self.page().open_links_externally = enable
def onEsc(self) -> None:
w = self.parent()
@ -822,7 +897,7 @@ html {{ {font} }}
Must be done on Windows prior to changing current working directory."""
self.requiresCol = False
self._domReady = False
self._page.setContent(cast(QByteArray, bytes("", "ascii")))
self.page().setContent(cast(QByteArray, bytes("", "ascii")))
def cleanup(self) -> None:
try:
@ -836,14 +911,14 @@ html {{ {font} }}
# defer page cleanup so that in-flight requests have a chance to complete first
# https://forums.ankiweb.net/t/error-when-exiting-browsing-when-the-software-is-installed-in-the-path-c-program-files-anki/38363
mw.progress.single_shot(5000, lambda: mw.mediaServer.clear_page_html(id(self)))
self._page.deleteLater()
self.page().deleteLater()
def on_theme_did_change(self) -> None:
# avoid flashes if page reloaded
self._page.setBackgroundColor(theme_manager.qcolor(colors.CANVAS))
self.page().setBackgroundColor(theme_manager.qcolor(colors.CANVAS))
if hasattr(QWebEngineSettings.WebAttribute, "ForceDarkMode"):
force_dark_mode = getattr(QWebEngineSettings.WebAttribute, "ForceDarkMode")
page_settings = self._page.settings()
page_settings = self.page().settings()
if page_settings is not None:
page_settings.setAttribute(
force_dark_mode,
@ -885,3 +960,53 @@ html {{ {font} }}
@deprecated(info="use theme_manager.qcolor() instead")
def get_window_bg_color(self, night_mode: bool | None = None) -> QColor:
return theme_manager.qcolor(colors.CANVAS)
# Pre-configured classes for use in Qt Designer
##########################################################################
class _AnkiWebViewKwargs(TypedDict, total=False):
parent: QWidget | None
title: str
kind: AnkiWebViewKind
def _create_ankiwebview_subclass(
name: str,
/,
**fixed_kwargs: Unpack[_AnkiWebViewKwargs],
) -> Type[AnkiWebView]:
def __init__(self, *args: Any, **kwargs: _AnkiWebViewKwargs) -> None:
# usersupplied kwargs override fixed kwargs
merged = cast(_AnkiWebViewKwargs, {**fixed_kwargs, **kwargs})
AnkiWebView.__init__(self, *args, **merged)
__init__.__qualname__ = f"{name}.__init__"
if fixed_kwargs:
__init__.__doc__ = (
f"Autogenerated wrapper that presets "
f"{', '.join(f'{k}={v!r}' for k, v in fixed_kwargs.items())}."
)
cls: Type[AnkiWebView] = type(name, (AnkiWebView,), {"__init__": __init__})
return cls
# These subclasses are used in Qt Designer UI files to allow for configuring
# web views at initialization time (custom widgets can otherwise only be
# initialized with the default constructor)
StatsWebView = _create_ankiwebview_subclass(
"StatsWebView", kind=AnkiWebViewKind.DECK_STATS
)
LegacyStatsWebView = _create_ankiwebview_subclass(
"LegacyStatsWebView", kind=AnkiWebViewKind.LEGACY_DECK_STATS
)
EmptyCardsWebView = _create_ankiwebview_subclass(
"EmptyCardsWebView", kind=AnkiWebViewKind.EMPTY_CARDS
)
FindDupesWebView = _create_ankiwebview_subclass(
"FindDupesWebView", kind=AnkiWebViewKind.FIND_DUPLICATES
)

View file

@ -34,6 +34,5 @@ impl BackendCardRenderingService for Backend {
request.speed,
&request.text,
)
.map(Into::into)
}
}

View file

@ -77,7 +77,6 @@ impl crate::services::ConfigService for Collection {
) -> Result<()> {
let val: Value = serde_json::from_slice(&input.value_json)?;
self.transact_no_undo(|col| col.set_config(input.key.as_str(), &val).map(|_| ()))
.map(Into::into)
}
fn remove_config(

View file

@ -18,9 +18,7 @@ impl BackendImportExportService for Backend {
let mut guard = self.lock_open_collection()?;
let col_inner = guard.take().unwrap();
col_inner
.export_colpkg(input.out_path, input.include_media, input.legacy)
.map(Into::into)
col_inner.export_colpkg(input.out_path, input.include_media, input.legacy)
}
fn import_collection_package(
@ -36,6 +34,5 @@ impl BackendImportExportService for Backend {
Path::new(&input.media_db),
self.new_progress_handler(),
)
.map(Into::into)
}
}

View file

@ -99,7 +99,7 @@ impl TryFrom<anki_proto::sync::SyncAuth> for SyncAuth {
impl crate::services::BackendSyncService for Backend {
fn sync_media(&self, input: anki_proto::sync::SyncAuth) -> Result<()> {
let auth = input.try_into()?;
self.sync_media_in_background(auth, None).map(Into::into)
self.sync_media_in_background(auth, None)
}
fn media_sync_status(&self) -> Result<MediaSyncStatusResponse> {

View file

@ -131,7 +131,7 @@ impl From<Card> for anki_proto::cards::Card {
original_due: c.original_due,
original_deck_id: c.original_deck_id.0,
flags: c.flags as u32,
original_position: c.original_position.map(Into::into),
original_position: c.original_position,
memory_state: c.memory_state.map(Into::into),
desired_retention: c.desired_retention,
custom_data: c.custom_data,

View file

@ -21,7 +21,6 @@ impl crate::services::DeckConfigService for Collection {
col.add_or_update_deck_config_legacy(&mut conf)?;
Ok(anki_proto::deck_config::DeckConfigId { dcid: conf.id.0 })
})
.map(Into::into)
}
fn all_deck_config_legacy(&mut self) -> Result<generic::Json> {
@ -62,7 +61,6 @@ impl crate::services::DeckConfigService for Collection {
fn remove_deck_config(&mut self, input: anki_proto::deck_config::DeckConfigId) -> Result<()> {
self.transact_no_undo(|col| col.remove_deck_config_inner(input.into()))
.map(Into::into)
}
fn get_deck_configs_for_update(

View file

@ -55,7 +55,7 @@ impl crate::services::SchedulerService for Collection {
self.transact_no_undo(|col| {
let today = col.current_due_day(0)?;
let usn = col.usn()?;
col.update_deck_stats(today, usn, input).map(Into::into)
col.update_deck_stats(today, usn, input)
})
}
@ -70,7 +70,6 @@ impl crate::services::SchedulerService for Collection {
input.new_delta,
input.review_delta,
)
.map(Into::into)
})
}
@ -229,7 +228,6 @@ impl crate::services::SchedulerService for Collection {
fn upgrade_scheduler(&mut self) -> Result<()> {
self.transact_no_undo(|col| col.upgrade_to_v2_scheduler())
.map(Into::into)
}
fn get_queued_cards(

View file

@ -129,7 +129,7 @@ impl crate::services::SearchService for Collection {
&mut self,
input: generic::Int64,
) -> Result<anki_proto::search::BrowserRow> {
self.browser_row_for_id(input.val).map(Into::into)
self.browser_row_for_id(input.val)
}
}

View file

@ -34,7 +34,7 @@ impl crate::services::StatsService for Collection {
&mut self,
input: anki_proto::stats::GraphPreferences,
) -> error::Result<()> {
self.set_graph_preferences(input).map(Into::into)
self.set_graph_preferences(input)
}
}

View file

@ -82,7 +82,6 @@ impl SqliteStorage {
.query_and_then([machine_name], row_to_deck)?
.next()
.transpose()
.map_err(Into::into)
}
pub(crate) fn get_all_decks(&self) -> Result<Vec<Deck>> {

View file

@ -68,7 +68,6 @@ impl SqliteStorage {
.query_and_then([name], |row| Ok::<_, AnkiError>(DeckConfigId(row.get(0)?)))?
.next()
.transpose()
.map_err(Into::into)
}
pub(crate) fn add_deck_conf(&self, conf: &mut DeckConfig) -> Result<()> {

View file

@ -280,7 +280,7 @@ impl super::SqliteStorage {
include_str!("get.sql"),
" WHERE id IN (SELECT nid FROM search_nids)"
))?
.query_and_then([], |r| row_to_note(r).map_err(Into::into))?
.query_and_then([], row_to_note)?
.collect()
}

View file

@ -175,7 +175,7 @@ impl SqliteStorage {
pub(crate) fn get_all_revlog_entries(&self, after: TimestampSecs) -> Result<Vec<RevlogEntry>> {
self.db
.prepare_cached(concat!(include_str!("get.sql"), " where id >= ?"))?
.query_and_then([after.0 * 1000], |r| row_to_revlog_entry(r).map(Into::into))?
.query_and_then([after.0 * 1000], row_to_revlog_entry)?
.collect()
}

View file

@ -410,7 +410,7 @@ fn schema_version(db: &Connection) -> Result<(bool, u8)> {
Ok((
false,
db.query_row("select ver from col", [], |r| r.get(0).map_err(Into::into))?,
db.query_row("select ver from col", [], |r| r.get(0))?,
))
}

View file

@ -2,10 +2,12 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use ammonia::Url;
use anki_io::metadata;
use axum::http::StatusCode;
use serde::Deserialize;
use serde::Serialize;
use tracing::debug;
use tracing::info;
use crate::config::SchedulerVersion;
use crate::prelude::*;
@ -18,6 +20,7 @@ use crate::sync::error::OrHttpErr;
use crate::sync::http_client::HttpSyncClient;
use crate::sync::request::IntoSyncRequest;
use crate::sync::request::SyncRequest;
use crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED;
use crate::sync::version::SYNC_VERSION_09_V2_SCHEDULER;
use crate::sync::version::SYNC_VERSION_10_V2_TIMEZONE;
use crate::sync::version::SYNC_VERSION_MAX;
@ -49,6 +52,8 @@ pub struct SyncMeta {
pub v2_scheduler_or_later: bool,
#[serde(skip)]
pub v2_timezone: bool,
#[serde(skip)]
pub collection_bytes: u64,
}
impl SyncMeta {
@ -123,6 +128,7 @@ pub struct MetaRequest {
impl Collection {
pub fn sync_meta(&self) -> Result<SyncMeta> {
let stamps = self.storage.get_collection_timestamps()?;
let collection_bytes = metadata(&self.col_path)?.len();
Ok(SyncMeta {
modified: stamps.collection_change,
schema: stamps.schema_change,
@ -136,6 +142,7 @@ impl Collection {
empty: !self.storage.have_at_least_one_card()?,
v2_scheduler_or_later: self.scheduler_version() == SchedulerVersion::V2,
v2_timezone: self.get_creation_utc_offset().is_some(),
collection_bytes,
// must be filled in by calling code
media_usn: Usn(0),
})
@ -152,6 +159,10 @@ pub fn server_meta(req: MetaRequest, col: &mut Collection) -> HttpResult<SyncMet
});
}
let mut meta = col.sync_meta().or_internal_err("sync meta")?;
if meta.collection_bytes > *MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED {
info!("collection is too large, forcing one-way sync");
meta.schema = TimestampMillis::now();
}
if meta.v2_scheduler_or_later && req.sync_version < SYNC_VERSION_09_V2_SCHEDULER {
meta.server_message = "Your client does not support the v2 scheduler".into();
meta.should_continue = false;

View file

@ -17,6 +17,7 @@ use crate::sync::collection::protocol::SyncProtocol;
use crate::sync::collection::status::online_sync_status_check;
use crate::sync::http_client::HttpSyncClient;
use crate::sync::login::SyncAuth;
use crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED;
pub struct NormalSyncer<'a> {
pub(in crate::sync) col: &'a mut Collection,
@ -68,6 +69,14 @@ impl NormalSyncer<'_> {
pub async fn sync(&mut self) -> error::Result<SyncOutput> {
debug!("fetching meta...");
let local = self.col.sync_meta()?;
let local_bytes = local.collection_bytes;
let limit = *MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED;
if self.server.endpoint.as_str().contains("ankiweb") && local.collection_bytes > limit {
return Err(AnkiError::sync_error(
format!("{local_bytes} > {limit}"),
SyncErrorKind::UploadTooLarge,
));
}
let state = online_sync_status_check(local, &mut self.server).await?;
debug!(?state, "fetched");
match state.required {

View file

@ -79,7 +79,7 @@ impl ServerMediaManager {
}
fn add_or_replace_file(path: &Path, data: Vec<u8>) -> error::Result<(), FileIoError> {
write_file(path, data).map_err(Into::into)
write_file(path, data)
}
fn remove_file(path: &Path) -> error::Result<(), FileIoError> {

View file

@ -1,3 +1,3 @@
[toolchain]
# older versions may fail to compile; newer versions may fail the clippy tests
channel = "1.84.0"
channel = "1.85.0"

View file

@ -1,3 +1,3 @@
#!/bin/bash
cargo install --git https://github.com/ankitects/n2.git --rev 3b725cf9c321efb90496dd2458cf5c1abbef4dba
cargo install --git https://github.com/evmar/n2.git --rev 53ec691df749277104d1d4201a344fe4243d6d0a

View file

@ -209,7 +209,7 @@ fn sveltekit_temp_file(path: &str) -> bool {
}
fn check_cargo_deny() -> Result<()> {
Command::run("cargo install cargo-deny@0.14.24")?;
Command::run("cargo install cargo-deny@0.18.2")?;
Command::run("cargo deny check")?;
Ok(())
}

View file

@ -4,8 +4,42 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ButtonGroup from "$lib/components/ButtonGroup.svelte";
import { bridgeCommand } from "@tslib/bridgecommand";
import { toggleEditorButton } from "../old-editor-adapter";
import { singleCallback } from "@tslib/typing";
import { on } from "@tslib/events";
export let buttons: string[];
const { buttons } = $props<{ buttons: string[] }>();
$effect(() => {
// Each time the buttons are changed...
buttons;
// Add event handlers to each button
const addonButtons = document.querySelectorAll(".anki-addon-button");
const cbs = [...addonButtons].map((button) =>
singleCallback(
on(button, "click", () => {
const command = button.getAttribute("data-command");
if (command) {
bridgeCommand(command);
}
const toggleable = button.getAttribute("data-cantoggle");
if (toggleable === "1") {
toggleEditorButton(button as HTMLButtonElement);
}
return false;
}),
on(button as HTMLButtonElement, "mousedown", (evt) => {
evt.preventDefault();
evt.stopPropagation();
}),
),
);
return singleCallback(...cbs);
});
const radius = "5px";
function getBorderRadius(index: number, length: number): string {