Compare commits

...

20 commits

Author SHA1 Message Date
Damien Elmes
4e83c9b82d Bump version again 2025-04-23 17:26:33 +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
39 changed files with 329 additions and 203 deletions

View file

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

View file

@ -1 +1 @@
25.02 25.02.3

46
Cargo.lock generated
View file

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

View file

@ -110,7 +110,7 @@ prost-build = "0.13"
prost-reflect = "0.14" prost-reflect = "0.14"
prost-types = "0.13" prost-types = "0.13"
pulldown-cmark = "0.9.6" 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" rand = "0.8.5"
regex = "1.11.0" regex = "1.11.0"
reqwest = { version = "0.12.8", default-features = false, features = ["json", "socks", "stream", "multipart"] } 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); build.add_outputs("", python_outputs);
} }
fn hide_last_line(&self) -> bool { fn hide_progress(&self) -> bool {
true true
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -141,7 +141,7 @@ class AVPlayer:
# audio be stopped? # audio be stopped?
interrupt_current_audio = True interrupt_current_audio = True
# caller key for the current playback (optional) # caller key for the current playback (optional)
current_caller = None current_caller: Any = None
def __init__(self) -> None: def __init__(self) -> None:
self._enqueued: list[AVTag] = [] self._enqueued: list[AVTag] = []
@ -165,7 +165,7 @@ class AVPlayer:
self._enqueued = [] self._enqueued = []
self._stop_if_playing() 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: if caller == self.current_caller:
self.stop_and_clear_queue() self.stop_and_clear_queue()
@ -177,7 +177,7 @@ class AVPlayer:
def play_file(self, filename: str) -> None: def play_file(self, filename: str) -> None:
self.play_tags([SoundOrVideoTag(filename=filename)]) 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.current_caller = caller
self.play_file(filename) self.play_file(filename)

View file

@ -25,7 +25,7 @@ from aqt.utils import (
tooltip, tooltip,
tr, tr,
) )
from aqt.webview import AnkiWebViewKind from aqt.webview import LegacyStatsWebView
class NewDeckStats(QDialog): class NewDeckStats(QDialog):
@ -71,7 +71,6 @@ class NewDeckStats(QDialog):
maybeHideClose(self.form.buttonBox) maybeHideClose(self.form.buttonBox)
addCloseShortcut(self) addCloseShortcut(self)
gui_hooks.stats_dialog_will_show(self) gui_hooks.stats_dialog_will_show(self)
self.form.web.set_kind(AnkiWebViewKind.DECK_STATS)
self.form.web.hide_while_preserving_layout() self.form.web.hide_while_preserving_layout()
self.show() self.show()
self.refresh() self.refresh()
@ -154,6 +153,9 @@ class DeckStats(QDialog):
self.name = "deckStats" self.name = "deckStats"
self.period = 0 self.period = 0
self.form = aqt.forms.stats.Ui_Dialog() 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.oldPos = None
self.wholeCollection = False self.wholeCollection = False
self.setMinimumWidth(700) self.setMinimumWidth(700)
@ -232,7 +234,6 @@ class DeckStats(QDialog):
stats = self.mw.col.stats() stats = self.mw.col.stats()
stats.wholeCollection = self.wholeCollection stats.wholeCollection = self.wholeCollection
self.report = stats.report(type=self.period) self.report = stats.report(type=self.period)
self.form.web.set_kind(AnkiWebViewKind.LEGACY_DECK_STATS)
self.form.web.stdHtml( self.form.web.stdHtml(
f"<html><body>{self.report}</body></html>", f"<html><body>{self.report}</body></html>",
js=["js/vendor/jquery.min.js", "js/vendor/plot.js"], js=["js/vendor/jquery.min.js", "js/vendor/plot.js"],

View file

@ -10,7 +10,9 @@ import re
import sys import sys
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from enum import Enum 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
import anki.lang import anki.lang
@ -28,36 +30,52 @@ serverbaseurl = re.compile(r"^.+:\/\/[^\/]+")
if TYPE_CHECKING: if TYPE_CHECKING:
from aqt.mediasrv import PageContext from aqt.mediasrv import PageContext
# Page for debug messages
##########################################################################
BridgeCommandHandler = Callable[[str], Any] BridgeCommandHandler = Callable[[str], Any]
class AnkiWebPage(QWebEnginePage): class AnkiWebViewKind(Enum):
def __init__(self, onBridgeCmd: BridgeCommandHandler) -> None: """Enum registry of all web views managed by Anki
QWebEnginePage.__init__(self)
self._onBridgeCmd = onBridgeCmd
self._setupBridge()
self.open_links_externally = True
def _setupBridge(self) -> None: The value of each entry corresponds to the web view's title.
class Bridge(QObject):
def __init__(self, bridge_handler: Callable[[str], Any]) -> None:
super().__init__()
self.onCmd = bridge_handler
@pyqtSlot(str, result=str) # type: ignore When introducing a new web view, please add it to the registry below.
def cmd(self, str: str) -> Any: """
return json.dumps(self.onCmd(str))
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" qwebchannel = ":/qtwebchannel/qwebchannel.js"
jsfile = QFile(qwebchannel) jsfile = QFile(qwebchannel)
if not jsfile.open(QIODevice.OpenModeFlag.ReadOnly): if not jsfile.open(QIODevice.OpenModeFlag.ReadOnly):
@ -90,10 +108,96 @@ class AnkiWebPage(QWebEnginePage):
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
script.setRunsOnSubFrames(False) 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() profile = self.profile()
assert profile is not None assert profile is not None
scripts = profile.scripts() scripts = profile.scripts()
assert scripts is not None 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) scripts.insert(script)
def javaScriptConsoleMessage( def javaScriptConsoleMessage(
@ -247,34 +351,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): class AnkiWebView(QWebEngineView):
allow_drops = False allow_drops = False
_kind: AnkiWebViewKind _kind: AnkiWebViewKind
@ -286,12 +362,11 @@ class AnkiWebView(QWebEngineView):
kind: AnkiWebViewKind = AnkiWebViewKind.DEFAULT, kind: AnkiWebViewKind = AnkiWebViewKind.DEFAULT,
) -> None: ) -> None:
QWebEngineView.__init__(self, parent=parent) QWebEngineView.__init__(self, parent=parent)
self.set_kind(kind) self._kind = kind
if title: self.set_title(kind.value)
self.set_title(title) self.setPage(AnkiWebPage(self._onBridgeCmd, kind, self))
self._page = AnkiWebPage(self._onBridgeCmd)
# reduce flicker # 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 # in new code, use .set_bridge_command() instead of setting this directly
self.onBridgeCmd: Callable[[str], Any] = self.defaultOnBridgeCmd self.onBridgeCmd: Callable[[str], Any] = self.defaultOnBridgeCmd
@ -299,7 +374,6 @@ class AnkiWebView(QWebEngineView):
self._domDone = True self._domDone = True
self._pendingActions: list[tuple[str, Sequence[Any]]] = [] self._pendingActions: list[tuple[str, Sequence[Any]]] = []
self.requiresCol = True self.requiresCol = True
self.setPage(self._page)
self._disable_zoom = False self._disable_zoom = False
self.resetHandlers() self.resetHandlers()
@ -320,9 +394,8 @@ class AnkiWebView(QWebEngineView):
""" """
) )
def set_kind(self, kind: AnkiWebViewKind) -> None: def page(self) -> AnkiWebPage:
self._kind = kind return cast(AnkiWebPage, super().page())
self.set_title(kind.value)
@property @property
def kind(self) -> AnkiWebViewKind: def kind(self) -> AnkiWebViewKind:
@ -357,7 +430,7 @@ class AnkiWebView(QWebEngineView):
return False return False
def set_open_links_externally(self, enable: bool) -> None: 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: def onEsc(self) -> None:
w = self.parent() w = self.parent()
@ -822,7 +895,7 @@ html {{ {font} }}
Must be done on Windows prior to changing current working directory.""" Must be done on Windows prior to changing current working directory."""
self.requiresCol = False self.requiresCol = False
self._domReady = False self._domReady = False
self._page.setContent(cast(QByteArray, bytes("", "ascii"))) self.page().setContent(cast(QByteArray, bytes("", "ascii")))
def cleanup(self) -> None: def cleanup(self) -> None:
try: try:
@ -836,14 +909,14 @@ html {{ {font} }}
# defer page cleanup so that in-flight requests have a chance to complete first # 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 # 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))) 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: def on_theme_did_change(self) -> None:
# avoid flashes if page reloaded # 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"): if hasattr(QWebEngineSettings.WebAttribute, "ForceDarkMode"):
force_dark_mode = getattr(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: if page_settings is not None:
page_settings.setAttribute( page_settings.setAttribute(
force_dark_mode, force_dark_mode,
@ -885,3 +958,53 @@ html {{ {font} }}
@deprecated(info="use theme_manager.qcolor() instead") @deprecated(info="use theme_manager.qcolor() instead")
def get_window_bg_color(self, night_mode: bool | None = None) -> QColor: def get_window_bg_color(self, night_mode: bool | None = None) -> QColor:
return theme_manager.qcolor(colors.CANVAS) 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.speed,
&request.text, &request.text,
) )
.map(Into::into)
} }
} }

View file

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

View file

@ -18,9 +18,7 @@ impl BackendImportExportService for Backend {
let mut guard = self.lock_open_collection()?; let mut guard = self.lock_open_collection()?;
let col_inner = guard.take().unwrap(); let col_inner = guard.take().unwrap();
col_inner col_inner.export_colpkg(input.out_path, input.include_media, input.legacy)
.export_colpkg(input.out_path, input.include_media, input.legacy)
.map(Into::into)
} }
fn import_collection_package( fn import_collection_package(
@ -36,6 +34,5 @@ impl BackendImportExportService for Backend {
Path::new(&input.media_db), Path::new(&input.media_db),
self.new_progress_handler(), 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 { impl crate::services::BackendSyncService for Backend {
fn sync_media(&self, input: anki_proto::sync::SyncAuth) -> Result<()> { fn sync_media(&self, input: anki_proto::sync::SyncAuth) -> Result<()> {
let auth = input.try_into()?; 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> { 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_due: c.original_due,
original_deck_id: c.original_deck_id.0, original_deck_id: c.original_deck_id.0,
flags: c.flags as u32, 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), memory_state: c.memory_state.map(Into::into),
desired_retention: c.desired_retention, desired_retention: c.desired_retention,
custom_data: c.custom_data, 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)?; col.add_or_update_deck_config_legacy(&mut conf)?;
Ok(anki_proto::deck_config::DeckConfigId { dcid: conf.id.0 }) Ok(anki_proto::deck_config::DeckConfigId { dcid: conf.id.0 })
}) })
.map(Into::into)
} }
fn all_deck_config_legacy(&mut self) -> Result<generic::Json> { 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<()> { 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())) self.transact_no_undo(|col| col.remove_deck_config_inner(input.into()))
.map(Into::into)
} }
fn get_deck_configs_for_update( fn get_deck_configs_for_update(

View file

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

View file

@ -129,7 +129,7 @@ impl crate::services::SearchService for Collection {
&mut self, &mut self,
input: generic::Int64, input: generic::Int64,
) -> Result<anki_proto::search::BrowserRow> { ) -> 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, &mut self,
input: anki_proto::stats::GraphPreferences, input: anki_proto::stats::GraphPreferences,
) -> error::Result<()> { ) -> 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)? .query_and_then([machine_name], row_to_deck)?
.next() .next()
.transpose() .transpose()
.map_err(Into::into)
} }
pub(crate) fn get_all_decks(&self) -> Result<Vec<Deck>> { 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)?)))? .query_and_then([name], |row| Ok::<_, AnkiError>(DeckConfigId(row.get(0)?)))?
.next() .next()
.transpose() .transpose()
.map_err(Into::into)
} }
pub(crate) fn add_deck_conf(&self, conf: &mut DeckConfig) -> Result<()> { pub(crate) fn add_deck_conf(&self, conf: &mut DeckConfig) -> Result<()> {

View file

@ -280,7 +280,7 @@ impl super::SqliteStorage {
include_str!("get.sql"), include_str!("get.sql"),
" WHERE id IN (SELECT nid FROM search_nids)" " 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() .collect()
} }

View file

@ -175,7 +175,7 @@ impl SqliteStorage {
pub(crate) fn get_all_revlog_entries(&self, after: TimestampSecs) -> Result<Vec<RevlogEntry>> { pub(crate) fn get_all_revlog_entries(&self, after: TimestampSecs) -> Result<Vec<RevlogEntry>> {
self.db self.db
.prepare_cached(concat!(include_str!("get.sql"), " where id >= ?"))? .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() .collect()
} }

View file

@ -410,7 +410,7 @@ fn schema_version(db: &Connection) -> Result<(bool, u8)> {
Ok(( Ok((
false, 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 // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use ammonia::Url; use ammonia::Url;
use anki_io::metadata;
use axum::http::StatusCode; use axum::http::StatusCode;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use tracing::debug; use tracing::debug;
use tracing::info;
use crate::config::SchedulerVersion; use crate::config::SchedulerVersion;
use crate::prelude::*; use crate::prelude::*;
@ -18,6 +20,7 @@ use crate::sync::error::OrHttpErr;
use crate::sync::http_client::HttpSyncClient; use crate::sync::http_client::HttpSyncClient;
use crate::sync::request::IntoSyncRequest; use crate::sync::request::IntoSyncRequest;
use crate::sync::request::SyncRequest; 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_09_V2_SCHEDULER;
use crate::sync::version::SYNC_VERSION_10_V2_TIMEZONE; use crate::sync::version::SYNC_VERSION_10_V2_TIMEZONE;
use crate::sync::version::SYNC_VERSION_MAX; use crate::sync::version::SYNC_VERSION_MAX;
@ -49,6 +52,8 @@ pub struct SyncMeta {
pub v2_scheduler_or_later: bool, pub v2_scheduler_or_later: bool,
#[serde(skip)] #[serde(skip)]
pub v2_timezone: bool, pub v2_timezone: bool,
#[serde(skip)]
pub collection_bytes: u64,
} }
impl SyncMeta { impl SyncMeta {
@ -123,6 +128,7 @@ pub struct MetaRequest {
impl Collection { impl Collection {
pub fn sync_meta(&self) -> Result<SyncMeta> { pub fn sync_meta(&self) -> Result<SyncMeta> {
let stamps = self.storage.get_collection_timestamps()?; let stamps = self.storage.get_collection_timestamps()?;
let collection_bytes = metadata(&self.col_path)?.len();
Ok(SyncMeta { Ok(SyncMeta {
modified: stamps.collection_change, modified: stamps.collection_change,
schema: stamps.schema_change, schema: stamps.schema_change,
@ -136,6 +142,7 @@ impl Collection {
empty: !self.storage.have_at_least_one_card()?, empty: !self.storage.have_at_least_one_card()?,
v2_scheduler_or_later: self.scheduler_version() == SchedulerVersion::V2, v2_scheduler_or_later: self.scheduler_version() == SchedulerVersion::V2,
v2_timezone: self.get_creation_utc_offset().is_some(), v2_timezone: self.get_creation_utc_offset().is_some(),
collection_bytes,
// must be filled in by calling code // must be filled in by calling code
media_usn: Usn(0), 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")?; 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 { 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.server_message = "Your client does not support the v2 scheduler".into();
meta.should_continue = false; 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::collection::status::online_sync_status_check;
use crate::sync::http_client::HttpSyncClient; use crate::sync::http_client::HttpSyncClient;
use crate::sync::login::SyncAuth; use crate::sync::login::SyncAuth;
use crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED;
pub struct NormalSyncer<'a> { pub struct NormalSyncer<'a> {
pub(in crate::sync) col: &'a mut Collection, pub(in crate::sync) col: &'a mut Collection,
@ -68,6 +69,14 @@ impl NormalSyncer<'_> {
pub async fn sync(&mut self) -> error::Result<SyncOutput> { pub async fn sync(&mut self) -> error::Result<SyncOutput> {
debug!("fetching meta..."); debug!("fetching meta...");
let local = self.col.sync_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?; let state = online_sync_status_check(local, &mut self.server).await?;
debug!(?state, "fetched"); debug!(?state, "fetched");
match state.required { match state.required {

View file

@ -79,7 +79,7 @@ impl ServerMediaManager {
} }
fn add_or_replace_file(path: &Path, data: Vec<u8>) -> error::Result<(), FileIoError> { 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> { fn remove_file(path: &Path) -> error::Result<(), FileIoError> {

View file

@ -1,3 +1,3 @@
[toolchain] [toolchain]
# older versions may fail to compile; newer versions may fail the clippy tests # 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 #!/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<()> { 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")?; Command::run("cargo deny check")?;
Ok(()) Ok(())
} }