mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
Merge branch 'main' into qt-widget-gallery
This commit is contained in:
commit
4c76bc4b0d
85 changed files with 1544 additions and 163 deletions
|
@ -6,7 +6,9 @@ antispam=", at the domain "
|
||||||
|
|
||||||
headAuthor=$(git log -1 --pretty=format:'%ae')
|
headAuthor=$(git log -1 --pretty=format:'%ae')
|
||||||
authorAt=$(echo "$headAuthor" | sed "s/@/$antispam/")
|
authorAt=$(echo "$headAuthor" | sed "s/@/$antispam/")
|
||||||
if git log --pretty=format:'%ae' CONTRIBUTORS | grep -i "$headAuthor" > /dev/null; then
|
if [ $headAuthor = "49699333+dependabot[bot]@users.noreply.github.com" ]; then
|
||||||
|
echo "Dependabot whitelisted."
|
||||||
|
elif git log --pretty=format:'%ae' CONTRIBUTORS | grep -i "$headAuthor" > /dev/null; then
|
||||||
echo "Author found in CONTRIBUTORS"
|
echo "Author found in CONTRIBUTORS"
|
||||||
else
|
else
|
||||||
echo "All contributors:"
|
echo "All contributors:"
|
||||||
|
|
2
.version
2
.version
|
@ -1 +1 @@
|
||||||
2.1.56
|
2.1.57
|
8
Cargo.lock
generated
8
Cargo.lock
generated
|
@ -347,9 +347,9 @@ checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bzip2"
|
name = "bzip2"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6afcd980b5f3a45017c57e57a2fcccbb351cc43a356ce117ef760ef8052b89b0"
|
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bzip2-sys",
|
"bzip2-sys",
|
||||||
"libc",
|
"libc",
|
||||||
|
@ -3602,9 +3602,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.23.0"
|
version = "1.23.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46"
|
checksum = "38a54aca0c15d014013256222ba0ebed095673f89345dd79119d912eb561b7a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
|
@ -13,7 +13,7 @@ camino = "1.1.1"
|
||||||
flate2 = "1.0.25"
|
flate2 = "1.0.25"
|
||||||
sha2 = { version = "0.10.6" }
|
sha2 = { version = "0.10.6" }
|
||||||
tar = "0.4.38"
|
tar = "0.4.38"
|
||||||
tokio = { version = "1.22.0", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.23.1", features = ["macros", "rt-multi-thread"] }
|
||||||
workspace-hack = { version = "0.1", path = "../../tools/workspace-hack" }
|
workspace-hack = { version = "0.1", path = "../../tools/workspace-hack" }
|
||||||
xz2 = "0.1.7"
|
xz2 = "0.1.7"
|
||||||
zip = "0.6.3"
|
zip = "0.6.3"
|
||||||
|
|
|
@ -12,7 +12,7 @@ pub struct SyncSubmodule {
|
||||||
|
|
||||||
impl BuildAction for SyncSubmodule {
|
impl BuildAction for SyncSubmodule {
|
||||||
fn command(&self) -> &str {
|
fn command(&self) -> &str {
|
||||||
"git submodule update --init $path"
|
"git -c protocol.file.allow=always submodule update --init $path"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn files(&mut self, build: &mut impl build::FilesHandle) {
|
fn files(&mut self, build: &mut impl build::FilesHandle) {
|
||||||
|
|
|
@ -22,6 +22,10 @@ pub fn build_artifacts(args: BuildArtifactsArgs) {
|
||||||
fs::remove_dir_all(&artifacts).unwrap();
|
fs::remove_dir_all(&artifacts).unwrap();
|
||||||
}
|
}
|
||||||
let bundle_root = args.bundle_root.canonicalize_utf8().unwrap();
|
let bundle_root = args.bundle_root.canonicalize_utf8().unwrap();
|
||||||
|
let build_folder = bundle_root.join("build");
|
||||||
|
if build_folder.exists() {
|
||||||
|
fs::remove_dir_all(&build_folder).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
run_silent(
|
run_silent(
|
||||||
Command::new(&args.pyoxidizer_bin)
|
Command::new(&args.pyoxidizer_bin)
|
||||||
|
@ -34,7 +38,7 @@ pub fn build_artifacts(args: BuildArtifactsArgs) {
|
||||||
"out/bundle/pyenv",
|
"out/bundle/pyenv",
|
||||||
"--var",
|
"--var",
|
||||||
"build",
|
"build",
|
||||||
bundle_root.join("build").as_str(),
|
build_folder.as_str(),
|
||||||
])
|
])
|
||||||
.env("CARGO_MANIFEST_DIR", "qt/bundle")
|
.env("CARGO_MANIFEST_DIR", "qt/bundle")
|
||||||
.env("CARGO_TARGET_DIR", "out/bundle/rust")
|
.env("CARGO_TARGET_DIR", "out/bundle/rust")
|
||||||
|
|
|
@ -29,7 +29,7 @@ $ sudo apt install bash grep findutils curl gcc g++ git rsync ninja-build
|
||||||
|
|
||||||
## Missing Libraries
|
## Missing Libraries
|
||||||
|
|
||||||
If you get errors during startup, try starting with
|
If you get errors during build or startup, try starting with
|
||||||
|
|
||||||
QT_DEBUG_PLUGINS=1 ./run
|
QT_DEBUG_PLUGINS=1 ./run
|
||||||
|
|
||||||
|
@ -42,6 +42,13 @@ sudo apt install libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
|
||||||
libxcb-randr0 libxcb-render-util0
|
libxcb-randr0 libxcb-render-util0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On some distros such as Arch Linux and Fedora, you may need to install the
|
||||||
|
`libxcrypt-compat` package if you get an error like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
error while loading shared libraries: libcrypt.so.1: cannot open shared object file: No such file or directory
|
||||||
|
```
|
||||||
|
|
||||||
## Audio
|
## Audio
|
||||||
|
|
||||||
To play and record audio during development, install mpv and lame.
|
To play and record audio during development, install mpv and lame.
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit 2d13bfbe9e60dd365c4300f1e0589100b8b044af
|
Subproject commit dae74b5783acf77e2ec9a9f764da254ed041563d
|
|
@ -48,3 +48,4 @@ preferences-monthly-backups = Monthly backups to keep:
|
||||||
preferences-minutes-between-backups = Minutes between automatic backups:
|
preferences-minutes-between-backups = Minutes between automatic backups:
|
||||||
preferences-reduce-motion = Reduce motion
|
preferences-reduce-motion = Reduce motion
|
||||||
preferences-reduce-motion-tooltip = Disable various animations and transitions of the user interface
|
preferences-reduce-motion-tooltip = Disable various animations and transitions of the user interface
|
||||||
|
preferences-collapse-toolbar = Hide top bar during review
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit b0336a1f67e8379c2a0e7359bc8e8c2a89719652
|
Subproject commit 4c7dd7b6cc9d4b666d04eda7cba8a94f68d1660e
|
74
proto/anki/ankidroid.proto
Normal file
74
proto/anki/ankidroid.proto
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
option java_multiple_files = true;
|
||||||
|
|
||||||
|
import "anki/generic.proto";
|
||||||
|
import "anki/scheduler.proto";
|
||||||
|
|
||||||
|
package anki.ankidroid;
|
||||||
|
|
||||||
|
service AnkidroidService {
|
||||||
|
rpc SchedTimingTodayLegacy(SchedTimingTodayLegacyRequest)
|
||||||
|
returns (scheduler.SchedTimingTodayResponse);
|
||||||
|
rpc LocalMinutesWestLegacy(generic.Int64) returns (generic.Int32);
|
||||||
|
rpc RunDbCommand(generic.Json) returns (generic.Json);
|
||||||
|
rpc RunDbCommandProto(generic.Json) returns (DBResponse);
|
||||||
|
rpc InsertForId(generic.Json) returns (generic.Int64);
|
||||||
|
rpc RunDbCommandForRowCount(generic.Json) returns (generic.Int64);
|
||||||
|
rpc FlushAllQueries(generic.Empty) returns (generic.Empty);
|
||||||
|
rpc FlushQuery(generic.Int32) returns (generic.Empty);
|
||||||
|
rpc GetNextResultPage(GetNextResultPageRequest) returns (DBResponse);
|
||||||
|
rpc SetPageSize(generic.Int64) returns (generic.Empty);
|
||||||
|
rpc GetColumnNamesFromQuery(generic.String) returns (generic.StringList);
|
||||||
|
rpc GetActiveSequenceNumbers(generic.Empty)
|
||||||
|
returns (GetActiveSequenceNumbersResponse);
|
||||||
|
rpc DebugProduceError(generic.String) returns (generic.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
message DebugActiveDatabaseSequenceNumbersResponse {
|
||||||
|
repeated int32 sequence_numbers = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SchedTimingTodayLegacyRequest {
|
||||||
|
int64 created_secs = 1;
|
||||||
|
optional sint32 created_mins_west = 2;
|
||||||
|
int64 now_secs = 3;
|
||||||
|
sint32 now_mins_west = 4;
|
||||||
|
sint32 rollover_hour = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We expect in Java: Null, String, Short, Int, Long, Float, Double, Boolean,
|
||||||
|
// Blob (unused) We get: DbResult (Null, String, i64, f64, Vec<u8>), which
|
||||||
|
// matches SQLite documentation
|
||||||
|
message SqlValue {
|
||||||
|
oneof Data {
|
||||||
|
string stringValue = 1;
|
||||||
|
int64 longValue = 2;
|
||||||
|
double doubleValue = 3;
|
||||||
|
bytes blobValue = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Row {
|
||||||
|
repeated SqlValue fields = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DbResult {
|
||||||
|
repeated Row rows = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DBResponse {
|
||||||
|
DbResult result = 1;
|
||||||
|
int32 sequenceNumber = 2;
|
||||||
|
int32 rowCount = 3;
|
||||||
|
int64 startIndex = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetNextResultPageRequest {
|
||||||
|
int32 sequence = 1;
|
||||||
|
int64 index = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetActiveSequenceNumbersResponse {
|
||||||
|
repeated int32 numbers = 1;
|
||||||
|
}
|
|
@ -30,6 +30,7 @@ enum ServiceIndex {
|
||||||
SERVICE_INDEX_CARDS = 14;
|
SERVICE_INDEX_CARDS = 14;
|
||||||
SERVICE_INDEX_LINKS = 15;
|
SERVICE_INDEX_LINKS = 15;
|
||||||
SERVICE_INDEX_IMPORT_EXPORT = 16;
|
SERVICE_INDEX_IMPORT_EXPORT = 16;
|
||||||
|
SERVICE_INDEX_ANKIDROID = 17;
|
||||||
}
|
}
|
||||||
|
|
||||||
message BackendInit {
|
message BackendInit {
|
||||||
|
@ -64,6 +65,7 @@ message BackendError {
|
||||||
IMPORT_ERROR = 16;
|
IMPORT_ERROR = 16;
|
||||||
DELETED = 17;
|
DELETED = 17;
|
||||||
CARD_TYPE_ERROR = 18;
|
CARD_TYPE_ERROR = 18;
|
||||||
|
ANKIDROID_PANIC_ERROR = 19;
|
||||||
}
|
}
|
||||||
|
|
||||||
// error description, usually localized, suitable for displaying to the user
|
// error description, usually localized, suitable for displaying to the user
|
||||||
|
|
|
@ -34,6 +34,9 @@ message OpenCollectionRequest {
|
||||||
string collection_path = 1;
|
string collection_path = 1;
|
||||||
string media_folder_path = 2;
|
string media_folder_path = 2;
|
||||||
string media_db_path = 3;
|
string media_db_path = 3;
|
||||||
|
|
||||||
|
// temporary option for AnkiDroid
|
||||||
|
bool force_schema11 = 99;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CloseCollectionRequest {
|
message CloseCollectionRequest {
|
||||||
|
|
|
@ -309,6 +309,7 @@ class Collection(DeprecatedNamesMixin):
|
||||||
collection_path=self.path,
|
collection_path=self.path,
|
||||||
media_folder_path=media_dir,
|
media_folder_path=media_dir,
|
||||||
media_db_path=media_db,
|
media_db_path=media_db,
|
||||||
|
force_schema11=False,
|
||||||
)
|
)
|
||||||
self.db = DBProxy(weakref.proxy(self._backend))
|
self.db = DBProxy(weakref.proxy(self._backend))
|
||||||
self.db.begin()
|
self.db.begin()
|
||||||
|
|
|
@ -194,6 +194,8 @@ for service in anki.backend_pb2.ServiceIndex.DESCRIPTOR.values:
|
||||||
base = service.name.replace("SERVICE_INDEX_", "")
|
base = service.name.replace("SERVICE_INDEX_", "")
|
||||||
service_pkg = service_modules.get(base)
|
service_pkg = service_modules.get(base)
|
||||||
service_var = "_" + base.replace("_", "") + "SERVICE"
|
service_var = "_" + base.replace("_", "") + "SERVICE"
|
||||||
|
if service_var == "_ANKIDROIDSERVICE":
|
||||||
|
continue
|
||||||
service_obj = getattr(service_pkg, service_var)
|
service_obj = getattr(service_pkg, service_var)
|
||||||
service_index = service.number
|
service_index = service.number
|
||||||
render_service(service_obj, service_index)
|
render_service(service_obj, service_index)
|
||||||
|
|
|
@ -9,6 +9,7 @@ mypy-protobuf
|
||||||
pip-tools
|
pip-tools
|
||||||
pylint
|
pylint
|
||||||
pytest
|
pytest
|
||||||
|
PyChromeDevTools
|
||||||
fluent.syntax
|
fluent.syntax
|
||||||
types-decorator
|
types-decorator
|
||||||
types-flask
|
types-flask
|
||||||
|
|
|
@ -368,6 +368,9 @@ protobuf==4.21.9 \
|
||||||
# via
|
# via
|
||||||
# -r requirements.bundle.txt
|
# -r requirements.bundle.txt
|
||||||
# mypy-protobuf
|
# mypy-protobuf
|
||||||
|
pychromedevtools==0.4 \
|
||||||
|
--hash=sha256:453f889b11c58fed348206d1b6e91a0bbfe23a319365c586ae462214ecb513ce
|
||||||
|
# via -r requirements.dev.in
|
||||||
pylint==2.15.5 \
|
pylint==2.15.5 \
|
||||||
--hash=sha256:3b120505e5af1d06a5ad76b55d8660d44bf0f2fc3c59c2bdd94e39188ee3a4df \
|
--hash=sha256:3b120505e5af1d06a5ad76b55d8660d44bf0f2fc3c59c2bdd94e39188ee3a4df \
|
||||||
--hash=sha256:c2108037eb074334d9e874dc3c783752cc03d0796c88c9a9af282d0f161a1004
|
--hash=sha256:c2108037eb074334d9e874dc3c783752cc03d0796c88c9a9af282d0f161a1004
|
||||||
|
@ -420,7 +423,9 @@ pytoml==0.1.21 \
|
||||||
requests==2.28.1 \
|
requests==2.28.1 \
|
||||||
--hash=sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983 \
|
--hash=sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983 \
|
||||||
--hash=sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349
|
--hash=sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349
|
||||||
# via -r requirements.bundle.txt
|
# via
|
||||||
|
# -r requirements.bundle.txt
|
||||||
|
# pychromedevtools
|
||||||
send2trash==1.8.0 \
|
send2trash==1.8.0 \
|
||||||
--hash=sha256:d2c24762fd3759860a0aff155e45871447ea58d2be6bdd39b5c8f966a0c99c2d \
|
--hash=sha256:d2c24762fd3759860a0aff155e45871447ea58d2be6bdd39b5c8f966a0c99c2d \
|
||||||
--hash=sha256:f20eaadfdb517eaca5ce077640cb261c7d2698385a6a0f072a4a5447fd49fa08
|
--hash=sha256:f20eaadfdb517eaca5ce077640cb261c7d2698385a6a0f072a4a5447fd49fa08
|
||||||
|
@ -525,6 +530,10 @@ waitress==2.1.2 \
|
||||||
--hash=sha256:7500c9625927c8ec60f54377d590f67b30c8e70ef4b8894214ac6e4cad233d2a \
|
--hash=sha256:7500c9625927c8ec60f54377d590f67b30c8e70ef4b8894214ac6e4cad233d2a \
|
||||||
--hash=sha256:780a4082c5fbc0fde6a2fcfe5e26e6efc1e8f425730863c04085769781f51eba
|
--hash=sha256:780a4082c5fbc0fde6a2fcfe5e26e6efc1e8f425730863c04085769781f51eba
|
||||||
# via -r requirements.bundle.txt
|
# via -r requirements.bundle.txt
|
||||||
|
websocket-client==1.4.2 \
|
||||||
|
--hash=sha256:d6b06432f184438d99ac1f456eaf22fe1ade524c3dd16e661142dc54e9cba574 \
|
||||||
|
--hash=sha256:d6e8f90ca8e2dd4e8027c4561adeb9456b54044312dba655e7cae652ceb9ae59
|
||||||
|
# via pychromedevtools
|
||||||
werkzeug==2.2.2 \
|
werkzeug==2.2.2 \
|
||||||
--hash=sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f \
|
--hash=sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f \
|
||||||
--hash=sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5
|
--hash=sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
pyqt6==6.4.0
|
pyqt6==6.4.0
|
||||||
pyqt6-qt6==6.4.0
|
pyqt6-qt6==6.4.2
|
||||||
pyqt6-webengine==6.4.0
|
pyqt6-webengine==6.4.0
|
||||||
pyqt6-webengine-qt6==6.4.0
|
pyqt6-webengine-qt6==6.4.2
|
||||||
pyqt6_sip==13.4.0
|
pyqt6_sip==13.4.0
|
||||||
|
|
|
@ -6,11 +6,11 @@ pyqt6==6.4.0 \
|
||||||
# via
|
# via
|
||||||
# -r requirements.qt6_4.in
|
# -r requirements.qt6_4.in
|
||||||
# pyqt6-webengine
|
# pyqt6-webengine
|
||||||
pyqt6-qt6==6.4.0 \
|
pyqt6-qt6==6.4.2 \
|
||||||
--hash=sha256:38cfedf942f6982e2492234c4912a6f9ae0d54430313ba32297b7d673adaa11d \
|
--hash=sha256:9f07c3c100cb46cca4074965e7494d4df4f0fc016497d5303c1fe135822876e1 \
|
||||||
--hash=sha256:9f53036e3c7e0f17eabf6e89689279f3fc4895747b29c0c22d547ba57a087a8b \
|
--hash=sha256:a29b8c858babd523e80c8db5f8fd19792641588ec04eab49af18b7a4423eb99f \
|
||||||
--hash=sha256:adee1f98678adebf14cdf4ea1f95cf00b6a644c14e9a79136166d0060de72dfc \
|
--hash=sha256:c0e91d0275d428496cacff717a9b719c52bfa52b21f124d638b79cc2217bc81e \
|
||||||
--hash=sha256:fe846c6f89c4ca720ec03c85ec31ac6cc3ffbe8bf5e780f25f99a4cac3372f7c
|
--hash=sha256:d19c4e72615762cd6f0b043f23fa5f0b02656091427ce6de1efccd58e10e6a53
|
||||||
# via
|
# via
|
||||||
# -r requirements.qt6_4.in
|
# -r requirements.qt6_4.in
|
||||||
# pyqt6
|
# pyqt6
|
||||||
|
@ -41,11 +41,11 @@ pyqt6-webengine==6.4.0 \
|
||||||
--hash=sha256:7f6cde52b7b8c00ef2a1522ad92cde66f2bd3a3066646efe4ef96a4907b1b1cd \
|
--hash=sha256:7f6cde52b7b8c00ef2a1522ad92cde66f2bd3a3066646efe4ef96a4907b1b1cd \
|
||||||
--hash=sha256:9658919bc1c5279a6fae9e6990448dfe483e136e957e6fb14e8f6265f4e9d1da
|
--hash=sha256:9658919bc1c5279a6fae9e6990448dfe483e136e957e6fb14e8f6265f4e9d1da
|
||||||
# via -r requirements.qt6_4.in
|
# via -r requirements.qt6_4.in
|
||||||
pyqt6-webengine-qt6==6.4.0 \
|
pyqt6-webengine-qt6==6.4.2 \
|
||||||
--hash=sha256:572e7fee6de616191b98dd974ced8bd732e86dc1856c1ada7ad734402e37285c \
|
--hash=sha256:071f8c96433c27d10110782dc98cd2d8fee4a9e60fe4ab50e5f2abea48876ae1 \
|
||||||
--hash=sha256:689127e483ab76744477762ab936de9541e7fc368ab4f4ee463a9099bf8bc5be \
|
--hash=sha256:1111a5b580332768b5f4ab08becd639f9298f9a780da59ba2c317f2327e6b191 \
|
||||||
--hash=sha256:971aedd051c77c17c59e724692636a4a0883c70dff3dbd172ae7cfb2fe7ddcc4 \
|
--hash=sha256:4eeeb50a3b92c873996036b168d8b5e42da7db4bef5f7f2de4d863c2958dda5e \
|
||||||
--hash=sha256:f13b3582c7f170017ecd52ec4c2e735c859316f05820e1bb4a2910c530611af4
|
--hash=sha256:e6cbd4193af5d6e7cd82ff2fb04a5d66bc886554bbda00295e9709b0d6447e9d
|
||||||
# via
|
# via
|
||||||
# -r requirements.qt6_4.in
|
# -r requirements.qt6_4.in
|
||||||
# pyqt6-webengine
|
# pyqt6-webengine
|
||||||
|
|
|
@ -12,7 +12,9 @@ if sys.version_info[0] < 3 or sys.version_info[1] < 9:
|
||||||
try:
|
try:
|
||||||
"テスト".encode(sys.getfilesystemencoding())
|
"テスト".encode(sys.getfilesystemencoding())
|
||||||
except UnicodeEncodeError as exc:
|
except UnicodeEncodeError as exc:
|
||||||
raise Exception("Anki requires a UTF-8 locale.") from exc
|
print("Anki requires a UTF-8 locale.")
|
||||||
|
print("Please Google 'how to change locale on [your Linux distro]'")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
from .package import packaged_build_setup
|
from .package import packaged_build_setup
|
||||||
|
|
||||||
|
|
|
@ -303,7 +303,7 @@ class SidebarTreeView(QTreeView):
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.current_search and (item := self.model().item_for_index(idx)):
|
if self.current_search and (item := self.model().item_for_index(idx)):
|
||||||
if item.is_highlighted():
|
if item.is_highlighted():
|
||||||
brush = QBrush(theme_manager.qcolor(colors.STATE_SUSPENDED))
|
brush = QBrush(theme_manager.qcolor(colors.HIGHLIGHT_BG))
|
||||||
painter.save()
|
painter.save()
|
||||||
painter.fillRect(options.rect, brush)
|
painter.fillRect(options.rect, brush)
|
||||||
painter.restore()
|
painter.restore()
|
||||||
|
|
|
@ -15,7 +15,9 @@ table {
|
||||||
&:hover {
|
&:hover {
|
||||||
@include elevation(2);
|
@include elevation(2);
|
||||||
}
|
}
|
||||||
transition: box-shadow 0.2s ease-in-out;
|
transition: box-shadow var(--transition) ease-in-out;
|
||||||
|
background: var(--canvas-glass);
|
||||||
|
backdrop-filter: blur(var(--blur));
|
||||||
}
|
}
|
||||||
|
|
||||||
a.deck {
|
a.deck {
|
||||||
|
|
|
@ -18,10 +18,6 @@ body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#innertable {
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#middle td[align="center"] {
|
#middle td[align="center"] {
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -30,7 +26,7 @@ body {
|
||||||
button {
|
button {
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin: 0.5em;
|
margin: 9px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,10 +47,6 @@ button {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ansbut {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
:focus {
|
:focus {
|
||||||
border-color: color(border-focus);
|
border-color: color(border-focus);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
#header {
|
#header {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
margin-bottom: 6px;
|
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding: 9px;
|
padding: 9px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,25 +6,52 @@
|
||||||
@use "sass/elevation" as *;
|
@use "sass/elevation" as *;
|
||||||
@use "sass/button-mixins" as button;
|
@use "sass/button-mixins" as button;
|
||||||
|
|
||||||
#header {
|
.header {
|
||||||
padding-bottom: 4px;
|
height: 41px;
|
||||||
margin-top: -3px;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
align-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tdcenter {
|
.left-tray {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-tray {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-tray,
|
||||||
|
.right-tray {
|
||||||
|
align-self: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
height: 31px;
|
||||||
|
justify-self: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-radius: prop(border-radius);
|
overflow: hidden;
|
||||||
border-bottom-left-radius: prop(border-radius-large);
|
border-bottom-left-radius: prop(border-radius-large);
|
||||||
border-bottom-right-radius: prop(border-radius-large);
|
border-bottom-right-radius: prop(border-radius-large);
|
||||||
@include button.base($with-hover: false, $with-active: false);
|
@include elevation(1, $opacity-boost: -0.1);
|
||||||
overflow: hidden;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
@include elevation(1, $opacity-boost: -0.08);
|
// elevated state (deck browser, overview)
|
||||||
&:hover {
|
body:not(.flat) & {
|
||||||
@include elevation(2);
|
background: var(--canvas-elevated);
|
||||||
|
@include elevation(1);
|
||||||
|
&:hover {
|
||||||
|
@include elevation(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
transition: box-shadow 0.2s ease-in-out;
|
// glass effect
|
||||||
|
background: var(--canvas-glass);backdrop-filter: unset;
|
||||||
|
backdrop-filter: blur(var(--blur));
|
||||||
|
|
||||||
|
transition: all var(--transition) ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
@ -32,6 +59,11 @@ body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.collapsed {
|
||||||
|
transform: translateY(-100vh);
|
||||||
|
}
|
||||||
|
transition: transform var(--transition) ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
@ -40,12 +72,16 @@ body {
|
||||||
|
|
||||||
.hitem {
|
.hitem {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 8px 14px;
|
padding: 5px 12px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: color(fg);
|
color: color(fg);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@include button.base;
|
|
||||||
border: none;
|
body:not(.flat) &,
|
||||||
|
&:hover {
|
||||||
|
@include button.base($border: false);
|
||||||
|
background: var(--canvas-elevated);
|
||||||
|
}
|
||||||
&:first-child {
|
&:first-child {
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
|
@ -75,7 +111,7 @@ body {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
visibility: visible !important;
|
visibility: visible !important;
|
||||||
animation-timing-function: linear;
|
animation-timing-function: linear;
|
||||||
transition: all 0.2s ease-in;
|
transition: all var(--transition) ease-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sync-spinner {
|
#sync-spinner {
|
||||||
|
|
|
@ -15,13 +15,21 @@
|
||||||
body {
|
body {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
background: var(--canvas);
|
background: var(--canvas);
|
||||||
transition: opacity 0.5s ease-out;
|
transition: opacity var(--transition-medium) ease-out;
|
||||||
margin: 2em;
|
margin: 2em;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
&:not(.isMac),
|
&:not(.isMac),
|
||||||
&:not(.isMac) * {
|
&:not(.isMac) * {
|
||||||
@include scrollbar.custom;
|
@include scrollbar.custom;
|
||||||
}
|
}
|
||||||
|
&.reduced-motion,
|
||||||
|
&.reduced-motion * {
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
&.no-blur * {
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
|
@ -27,3 +27,63 @@ function updateSyncColor(state: SyncState) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dealing with legacy add-ons that used CSS to absolutely position
|
||||||
|
// themselves at toolbar edges
|
||||||
|
|
||||||
|
function isAbsolutelyPositioned(node: Node): boolean {
|
||||||
|
if (!(node instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return getComputedStyle(node).position === "absolute";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLegacyAddonElement(node: Node): boolean {
|
||||||
|
if (isAbsolutelyPositioned(node)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
if (isAbsolutelyPositioned(child)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElementDimensions(element: HTMLElement): [number, number] {
|
||||||
|
const widths = [element.offsetWidth];
|
||||||
|
const heights = [element.offsetHeight];
|
||||||
|
// Some add-ons inject spans or anchors into the toolbar whose dimensions,
|
||||||
|
// as reported by the properties above are zero, but still occupy space due
|
||||||
|
// to their child elements:
|
||||||
|
for (const child of element.childNodes) {
|
||||||
|
if (!(child instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
widths.push(child.offsetWidth);
|
||||||
|
heights.push(child.offsetHeight);
|
||||||
|
}
|
||||||
|
return [Math.max(...widths), Math.max(...heights)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLegacyAddonsToTray() {
|
||||||
|
const rightTray = document.getElementsByClassName("right-tray")[0];
|
||||||
|
const toolbarChildren = document.querySelectorAll<HTMLElement>(".toolbar > *");
|
||||||
|
const legacyAddonElements: HTMLElement[] = Array.from(toolbarChildren)
|
||||||
|
.reverse() // restore original add-on load order
|
||||||
|
.filter(isLegacyAddonElement);
|
||||||
|
|
||||||
|
for (const element of legacyAddonElements) {
|
||||||
|
const wrapperElement = document.createElement("div");
|
||||||
|
const dimensions = getElementDimensions(element);
|
||||||
|
element.style.right = "0px"; // remove manual padding
|
||||||
|
wrapperElement.append(element);
|
||||||
|
wrapperElement.style.cssText = `\
|
||||||
|
width: ${dimensions[0]}px; height: ${dimensions[1]}}px;
|
||||||
|
margin-left: 5px; margin-right: 5px; position: relative;`;
|
||||||
|
wrapperElement.className = "tray-item tray-item-legacy";
|
||||||
|
rightTray.append(wrapperElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", moveLegacyAddonsToTray);
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<x>0</x>
|
<x>0</x>
|
||||||
<y>0</y>
|
<y>0</y>
|
||||||
<width>640</width>
|
<width>640</width>
|
||||||
<height>640</height>
|
<height>660</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<property name="windowTitle">
|
<property name="windowTitle">
|
||||||
|
@ -113,6 +113,13 @@
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item alignment="Qt::AlignLeft">
|
||||||
|
<widget class="QCheckBox" name="collapse_toolbar">
|
||||||
|
<property name="text">
|
||||||
|
<string>preferences_collapse_toolbar</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QComboBox" name="useCurrent">
|
<widget class="QComboBox" name="useCurrent">
|
||||||
<item>
|
<item>
|
||||||
|
@ -666,7 +673,7 @@
|
||||||
</widget>
|
</widget>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item alignment="Qt::AlignCenter">
|
<item>
|
||||||
<widget class="QLabel" name="label_21">
|
<widget class="QLabel" name="label_21">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>preferences_some_settings_will_take_effect_after</string>
|
<string>preferences_some_settings_will_take_effect_after</string>
|
||||||
|
@ -696,6 +703,7 @@
|
||||||
<tabstop>ignore_accents_in_search</tabstop>
|
<tabstop>ignore_accents_in_search</tabstop>
|
||||||
<tabstop>legacy_import_export</tabstop>
|
<tabstop>legacy_import_export</tabstop>
|
||||||
<tabstop>reduce_motion</tabstop>
|
<tabstop>reduce_motion</tabstop>
|
||||||
|
<tabstop>collapse_toolbar</tabstop>
|
||||||
<tabstop>useCurrent</tabstop>
|
<tabstop>useCurrent</tabstop>
|
||||||
<tabstop>default_search_text</tabstop>
|
<tabstop>default_search_text</tabstop>
|
||||||
<tabstop>uiScale</tabstop>
|
<tabstop>uiScale</tabstop>
|
||||||
|
|
|
@ -66,6 +66,7 @@ from aqt.qt import sip
|
||||||
from aqt.sync import sync_collection, sync_login
|
from aqt.sync import sync_collection, sync_login
|
||||||
from aqt.taskman import TaskManager
|
from aqt.taskman import TaskManager
|
||||||
from aqt.theme import Theme, theme_manager
|
from aqt.theme import Theme, theme_manager
|
||||||
|
from aqt.toolbar import Toolbar, ToolbarWebView
|
||||||
from aqt.undo import UndoActionsInfo
|
from aqt.undo import UndoActionsInfo
|
||||||
from aqt.utils import (
|
from aqt.utils import (
|
||||||
HelpPage,
|
HelpPage,
|
||||||
|
@ -143,6 +144,27 @@ class MainWebView(AnkiWebView):
|
||||||
# currently safe for us to import more than one file at once
|
# currently safe for us to import more than one file at once
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Main webview specific event handling
|
||||||
|
def eventFilter(self, obj, evt):
|
||||||
|
if handled := super().eventFilter(obj, evt):
|
||||||
|
return handled
|
||||||
|
|
||||||
|
if evt.type() == QEvent.Type.Leave:
|
||||||
|
if self.mw.pm.collapse_toolbar():
|
||||||
|
# Expand toolbar when mouse moves above main webview
|
||||||
|
# and automatically collapse it with delay after mouse leaves
|
||||||
|
if self.mapFromGlobal(QCursor.pos()).y() < self.geometry().y():
|
||||||
|
if self.mw.toolbarWeb.collapsed:
|
||||||
|
self.mw.toolbarWeb.expand()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if evt.type() == QEvent.Type.Enter:
|
||||||
|
if self.mw.pm.collapse_toolbar():
|
||||||
|
self.mw.toolbarWeb.hide_timer.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class AnkiQt(QMainWindow):
|
class AnkiQt(QMainWindow):
|
||||||
col: Collection
|
col: Collection
|
||||||
|
@ -707,10 +729,16 @@ class AnkiQt(QMainWindow):
|
||||||
|
|
||||||
def _reviewState(self, oldState: MainWindowState) -> None:
|
def _reviewState(self, oldState: MainWindowState) -> None:
|
||||||
self.reviewer.show()
|
self.reviewer.show()
|
||||||
|
if self.pm.collapse_toolbar():
|
||||||
|
self.toolbarWeb.collapse()
|
||||||
|
else:
|
||||||
|
self.toolbarWeb.flatten()
|
||||||
|
|
||||||
def _reviewCleanup(self, newState: MainWindowState) -> None:
|
def _reviewCleanup(self, newState: MainWindowState) -> None:
|
||||||
if newState != "resetRequired" and newState != "review":
|
if newState != "resetRequired" and newState != "review":
|
||||||
self.reviewer.cleanup()
|
self.reviewer.cleanup()
|
||||||
|
self.toolbarWeb.elevate()
|
||||||
|
self.toolbarWeb.expand()
|
||||||
|
|
||||||
# Resetting state
|
# Resetting state
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
@ -844,10 +872,8 @@ title="{}" {}>{}</button>""".format(
|
||||||
self.form = aqt.forms.main.Ui_MainWindow()
|
self.form = aqt.forms.main.Ui_MainWindow()
|
||||||
self.form.setupUi(self)
|
self.form.setupUi(self)
|
||||||
# toolbar
|
# toolbar
|
||||||
tweb = self.toolbarWeb = AnkiWebView(title="top toolbar")
|
tweb = self.toolbarWeb = ToolbarWebView(self, title="top toolbar")
|
||||||
tweb.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
|
self.toolbar = Toolbar(self, tweb)
|
||||||
tweb.disable_zoom()
|
|
||||||
self.toolbar = aqt.toolbar.Toolbar(self, tweb)
|
|
||||||
# main area
|
# main area
|
||||||
self.web = MainWebView(self)
|
self.web = MainWebView(self)
|
||||||
# bottom area
|
# bottom area
|
||||||
|
@ -1332,6 +1358,10 @@ title="{}" {}>{}</button>""".format(
|
||||||
window.windowState() ^ Qt.WindowState.WindowFullScreen
|
window.windowState() ^ Qt.WindowState.WindowFullScreen
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def collapse_toolbar_if_allowed(self) -> None:
|
||||||
|
if self.pm.collapse_toolbar() and self.state == "review":
|
||||||
|
self.toolbarWeb.collapse()
|
||||||
|
|
||||||
# Auto update
|
# Auto update
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
|
@ -1382,13 +1412,6 @@ title="{}" {}>{}</button>""".format(
|
||||||
True,
|
True,
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
self.progress.timer(
|
|
||||||
12 * 60 * 1000,
|
|
||||||
self.refresh_certs,
|
|
||||||
repeat=True,
|
|
||||||
requiresCollection=False,
|
|
||||||
parent=self,
|
|
||||||
)
|
|
||||||
|
|
||||||
def onRefreshTimer(self) -> None:
|
def onRefreshTimer(self) -> None:
|
||||||
if self.state == "deckBrowser":
|
if self.state == "deckBrowser":
|
||||||
|
@ -1404,15 +1427,6 @@ title="{}" {}>{}</button>""".format(
|
||||||
if elap > minutes * 60:
|
if elap > minutes * 60:
|
||||||
self.maybe_auto_sync_media()
|
self.maybe_auto_sync_media()
|
||||||
|
|
||||||
def refresh_certs(self) -> None:
|
|
||||||
# The requests library copies the certs into a temporary folder on startup,
|
|
||||||
# and chokes when the file is later missing due to temp file cleaners.
|
|
||||||
# Work around the issue by accessing them once every 12 hours.
|
|
||||||
from requests.certs import where # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
with open(where(), "rb") as f:
|
|
||||||
f.read()
|
|
||||||
|
|
||||||
# Backups
|
# Backups
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
|
|
|
@ -92,6 +92,9 @@ class MPVBase:
|
||||||
"--gapless-audio=no",
|
"--gapless-audio=no",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if is_win:
|
||||||
|
default_argv += ["--af-add=lavfi=[apad=pad_dur=0.150]"]
|
||||||
|
|
||||||
def __init__(self, window_id=None, debug=False):
|
def __init__(self, window_id=None, debug=False):
|
||||||
self.window_id = window_id
|
self.window_id = window_id
|
||||||
self.debug = debug
|
self.debug = debug
|
||||||
|
@ -143,7 +146,7 @@ class MPVBase:
|
||||||
--input-unix-socket option.
|
--input-unix-socket option.
|
||||||
"""
|
"""
|
||||||
if is_win:
|
if is_win:
|
||||||
self._sock_filename = "ankimpv"
|
self._sock_filename = "ankimpv{}".format(os.getpid())
|
||||||
return
|
return
|
||||||
fd, self._sock_filename = tempfile.mkstemp(prefix="mpv.")
|
fd, self._sock_filename = tempfile.mkstemp(prefix="mpv.")
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
@ -156,12 +159,11 @@ class MPVBase:
|
||||||
start = time.time()
|
start = time.time()
|
||||||
while self.is_running() and time.time() < start + 10:
|
while self.is_running() and time.time() < start + 10:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
|
||||||
if is_win:
|
if is_win:
|
||||||
# named pipe
|
# named pipe
|
||||||
try:
|
try:
|
||||||
self._sock = win32file.CreateFile(
|
self._sock = win32file.CreateFile(
|
||||||
r"\\.\pipe\ankimpv",
|
r"\\.\pipe\{}".format(self._sock_filename),
|
||||||
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
|
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
|
||||||
0,
|
0,
|
||||||
None,
|
None,
|
||||||
|
|
|
@ -7,6 +7,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def _fix_pywin32() -> None:
|
def _fix_pywin32() -> None:
|
||||||
|
@ -49,6 +50,24 @@ def _patch_pkgutil() -> None:
|
||||||
pkgutil.get_data = get_data_custom
|
pkgutil.get_data = get_data_custom
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_certifi() -> None:
|
||||||
|
"""Tell certifi (and thus requests) to use a file in our package folder.
|
||||||
|
|
||||||
|
By default it creates a copy of the data in a temporary folder, which then gets
|
||||||
|
cleaned up by macOS's temp file cleaner."""
|
||||||
|
import certifi
|
||||||
|
|
||||||
|
def where() -> str:
|
||||||
|
prefix = Path(sys.prefix)
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
path = prefix / "../Resources/certifi/cacert.pem"
|
||||||
|
else:
|
||||||
|
path = prefix / "lib" / "certifi" / "cacert.pem"
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
certifi.where = where
|
||||||
|
|
||||||
|
|
||||||
def packaged_build_setup() -> None:
|
def packaged_build_setup() -> None:
|
||||||
if not getattr(sys, "frozen", False):
|
if not getattr(sys, "frozen", False):
|
||||||
return
|
return
|
||||||
|
@ -59,6 +78,7 @@ def packaged_build_setup() -> None:
|
||||||
_fix_pywin32()
|
_fix_pywin32()
|
||||||
|
|
||||||
_patch_pkgutil()
|
_patch_pkgutil()
|
||||||
|
_patch_certifi()
|
||||||
|
|
||||||
# escape hatch for debugging issues with packaged build startup
|
# escape hatch for debugging issues with packaged build startup
|
||||||
if os.getenv("ANKI_STARTUP_REPL"):
|
if os.getenv("ANKI_STARTUP_REPL"):
|
||||||
|
|
|
@ -208,6 +208,7 @@ class Preferences(QDialog):
|
||||||
def setup_global(self) -> None:
|
def setup_global(self) -> None:
|
||||||
"Setup options global to all profiles."
|
"Setup options global to all profiles."
|
||||||
self.form.reduce_motion.setChecked(self.mw.pm.reduced_motion())
|
self.form.reduce_motion.setChecked(self.mw.pm.reduced_motion())
|
||||||
|
self.form.collapse_toolbar.setChecked(self.mw.pm.collapse_toolbar())
|
||||||
self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100))
|
self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100))
|
||||||
themes = [
|
themes = [
|
||||||
tr.preferences_theme_label(theme=theme)
|
tr.preferences_theme_label(theme=theme)
|
||||||
|
@ -238,7 +239,7 @@ class Preferences(QDialog):
|
||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
self.mw.pm.set_reduced_motion(self.form.reduce_motion.isChecked())
|
self.mw.pm.set_reduced_motion(self.form.reduce_motion.isChecked())
|
||||||
|
self.mw.pm.set_collapse_toolbar(self.form.collapse_toolbar.isChecked())
|
||||||
self.mw.pm.set_legacy_import_export(self.form.legacy_import_export.isChecked())
|
self.mw.pm.set_legacy_import_export(self.form.legacy_import_export.isChecked())
|
||||||
|
|
||||||
if restart_required:
|
if restart_required:
|
||||||
|
|
|
@ -524,6 +524,12 @@ create table if not exists profiles
|
||||||
def set_reduced_motion(self, on: bool) -> None:
|
def set_reduced_motion(self, on: bool) -> None:
|
||||||
self.meta["reduced_motion"] = on
|
self.meta["reduced_motion"] = on
|
||||||
|
|
||||||
|
def collapse_toolbar(self) -> bool:
|
||||||
|
return self.meta.get("collapse_toolbar", False)
|
||||||
|
|
||||||
|
def set_collapse_toolbar(self, on: bool) -> None:
|
||||||
|
self.meta["collapse_toolbar"] = on
|
||||||
|
|
||||||
def last_addon_update_check(self) -> int:
|
def last_addon_update_check(self) -> int:
|
||||||
return self.meta.get("last_addon_update_check", 0)
|
return self.meta.get("last_addon_update_check", 0)
|
||||||
|
|
||||||
|
|
|
@ -543,6 +543,8 @@ class Reviewer:
|
||||||
self.showContextMenu()
|
self.showContextMenu()
|
||||||
elif url.startswith("play:"):
|
elif url.startswith("play:"):
|
||||||
play_clicked_audio(url, self.card)
|
play_clicked_audio(url, self.card)
|
||||||
|
elif url.startswith("updateToolbar"):
|
||||||
|
self.mw.toolbarWeb.update_background_image()
|
||||||
else:
|
else:
|
||||||
print("unrecognized anki link:", url)
|
print("unrecognized anki link:", url)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# Copyright: Ankitects Pty Ltd and contributors
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
# 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
|
||||||
|
|
||||||
|
from anki.utils import is_win
|
||||||
from aqt import colors, props
|
from aqt import colors, props
|
||||||
from aqt.theme import ThemeManager
|
from aqt.theme import ThemeManager
|
||||||
|
|
||||||
|
@ -101,8 +102,10 @@ QMenu::indicator {{
|
||||||
|
|
||||||
|
|
||||||
def button_styles(tm: ThemeManager) -> str:
|
def button_styles(tm: ThemeManager) -> str:
|
||||||
|
# For some reason, Windows needs a larger padding to look the same
|
||||||
|
button_pad = 25 if is_win else 15
|
||||||
return f"""
|
return f"""
|
||||||
QPushButton {{ padding-left: 15px; padding-right: 15px; }}
|
QPushButton {{ padding-left: {button_pad}px; padding-right: {button_pad}px; }}
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QTabBar::tab:!selected,
|
QTabBar::tab:!selected,
|
||||||
QComboBox:!editable,
|
QComboBox:!editable,
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
# 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
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from aqt import colors
|
from aqt import colors, props
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.theme import theme_manager
|
from aqt.theme import theme_manager
|
||||||
|
|
||||||
|
@ -173,7 +173,7 @@ class Switch(QAbstractButton):
|
||||||
|
|
||||||
def _animate_toggle(self) -> None:
|
def _animate_toggle(self) -> None:
|
||||||
animation = QPropertyAnimation(self, cast(QByteArray, b"position"), self)
|
animation = QPropertyAnimation(self, cast(QByteArray, b"position"), self)
|
||||||
animation.setDuration(100)
|
animation.setDuration(int(theme_manager.var(props.TRANSITION)))
|
||||||
animation.setStartValue(self.start_position)
|
animation.setStartValue(self.start_position)
|
||||||
animation.setEndValue(self.end_position)
|
animation.setEndValue(self.end_position)
|
||||||
# hide label during animation
|
# hide label during animation
|
||||||
|
|
|
@ -25,6 +25,7 @@ from aqt.qt import (
|
||||||
QStyleFactory,
|
QStyleFactory,
|
||||||
Qt,
|
Qt,
|
||||||
qtmajor,
|
qtmajor,
|
||||||
|
qtminor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -175,6 +176,8 @@ class ThemeManager:
|
||||||
classes.append("macos-dark-mode")
|
classes.append("macos-dark-mode")
|
||||||
if aqt.mw.pm.reduced_motion():
|
if aqt.mw.pm.reduced_motion():
|
||||||
classes.append("reduced-motion")
|
classes.append("reduced-motion")
|
||||||
|
if qtmajor == 5 and qtminor < 15:
|
||||||
|
classes.append("no-blur")
|
||||||
return " ".join(classes)
|
return " ".join(classes)
|
||||||
|
|
||||||
def body_classes_for_card_ord(
|
def body_classes_for_card_ord(
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
# 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
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
import re
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki.sync import SyncStatus
|
from anki.sync import SyncStatus
|
||||||
|
@ -25,6 +26,76 @@ class BottomToolbar:
|
||||||
self.toolbar = toolbar
|
self.toolbar = toolbar
|
||||||
|
|
||||||
|
|
||||||
|
class ToolbarWebView(AnkiWebView):
|
||||||
|
def __init__(self, mw: aqt.AnkiQt, title: str) -> None:
|
||||||
|
AnkiWebView.__init__(self, mw, title=title)
|
||||||
|
self.mw = mw
|
||||||
|
self.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
|
||||||
|
self.disable_zoom()
|
||||||
|
self.collapsed = False
|
||||||
|
self.web_height = 0
|
||||||
|
# collapse timer
|
||||||
|
self.hide_timer = QTimer()
|
||||||
|
self.hide_timer.setSingleShot(True)
|
||||||
|
self.hide_timer.setInterval(1000)
|
||||||
|
qconnect(self.hide_timer.timeout, self.mw.collapse_toolbar_if_allowed)
|
||||||
|
|
||||||
|
def eventFilter(self, obj, evt):
|
||||||
|
if handled := super().eventFilter(obj, evt):
|
||||||
|
return handled
|
||||||
|
|
||||||
|
# prevent collapse if pointer inside
|
||||||
|
if evt.type() == QEvent.Type.Enter:
|
||||||
|
self.hide_timer.stop()
|
||||||
|
self.hide_timer.setInterval(1000)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _onHeight(self, qvar: Optional[int]) -> None:
|
||||||
|
super()._onHeight(qvar)
|
||||||
|
self.web_height = int(qvar)
|
||||||
|
|
||||||
|
def collapse(self) -> None:
|
||||||
|
self.collapsed = True
|
||||||
|
self.eval("""document.body.classList.add("collapsed"); """)
|
||||||
|
|
||||||
|
def expand(self) -> None:
|
||||||
|
self.collapsed = False
|
||||||
|
self.eval("""document.body.classList.remove("collapsed"); """)
|
||||||
|
|
||||||
|
def flatten(self) -> None:
|
||||||
|
self.eval("document.body.classList.add('flat'); ")
|
||||||
|
|
||||||
|
def elevate(self) -> None:
|
||||||
|
self.eval(
|
||||||
|
"""
|
||||||
|
document.body.classList.remove("flat");
|
||||||
|
document.body.style.removeProperty("background");
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_background_image(self) -> None:
|
||||||
|
def set_background(val: str) -> None:
|
||||||
|
# remove offset from copy
|
||||||
|
background = re.sub(r"-\d+px ", "0%", val)
|
||||||
|
# change computedStyle px value back to 100vw
|
||||||
|
background = re.sub(r"\d+px", "100vw", background)
|
||||||
|
|
||||||
|
self.eval(
|
||||||
|
f"""document.body.style.setProperty("background", '{background}'); """
|
||||||
|
)
|
||||||
|
# offset reviewer background by toolbar height
|
||||||
|
self.mw.web.eval(
|
||||||
|
f"""document.body.style.setProperty("background-position-y", "-{self.web_height}px"); """
|
||||||
|
)
|
||||||
|
|
||||||
|
self.mw.web.evalWithCallback(
|
||||||
|
"""window.getComputedStyle(document.body).background; """,
|
||||||
|
set_background,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Toolbar:
|
class Toolbar:
|
||||||
def __init__(self, mw: aqt.AnkiQt, web: AnkiWebView) -> None:
|
def __init__(self, mw: aqt.AnkiQt, web: AnkiWebView) -> None:
|
||||||
self.mw = mw
|
self.mw = mw
|
||||||
|
@ -32,7 +103,6 @@ class Toolbar:
|
||||||
self.link_handlers: dict[str, Callable] = {
|
self.link_handlers: dict[str, Callable] = {
|
||||||
"study": self._studyLinkHandler,
|
"study": self._studyLinkHandler,
|
||||||
}
|
}
|
||||||
self.web.setFixedHeight(30)
|
|
||||||
self.web.requiresCol = False
|
self.web.requiresCol = False
|
||||||
|
|
||||||
def draw(
|
def draw(
|
||||||
|
@ -44,8 +114,13 @@ class Toolbar:
|
||||||
web_context = web_context or TopToolbar(self)
|
web_context = web_context or TopToolbar(self)
|
||||||
link_handler = link_handler or self._linkHandler
|
link_handler = link_handler or self._linkHandler
|
||||||
self.web.set_bridge_command(link_handler, web_context)
|
self.web.set_bridge_command(link_handler, web_context)
|
||||||
|
body = self._body.format(
|
||||||
|
toolbar_content=self._centerLinks(),
|
||||||
|
left_tray_content=self._left_tray_content(),
|
||||||
|
right_tray_content=self._right_tray_content(),
|
||||||
|
)
|
||||||
self.web.stdHtml(
|
self.web.stdHtml(
|
||||||
self._body % self._centerLinks(),
|
body,
|
||||||
css=["css/toolbar.css"],
|
css=["css/toolbar.css"],
|
||||||
js=["js/vendor/jquery.min.js", "js/toolbar.js"],
|
js=["js/vendor/jquery.min.js", "js/toolbar.js"],
|
||||||
context=web_context,
|
context=web_context,
|
||||||
|
@ -134,6 +209,22 @@ class Toolbar:
|
||||||
|
|
||||||
return "\n".join(links)
|
return "\n".join(links)
|
||||||
|
|
||||||
|
# Add-ons
|
||||||
|
######################################################################
|
||||||
|
|
||||||
|
def _left_tray_content(self) -> str:
|
||||||
|
left_tray_content: list[str] = []
|
||||||
|
gui_hooks.top_toolbar_will_set_left_tray_content(left_tray_content, self)
|
||||||
|
return self._process_tray_content(left_tray_content)
|
||||||
|
|
||||||
|
def _right_tray_content(self) -> str:
|
||||||
|
right_tray_content: list[str] = []
|
||||||
|
gui_hooks.top_toolbar_will_set_right_tray_content(right_tray_content, self)
|
||||||
|
return self._process_tray_content(right_tray_content)
|
||||||
|
|
||||||
|
def _process_tray_content(self, content: list[str]) -> str:
|
||||||
|
return "\n".join(f"""<div class="tray-item">{item}</div>""" for item in content)
|
||||||
|
|
||||||
# Sync
|
# Sync
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
|
@ -195,12 +286,11 @@ class Toolbar:
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
_body = """
|
_body = """
|
||||||
<center id=outer>
|
<div class="header">
|
||||||
<table id=header>
|
<div class="left-tray">{left_tray_content}</div>
|
||||||
<tr>
|
<div class="toolbar">{toolbar_content}</div>
|
||||||
<td class=tdcenter align=center>%s</td>
|
<div class="right-tray">{right_tray_content}</div>
|
||||||
</tr></table>
|
</div>
|
||||||
</center>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -634,7 +634,6 @@ html {{ {font} }}
|
||||||
from aqt import mw
|
from aqt import mw
|
||||||
|
|
||||||
if qvar is None:
|
if qvar is None:
|
||||||
|
|
||||||
mw.progress.single_shot(1000, mw.reset)
|
mw.progress.single_shot(1000, mw.reset)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
@ -132,7 +132,7 @@ fn make_app(kind: DistKind, mut plist: plist::Dictionary, stamp: &Utf8Path) -> R
|
||||||
let path_str = relative_path.to_str().unwrap();
|
let path_str = relative_path.to_str().unwrap();
|
||||||
if path_str.contains("libankihelper") {
|
if path_str.contains("libankihelper") {
|
||||||
builder.add_file_macos("libankihelper.dylib", entry)?;
|
builder.add_file_macos("libankihelper.dylib", entry)?;
|
||||||
} else if path_str.contains("aqt/data") {
|
} else if path_str.contains("aqt/data") || path_str.contains("certifi") {
|
||||||
builder.add_file_resources(relative_path.strip_prefix("lib").unwrap(), entry)?;
|
builder.add_file_resources(relative_path.strip_prefix("lib").unwrap(), entry)?;
|
||||||
} else {
|
} else {
|
||||||
if path_str.contains("__pycache__") {
|
if path_str.contains("__pycache__") {
|
||||||
|
|
|
@ -55,6 +55,8 @@ def handle_resource(policy, resource):
|
||||||
for prefix in included_resource_packages:
|
for prefix in included_resource_packages:
|
||||||
if resource.package.startswith(prefix):
|
if resource.package.startswith(prefix):
|
||||||
resource.add_include = True
|
resource.add_include = True
|
||||||
|
if resource.package == "certifi":
|
||||||
|
resource.add_location = "filesystem-relative:lib"
|
||||||
for suffix in excluded_resource_suffixes:
|
for suffix in excluded_resource_suffixes:
|
||||||
if resource.name.endswith(suffix):
|
if resource.name.endswith(suffix):
|
||||||
resource.add_include = False
|
resource.add_include = False
|
||||||
|
|
|
@ -47,10 +47,14 @@ for line in re.split(r"[;\{\}]|\*\/", data):
|
||||||
print("failed to match", line)
|
print("failed to match", line)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# convert variable names to Qt style
|
||||||
var = m.group(1).replace("-", "_").upper()
|
var = m.group(1).replace("-", "_").upper()
|
||||||
val = m.group(2)
|
val = m.group(2)
|
||||||
|
|
||||||
if reached_props:
|
if reached_props:
|
||||||
|
# remove trailing ms from time props
|
||||||
|
val = re.sub(r"^(\d+)ms$", r"\1", val)
|
||||||
|
|
||||||
if not var in props:
|
if not var in props:
|
||||||
props.setdefault(var, {})["comment"] = comment
|
props.setdefault(var, {})["comment"] = comment
|
||||||
props[var]["light"] = val
|
props[var]["light"] = val
|
||||||
|
|
|
@ -795,6 +795,36 @@ gui_hooks.webview_did_inject_style_into_page.append(mytest)
|
||||||
links.append(my_link)
|
links.append(my_link)
|
||||||
""",
|
""",
|
||||||
),
|
),
|
||||||
|
Hook(
|
||||||
|
name="top_toolbar_will_set_left_tray_content",
|
||||||
|
args=["content: list[str]", "top_toolbar: aqt.toolbar.Toolbar"],
|
||||||
|
doc="""Used to add custom add-on components to the *left* area of Anki's main
|
||||||
|
window toolbar
|
||||||
|
|
||||||
|
'content' is a list of HTML strings added by add-ons which you can append your
|
||||||
|
own components or elements to. To equip your components with logic and styling
|
||||||
|
please see `webview_will_set_content` and `webview_did_receive_js_message`.
|
||||||
|
|
||||||
|
Please note that Anki's main screen is due to undergo a significant refactor
|
||||||
|
in the future and, as a result, add-ons subscribing to this hook will likely
|
||||||
|
require changes to continue working.
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
Hook(
|
||||||
|
name="top_toolbar_will_set_right_tray_content",
|
||||||
|
args=["content: list[str]", "top_toolbar: aqt.toolbar.Toolbar"],
|
||||||
|
doc="""Used to add custom add-on components to the *right* area of Anki's main
|
||||||
|
window toolbar
|
||||||
|
|
||||||
|
'content' is a list of HTML strings added by add-ons which you can append your
|
||||||
|
own components or elements to. To equip your components with logic and styling
|
||||||
|
please see `webview_will_set_content` and `webview_did_receive_js_message`.
|
||||||
|
|
||||||
|
Please note that Anki's main screen is due to undergo a significant refactor
|
||||||
|
in the future and, as a result, add-ons subscribing to this hook will likely
|
||||||
|
require changes to continue working.
|
||||||
|
""",
|
||||||
|
),
|
||||||
Hook(
|
Hook(
|
||||||
name="top_toolbar_did_redraw",
|
name="top_toolbar_did_redraw",
|
||||||
args=["top_toolbar: aqt.toolbar.Toolbar"],
|
args=["top_toolbar: aqt.toolbar.Toolbar"],
|
||||||
|
|
|
@ -32,7 +32,7 @@ which = "4.3.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
env_logger = "0.10.0"
|
env_logger = "0.10.0"
|
||||||
tokio = { version = "1.22", features = ["macros"] }
|
tokio = { version = "1.23", features = ["macros"] }
|
||||||
|
|
||||||
[dependencies.reqwest]
|
[dependencies.reqwest]
|
||||||
version = "=0.11.3"
|
version = "=0.11.3"
|
||||||
|
@ -54,7 +54,7 @@ ammonia = "3.3.0"
|
||||||
async-trait = "0.1.59"
|
async-trait = "0.1.59"
|
||||||
blake3 = "1.3.3"
|
blake3 = "1.3.3"
|
||||||
bytes = "1.3.0"
|
bytes = "1.3.0"
|
||||||
chrono = { version = "0.4.23", default-features = false, features = ["std", "clock"] }
|
chrono = { version = "0.4.19", default-features = false, features = ["std", "clock"] }
|
||||||
coarsetime = "0.1.22"
|
coarsetime = "0.1.22"
|
||||||
convert_case = "0.6.0"
|
convert_case = "0.6.0"
|
||||||
dissimilar = "1.0.4"
|
dissimilar = "1.0.4"
|
||||||
|
@ -90,7 +90,7 @@ sha1 = "0.10.5"
|
||||||
snafu = { version = "0.7.3", features = ["backtraces"] }
|
snafu = { version = "0.7.3", features = ["backtraces"] }
|
||||||
strum = { version = "0.24.1", features = ["derive"] }
|
strum = { version = "0.24.1", features = ["derive"] }
|
||||||
tempfile = "3.3.0"
|
tempfile = "3.3.0"
|
||||||
tokio = { version = "1.22", features = ["fs", "rt-multi-thread"] }
|
tokio = { version = "1.23", features = ["fs", "rt-multi-thread"] }
|
||||||
tokio-util = { version = "0.7.4", features = ["io"] }
|
tokio-util = { version = "0.7.4", features = ["io"] }
|
||||||
tracing = { version = "0.1.37", features = ["max_level_trace", "release_max_level_debug"] }
|
tracing = { version = "0.1.37", features = ["max_level_trace", "release_max_level_debug"] }
|
||||||
tracing-appender = "0.2.2"
|
tracing-appender = "0.2.2"
|
||||||
|
|
|
@ -20,7 +20,7 @@ linkcheck = { git = "https://github.com/ankitects/linkcheck.git", rev = "2f20798
|
||||||
futures = "0.3.25"
|
futures = "0.3.25"
|
||||||
itertools = "0.10.5"
|
itertools = "0.10.5"
|
||||||
strum = { version = "0.24.1", features = ["derive"] }
|
strum = { version = "0.24.1", features = ["derive"] }
|
||||||
tokio = { version = "1.22.0", features = ["full"] }
|
tokio = { version = "1.23.1", features = ["full"] }
|
||||||
workspace-hack = { version = "0.1", path = "../../tools/workspace-hack" }
|
workspace-hack = { version = "0.1", path = "../../tools/workspace-hack" }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|
436
rslib/src/backend/ankidroid/db.rs
Normal file
436
rslib/src/backend/ankidroid/db.rs
Normal file
|
@ -0,0 +1,436 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
mem::size_of,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicI32, Ordering},
|
||||||
|
Mutex,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use itertools::{
|
||||||
|
FoldWhile,
|
||||||
|
FoldWhile::{Continue, Done},
|
||||||
|
Itertools,
|
||||||
|
};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use rusqlite::ToSql;
|
||||||
|
use serde_derive::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::Collection,
|
||||||
|
error::Result,
|
||||||
|
pb::ankidroid::{sql_value::Data, DbResponse, DbResult, Row, SqlValue},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A pointer to the SqliteStorage object stored in a collection, used to
|
||||||
|
/// uniquely index results from multiple open collections at once.
|
||||||
|
impl Collection {
|
||||||
|
fn id_for_db_cache(&self) -> CollectionId {
|
||||||
|
CollectionId((&self.storage as *const _) as i64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Hash, PartialEq, Eq)]
|
||||||
|
struct CollectionId(i64);
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DBArgs {
|
||||||
|
sql: String,
|
||||||
|
args: Vec<crate::backend::dbproxy::SqlValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Sizable {
|
||||||
|
/** Estimates the heap size of the value, in bytes */
|
||||||
|
fn estimate_size(&self) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sizable for Data {
|
||||||
|
fn estimate_size(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Data::StringValue(s) => s.len(),
|
||||||
|
Data::LongValue(_) => size_of::<i64>(),
|
||||||
|
Data::DoubleValue(_) => size_of::<f64>(),
|
||||||
|
Data::BlobValue(b) => b.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sizable for SqlValue {
|
||||||
|
fn estimate_size(&self) -> usize {
|
||||||
|
// Add a byte for the optional
|
||||||
|
self.data
|
||||||
|
.as_ref()
|
||||||
|
.map(|f| f.estimate_size() + 1)
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sizable for Row {
|
||||||
|
fn estimate_size(&self) -> usize {
|
||||||
|
self.fields.iter().map(|x| x.estimate_size()).sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sizable for DbResult {
|
||||||
|
fn estimate_size(&self) -> usize {
|
||||||
|
// Performance: It might be best to take the first x rows and determine the data types
|
||||||
|
// If we have floats or longs, they'll be a fixed size (excluding nulls) and should speed
|
||||||
|
// up the calculation as we'll only calculate a subset of the columns.
|
||||||
|
self.rows.iter().map(|x| x.estimate_size()).sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn select_next_slice<'a>(rows: impl Iterator<Item = &'a Row>) -> Vec<Row> {
|
||||||
|
select_slice_of_size(rows, get_max_page_size())
|
||||||
|
.into_inner()
|
||||||
|
.1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_slice_of_size<'a>(
|
||||||
|
mut rows: impl Iterator<Item = &'a Row>,
|
||||||
|
max_size: usize,
|
||||||
|
) -> FoldWhile<(usize, Vec<Row>)> {
|
||||||
|
let init: Vec<Row> = Vec::new();
|
||||||
|
rows.fold_while((0, init), |mut acc, x| {
|
||||||
|
let new_size = acc.0 + x.estimate_size();
|
||||||
|
// If the accumulator is 0, but we're over the size: return a single result so we don't loop forever.
|
||||||
|
// Theoretically, this shouldn't happen as data should be reasonably sized
|
||||||
|
if new_size > max_size && acc.0 > 0 {
|
||||||
|
Done(acc)
|
||||||
|
} else {
|
||||||
|
// PERF: should be faster to return (size, numElements) then bulk copy/slice
|
||||||
|
acc.1.push(x.to_owned());
|
||||||
|
Continue((new_size, acc.1))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SequenceNumber = i32;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref HASHMAP: Mutex<HashMap<CollectionId, HashMap<SequenceNumber, DbResponse>>> =
|
||||||
|
Mutex::new(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn flush_single_result(col: &Collection, sequence_number: i32) {
|
||||||
|
HASHMAP
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get_mut(&col.id_for_db_cache())
|
||||||
|
.map(|storage| storage.remove(&sequence_number));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn flush_collection(col: &Collection) {
|
||||||
|
HASHMAP.lock().unwrap().remove(&col.id_for_db_cache());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn active_sequences(col: &Collection) -> Vec<i32> {
|
||||||
|
HASHMAP
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&col.id_for_db_cache())
|
||||||
|
.map(|h| h.keys().copied().collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Store the data in the cache if larger than than the page size.<br/>
|
||||||
|
Returns: The data capped to the page size
|
||||||
|
*/
|
||||||
|
pub(crate) fn trim_and_cache_remaining(
|
||||||
|
col: &Collection,
|
||||||
|
values: DbResult,
|
||||||
|
sequence_number: i32,
|
||||||
|
) -> DbResponse {
|
||||||
|
let start_index = 0;
|
||||||
|
|
||||||
|
// PERF: Could speed this up by not creating the vector and just calculating the count
|
||||||
|
let first_result = select_next_slice(values.rows.iter());
|
||||||
|
|
||||||
|
let row_count = values.rows.len() as i32;
|
||||||
|
if first_result.len() < values.rows.len() {
|
||||||
|
let to_store = DbResponse {
|
||||||
|
result: Some(values),
|
||||||
|
sequence_number,
|
||||||
|
row_count,
|
||||||
|
start_index,
|
||||||
|
};
|
||||||
|
insert_cache(col, to_store);
|
||||||
|
|
||||||
|
DbResponse {
|
||||||
|
result: Some(DbResult { rows: first_result }),
|
||||||
|
sequence_number,
|
||||||
|
row_count,
|
||||||
|
start_index,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DbResponse {
|
||||||
|
result: Some(values),
|
||||||
|
sequence_number,
|
||||||
|
row_count,
|
||||||
|
start_index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_cache(col: &Collection, result: DbResponse) {
|
||||||
|
HASHMAP
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(col.id_for_db_cache())
|
||||||
|
.or_default()
|
||||||
|
.insert(result.sequence_number, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_next(
|
||||||
|
col: &Collection,
|
||||||
|
sequence_number: i32,
|
||||||
|
start_index: i64,
|
||||||
|
) -> Option<DbResponse> {
|
||||||
|
let result = get_next_result(col, &sequence_number, start_index);
|
||||||
|
|
||||||
|
if let Some(resp) = result.as_ref() {
|
||||||
|
if resp.result.is_none() || resp.result.as_ref().unwrap().rows.is_empty() {
|
||||||
|
flush_single_result(col, sequence_number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_next_result(
|
||||||
|
col: &Collection,
|
||||||
|
sequence_number: &i32,
|
||||||
|
start_index: i64,
|
||||||
|
) -> Option<DbResponse> {
|
||||||
|
let map = HASHMAP.lock().unwrap();
|
||||||
|
let result_map = map.get(&col.id_for_db_cache())?;
|
||||||
|
let current_result = result_map.get(sequence_number)?;
|
||||||
|
|
||||||
|
// TODO: This shouldn't need to exist
|
||||||
|
let tmp: Vec<Row> = Vec::new();
|
||||||
|
let next_rows = current_result
|
||||||
|
.result
|
||||||
|
.as_ref()
|
||||||
|
.map(|x| x.rows.iter())
|
||||||
|
.unwrap_or_else(|| tmp.iter());
|
||||||
|
|
||||||
|
let skipped_rows = next_rows.clone().skip(start_index as usize).collect_vec();
|
||||||
|
println!("{}", skipped_rows.len());
|
||||||
|
|
||||||
|
let filtered_rows = select_next_slice(next_rows.skip(start_index as usize));
|
||||||
|
|
||||||
|
let result = DbResult {
|
||||||
|
rows: filtered_rows,
|
||||||
|
};
|
||||||
|
|
||||||
|
let trimmed_result = DbResponse {
|
||||||
|
result: Some(result),
|
||||||
|
sequence_number: current_result.sequence_number,
|
||||||
|
row_count: current_result.row_count,
|
||||||
|
start_index,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(trimmed_result)
|
||||||
|
}
|
||||||
|
|
||||||
|
static SEQUENCE_NUMBER: AtomicI32 = AtomicI32::new(0);
|
||||||
|
|
||||||
|
pub(crate) fn next_sequence_number() -> i32 {
|
||||||
|
SEQUENCE_NUMBER.fetch_add(1, Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
// same as we get from io.requery.android.database.CursorWindow.sCursorWindowSize
|
||||||
|
static ref DB_COMMAND_PAGE_SIZE: Mutex<usize> = Mutex::new(1024 * 1024 * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_max_page_size(size: usize) {
|
||||||
|
let mut state = DB_COMMAND_PAGE_SIZE.lock().expect("Could not lock mutex");
|
||||||
|
*state = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_max_page_size() -> usize {
|
||||||
|
*DB_COMMAND_PAGE_SIZE.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_args(in_bytes: &[u8]) -> Result<DBArgs> {
|
||||||
|
let ret: DBArgs = serde_json::from_slice(in_bytes)?;
|
||||||
|
Ok(ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn insert_for_id(col: &Collection, json: &[u8]) -> Result<i64> {
|
||||||
|
let req = get_args(json)?;
|
||||||
|
let args: Vec<_> = req.args.iter().map(|a| a as &dyn ToSql).collect();
|
||||||
|
col.storage.db.execute(&req.sql, &args[..])?;
|
||||||
|
Ok(col.storage.db.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn execute_for_row_count(col: &Collection, req: &[u8]) -> Result<i64> {
|
||||||
|
let req = get_args(req)?;
|
||||||
|
let args: Vec<_> = req.args.iter().map(|a| a as &dyn ToSql).collect();
|
||||||
|
let count = col.storage.db.execute(&req.sql, &args[..])?;
|
||||||
|
Ok(count as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
backend::ankidroid::db::{select_slice_of_size, Sizable},
|
||||||
|
collection::open_test_collection,
|
||||||
|
pb::ankidroid::{sql_value, Row, SqlValue},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn gen_data() -> Vec<SqlValue> {
|
||||||
|
vec![
|
||||||
|
SqlValue {
|
||||||
|
data: Some(sql_value::Data::DoubleValue(12.0)),
|
||||||
|
},
|
||||||
|
SqlValue {
|
||||||
|
data: Some(sql_value::Data::LongValue(12)),
|
||||||
|
},
|
||||||
|
SqlValue {
|
||||||
|
data: Some(sql_value::Data::StringValue(
|
||||||
|
"Hellooooooo World".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
SqlValue {
|
||||||
|
data: Some(sql_value::Data::BlobValue(vec![])),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_size_estimate() {
|
||||||
|
let row = Row { fields: gen_data() };
|
||||||
|
let result = DbResult {
|
||||||
|
rows: vec![row.clone(), row],
|
||||||
|
};
|
||||||
|
|
||||||
|
let actual_size = result.estimate_size();
|
||||||
|
|
||||||
|
let expected_size = (17 + 8 + 8) * 2; // 1 variable string, 1 long, 1 float
|
||||||
|
let expected_overhead = 4 * 2; // 4 optional columns
|
||||||
|
|
||||||
|
assert_eq!(actual_size, expected_overhead + expected_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stream_size() {
|
||||||
|
let row = Row { fields: gen_data() };
|
||||||
|
let result = DbResult {
|
||||||
|
rows: vec![row.clone(), row.clone(), row],
|
||||||
|
};
|
||||||
|
let limit = 74 + 1; // two rows are 74
|
||||||
|
|
||||||
|
let result = select_slice_of_size(result.rows.iter(), limit).into_inner();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
2,
|
||||||
|
result.1.len(),
|
||||||
|
"The final element should not be included"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
74, result.0,
|
||||||
|
"The size should be the size of the first two objects"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stream_size_too_small() {
|
||||||
|
let row = Row { fields: gen_data() };
|
||||||
|
let result = DbResult { rows: vec![row] };
|
||||||
|
let limit = 1;
|
||||||
|
|
||||||
|
let result = select_slice_of_size(result.rows.iter(), limit).into_inner();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
1,
|
||||||
|
result.1.len(),
|
||||||
|
"If the limit is too small, a result is still returned"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
37, result.0,
|
||||||
|
"The size should be the size of the first objects"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEQUENCE_NUMBER: i32 = 1;
|
||||||
|
|
||||||
|
fn get(col: &Collection, index: i64) -> Option<DbResponse> {
|
||||||
|
get_next(col, SEQUENCE_NUMBER, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_first(col: &Collection, result: DbResult) -> DbResponse {
|
||||||
|
trim_and_cache_remaining(col, result, SEQUENCE_NUMBER)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seq_number_used(col: &Collection) -> bool {
|
||||||
|
HASHMAP
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&col.id_for_db_cache())
|
||||||
|
.unwrap()
|
||||||
|
.contains_key(&SEQUENCE_NUMBER)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integration_test() {
|
||||||
|
let col = open_test_collection();
|
||||||
|
|
||||||
|
let row = Row { fields: gen_data() };
|
||||||
|
|
||||||
|
// return one row at a time
|
||||||
|
set_max_page_size(row.estimate_size() - 1);
|
||||||
|
|
||||||
|
let db_query_result = DbResult {
|
||||||
|
rows: vec![row.clone(), row],
|
||||||
|
};
|
||||||
|
|
||||||
|
let first_jni_response = get_first(&col, db_query_result);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
row_count(&first_jni_response),
|
||||||
|
1,
|
||||||
|
"The first call should only return one row"
|
||||||
|
);
|
||||||
|
|
||||||
|
let next_index = first_jni_response.start_index + row_count(&first_jni_response);
|
||||||
|
|
||||||
|
let second_response = get(&col, next_index);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
second_response.is_some(),
|
||||||
|
"The second response should return a value"
|
||||||
|
);
|
||||||
|
let valid_second_response = second_response.unwrap();
|
||||||
|
assert_eq!(row_count(&valid_second_response), 1);
|
||||||
|
|
||||||
|
let final_index = valid_second_response.start_index + row_count(&valid_second_response);
|
||||||
|
|
||||||
|
assert!(seq_number_used(&col), "The sequence number is assigned");
|
||||||
|
|
||||||
|
let final_response = get(&col, final_index);
|
||||||
|
assert!(
|
||||||
|
final_response.is_some(),
|
||||||
|
"The third call should return something with no rows"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
row_count(&final_response.unwrap()),
|
||||||
|
0,
|
||||||
|
"The third call should return something with no rows"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!seq_number_used(&col),
|
||||||
|
"Sequence number data has been cleared"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_count(resp: &DbResponse) -> i64 {
|
||||||
|
resp.result.as_ref().map(|x| x.rows.len()).unwrap_or(0) as i64
|
||||||
|
}
|
||||||
|
}
|
139
rslib/src/backend/ankidroid/error.rs
Normal file
139
rslib/src/backend/ankidroid/error.rs
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::{
|
||||||
|
DbError, DbErrorKind as DB, FilteredDeckError, InvalidInputError, NetworkError,
|
||||||
|
NetworkErrorKind as Net, NotFoundError, SearchErrorKind, SyncError, SyncErrorKind as Sync,
|
||||||
|
},
|
||||||
|
prelude::AnkiError,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) fn debug_produce_error(s: &str) -> AnkiError {
|
||||||
|
let info = "error_value".to_string();
|
||||||
|
match s {
|
||||||
|
"TemplateError" => AnkiError::TemplateError { info },
|
||||||
|
"DbErrorFileTooNew" => AnkiError::DbError {
|
||||||
|
source: DbError {
|
||||||
|
info,
|
||||||
|
kind: DB::FileTooNew,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"DbErrorFileTooOld" => AnkiError::DbError {
|
||||||
|
source: DbError {
|
||||||
|
info,
|
||||||
|
kind: DB::FileTooOld,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"DbErrorMissingEntity" => AnkiError::DbError {
|
||||||
|
source: DbError {
|
||||||
|
info,
|
||||||
|
kind: DB::MissingEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"DbErrorCorrupt" => AnkiError::DbError {
|
||||||
|
source: DbError {
|
||||||
|
info,
|
||||||
|
kind: DB::Corrupt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"DbErrorLocked" => AnkiError::DbError {
|
||||||
|
source: DbError {
|
||||||
|
info,
|
||||||
|
kind: DB::Locked,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"DbErrorOther" => AnkiError::DbError {
|
||||||
|
source: DbError {
|
||||||
|
info,
|
||||||
|
kind: DB::Other,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"NetworkError" => AnkiError::NetworkError {
|
||||||
|
source: NetworkError {
|
||||||
|
info,
|
||||||
|
kind: Net::Offline,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"SyncErrorConflict" => AnkiError::SyncError {
|
||||||
|
source: SyncError {
|
||||||
|
info,
|
||||||
|
kind: Sync::Conflict,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"SyncErrorServerError" => AnkiError::SyncError {
|
||||||
|
source: SyncError {
|
||||||
|
info,
|
||||||
|
kind: Sync::ServerError,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"SyncErrorClientTooOld" => AnkiError::SyncError {
|
||||||
|
source: SyncError {
|
||||||
|
info,
|
||||||
|
kind: Sync::ClientTooOld,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"SyncErrorAuthFailed" => AnkiError::SyncError {
|
||||||
|
source: SyncError {
|
||||||
|
info,
|
||||||
|
kind: Sync::AuthFailed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"SyncErrorServerMessage" => AnkiError::SyncError {
|
||||||
|
source: SyncError {
|
||||||
|
info,
|
||||||
|
kind: Sync::ServerMessage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"SyncErrorClockIncorrect" => AnkiError::SyncError {
|
||||||
|
source: SyncError {
|
||||||
|
info,
|
||||||
|
kind: Sync::ClockIncorrect,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"SyncErrorOther" => AnkiError::SyncError {
|
||||||
|
source: SyncError {
|
||||||
|
info,
|
||||||
|
kind: Sync::Other,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"SyncErrorResyncRequired" => AnkiError::SyncError {
|
||||||
|
source: SyncError {
|
||||||
|
info,
|
||||||
|
kind: Sync::ResyncRequired,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"SyncErrorDatabaseCheckRequired" => AnkiError::SyncError {
|
||||||
|
source: SyncError {
|
||||||
|
info,
|
||||||
|
kind: Sync::DatabaseCheckRequired,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"JSONError" => AnkiError::JsonError { info },
|
||||||
|
"ProtoError" => AnkiError::ProtoError { info },
|
||||||
|
"Interrupted" => AnkiError::Interrupted,
|
||||||
|
"CollectionNotOpen" => AnkiError::CollectionNotOpen,
|
||||||
|
"CollectionAlreadyOpen" => AnkiError::CollectionAlreadyOpen,
|
||||||
|
"NotFound" => AnkiError::NotFound {
|
||||||
|
source: NotFoundError {
|
||||||
|
type_name: "".to_string(),
|
||||||
|
identifier: "".to_string(),
|
||||||
|
backtrace: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Existing" => AnkiError::Existing,
|
||||||
|
"FilteredDeckError" => AnkiError::FilteredDeckError {
|
||||||
|
source: FilteredDeckError::FilteredDeckRequired,
|
||||||
|
},
|
||||||
|
"SearchError" => AnkiError::SearchError {
|
||||||
|
source: SearchErrorKind::EmptyGroup,
|
||||||
|
},
|
||||||
|
_ => AnkiError::InvalidInput {
|
||||||
|
source: InvalidInputError {
|
||||||
|
message: info,
|
||||||
|
source: None,
|
||||||
|
backtrace: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
117
rslib/src/backend/ankidroid/mod.rs
Normal file
117
rslib/src/backend/ankidroid/mod.rs
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
pub(crate) mod db;
|
||||||
|
pub(crate) mod error;
|
||||||
|
|
||||||
|
use self::{db::active_sequences, error::debug_produce_error};
|
||||||
|
use super::{
|
||||||
|
dbproxy::{db_command_bytes, db_command_proto},
|
||||||
|
Backend,
|
||||||
|
};
|
||||||
|
pub(super) use crate::pb::ankidroid::ankidroid_service::Service as AnkidroidService;
|
||||||
|
use crate::{
|
||||||
|
backend::ankidroid::db::{execute_for_row_count, insert_for_id},
|
||||||
|
pb::{
|
||||||
|
self as pb,
|
||||||
|
ankidroid::{DbResponse, GetActiveSequenceNumbersResponse, GetNextResultPageRequest},
|
||||||
|
generic::{self, Empty, Int32, Json},
|
||||||
|
},
|
||||||
|
prelude::*,
|
||||||
|
scheduler::timing::{self, fixed_offset_from_minutes},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl AnkidroidService for Backend {
|
||||||
|
fn sched_timing_today_legacy(
|
||||||
|
&self,
|
||||||
|
input: pb::ankidroid::SchedTimingTodayLegacyRequest,
|
||||||
|
) -> Result<pb::scheduler::SchedTimingTodayResponse> {
|
||||||
|
let result = timing::sched_timing_today(
|
||||||
|
TimestampSecs::from(input.created_secs),
|
||||||
|
TimestampSecs::from(input.now_secs),
|
||||||
|
input.created_mins_west.map(fixed_offset_from_minutes),
|
||||||
|
fixed_offset_from_minutes(input.now_mins_west),
|
||||||
|
Some(input.rollover_hour as u8),
|
||||||
|
)?;
|
||||||
|
Ok(pb::scheduler::SchedTimingTodayResponse::from(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_minutes_west_legacy(&self, input: pb::generic::Int64) -> Result<pb::generic::Int32> {
|
||||||
|
Ok(pb::generic::Int32 {
|
||||||
|
val: timing::local_minutes_west_for_stamp(input.val.into())?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_db_command(&self, input: Json) -> Result<Json> {
|
||||||
|
self.with_col(|col| db_command_bytes(col, &input.json))
|
||||||
|
.map(|json| Json { json })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_db_command_proto(&self, input: Json) -> Result<DbResponse> {
|
||||||
|
self.with_col(|col| db_command_proto(col, &input.json))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_db_command_for_row_count(&self, input: Json) -> Result<pb::generic::Int64> {
|
||||||
|
self.with_col(|col| execute_for_row_count(col, &input.json))
|
||||||
|
.map(|val| pb::generic::Int64 { val })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush_all_queries(&self, _input: Empty) -> Result<Empty> {
|
||||||
|
self.with_col(|col| {
|
||||||
|
db::flush_collection(col);
|
||||||
|
Ok(Empty {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush_query(&self, input: Int32) -> Result<Empty> {
|
||||||
|
self.with_col(|col| {
|
||||||
|
db::flush_single_result(col, input.val);
|
||||||
|
Ok(Empty {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_next_result_page(&self, input: GetNextResultPageRequest) -> Result<DbResponse> {
|
||||||
|
self.with_col(|col| {
|
||||||
|
db::get_next(col, input.sequence, input.index).or_invalid("missing result page")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_for_id(&self, input: Json) -> Result<pb::generic::Int64> {
|
||||||
|
self.with_col(|col| insert_for_id(col, &input.json).map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_page_size(&self, input: pb::generic::Int64) -> Result<Empty> {
|
||||||
|
// we don't require an open collection, but should avoid modifying this
|
||||||
|
// concurrently
|
||||||
|
let _guard = self.col.lock();
|
||||||
|
db::set_max_page_size(input.val as usize);
|
||||||
|
Ok(().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_column_names_from_query(
|
||||||
|
&self,
|
||||||
|
input: generic::String,
|
||||||
|
) -> Result<pb::generic::StringList> {
|
||||||
|
self.with_col(|col| {
|
||||||
|
let stmt = col.storage.db.prepare(&input.val)?;
|
||||||
|
let names = stmt.column_names();
|
||||||
|
let names: Vec<_> = names.iter().map(ToString::to_string).collect();
|
||||||
|
Ok(names.into())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_active_sequence_numbers(
|
||||||
|
&self,
|
||||||
|
_input: Empty,
|
||||||
|
) -> Result<GetActiveSequenceNumbersResponse> {
|
||||||
|
self.with_col(|col| {
|
||||||
|
Ok(GetActiveSequenceNumbersResponse {
|
||||||
|
numbers: active_sequences(col),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_produce_error(&self, input: generic::String) -> Result<Empty> {
|
||||||
|
Err(debug_produce_error(&input.val))
|
||||||
|
}
|
||||||
|
}
|
|
@ -34,6 +34,7 @@ impl CollectionService for Backend {
|
||||||
|
|
||||||
let mut builder = CollectionBuilder::new(input.collection_path);
|
let mut builder = CollectionBuilder::new(input.collection_path);
|
||||||
builder
|
builder
|
||||||
|
.set_force_schema11(input.force_schema11)
|
||||||
.set_media_paths(input.media_folder_path, input.media_db_path)
|
.set_media_paths(input.media_folder_path, input.media_db_path)
|
||||||
.set_server(self.server)
|
.set_server(self.server)
|
||||||
.set_tr(self.tr.clone());
|
.set_tr(self.tr.clone());
|
||||||
|
|
|
@ -8,7 +8,14 @@ use rusqlite::{
|
||||||
};
|
};
|
||||||
use serde_derive::{Deserialize, Serialize};
|
use serde_derive::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{prelude::*, storage::SqliteStorage};
|
use crate::{
|
||||||
|
pb,
|
||||||
|
pb::ankidroid::{
|
||||||
|
sql_value::Data, DbResponse, DbResult as ProtoDbResult, Row, SqlValue as pb_SqlValue,
|
||||||
|
},
|
||||||
|
prelude::*,
|
||||||
|
storage::SqliteStorage,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
|
@ -57,6 +64,42 @@ impl ToSql for SqlValue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<&SqlValue> for pb::ankidroid::SqlValue {
|
||||||
|
fn from(item: &SqlValue) -> Self {
|
||||||
|
match item {
|
||||||
|
SqlValue::Null => pb_SqlValue { data: Option::None },
|
||||||
|
SqlValue::String(s) => pb_SqlValue {
|
||||||
|
data: Some(Data::StringValue(s.to_string())),
|
||||||
|
},
|
||||||
|
SqlValue::Int(i) => pb_SqlValue {
|
||||||
|
data: Some(Data::LongValue(*i)),
|
||||||
|
},
|
||||||
|
SqlValue::Double(d) => pb_SqlValue {
|
||||||
|
data: Some(Data::DoubleValue(*d)),
|
||||||
|
},
|
||||||
|
SqlValue::Blob(b) => pb_SqlValue {
|
||||||
|
data: Some(Data::BlobValue(b.clone())),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Vec<SqlValue>> for pb::ankidroid::Row {
|
||||||
|
fn from(item: &Vec<SqlValue>) -> Self {
|
||||||
|
Row {
|
||||||
|
fields: item.iter().map(pb::ankidroid::SqlValue::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Vec<Vec<SqlValue>>> for pb::ankidroid::DbResult {
|
||||||
|
fn from(item: &Vec<Vec<SqlValue>>) -> Self {
|
||||||
|
ProtoDbResult {
|
||||||
|
rows: item.iter().map(Row::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FromSql for SqlValue {
|
impl FromSql for SqlValue {
|
||||||
fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {
|
fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {
|
||||||
let val = match value {
|
let val = match value {
|
||||||
|
@ -71,6 +114,10 @@ impl FromSql for SqlValue {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result<Vec<u8>> {
|
pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
serde_json::to_vec(&db_command_bytes_inner(col, input)?).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn db_command_bytes_inner(col: &mut Collection, input: &[u8]) -> Result<DbResult> {
|
||||||
let req: DbRequest = serde_json::from_slice(input)?;
|
let req: DbRequest = serde_json::from_slice(input)?;
|
||||||
let resp = match req {
|
let resp = match req {
|
||||||
DbRequest::Query {
|
DbRequest::Query {
|
||||||
|
@ -107,7 +154,7 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result<Vec
|
||||||
db_execute_many(&col.storage, &sql, &args)?
|
db_execute_many(&col.storage, &sql, &args)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(serde_json::to_vec(&resp)?)
|
Ok(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_state_after_modification(col: &mut Collection, sql: &str) {
|
fn update_state_after_modification(col: &mut Collection, sql: &str) {
|
||||||
|
@ -128,6 +175,20 @@ fn is_dql(sql: &str) -> bool {
|
||||||
head.starts_with("select")
|
head.starts_with("select")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn db_command_proto(col: &mut Collection, input: &[u8]) -> Result<DbResponse> {
|
||||||
|
let result = db_command_bytes_inner(col, input)?;
|
||||||
|
let proto_resp = match result {
|
||||||
|
DbResult::None => ProtoDbResult { rows: Vec::new() },
|
||||||
|
DbResult::Rows(rows) => ProtoDbResult::from(&rows),
|
||||||
|
};
|
||||||
|
let trimmed = super::ankidroid::db::trim_and_cache_remaining(
|
||||||
|
col,
|
||||||
|
proto_resp,
|
||||||
|
super::ankidroid::db::next_sequence_number(),
|
||||||
|
);
|
||||||
|
Ok(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn db_query_row(ctx: &SqliteStorage, sql: &str, args: &[SqlValue]) -> Result<DbResult> {
|
pub(super) fn db_query_row(ctx: &SqliteStorage, sql: &str, args: &[SqlValue]) -> Result<DbResult> {
|
||||||
let mut stmt = ctx.db.prepare_cached(sql)?;
|
let mut stmt = ctx.db.prepare_cached(sql)?;
|
||||||
let columns = stmt.column_count();
|
let columns = stmt.column_count();
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
#![allow(clippy::unnecessary_wraps)]
|
#![allow(clippy::unnecessary_wraps)]
|
||||||
|
|
||||||
mod adding;
|
mod adding;
|
||||||
|
mod ankidroid;
|
||||||
mod card;
|
mod card;
|
||||||
mod cardrendering;
|
mod cardrendering;
|
||||||
mod collection;
|
mod collection;
|
||||||
|
@ -42,6 +43,7 @@ use tokio::runtime::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use self::{
|
use self::{
|
||||||
|
ankidroid::AnkidroidService,
|
||||||
card::CardsService,
|
card::CardsService,
|
||||||
cardrendering::CardRenderingService,
|
cardrendering::CardRenderingService,
|
||||||
collection::CollectionService,
|
collection::CollectionService,
|
||||||
|
@ -120,6 +122,7 @@ impl Backend {
|
||||||
ServiceIndex::from_i32(service as i32)
|
ServiceIndex::from_i32(service as i32)
|
||||||
.or_invalid("invalid service")
|
.or_invalid("invalid service")
|
||||||
.and_then(|service| match service {
|
.and_then(|service| match service {
|
||||||
|
ServiceIndex::Ankidroid => AnkidroidService::run_method(self, method, input),
|
||||||
ServiceIndex::Scheduler => SchedulerService::run_method(self, method, input),
|
ServiceIndex::Scheduler => SchedulerService::run_method(self, method, input),
|
||||||
ServiceIndex::Decks => DecksService::run_method(self, method, input),
|
ServiceIndex::Decks => DecksService::run_method(self, method, input),
|
||||||
ServiceIndex::Notes => NotesService::run_method(self, method, input),
|
ServiceIndex::Notes => NotesService::run_method(self, method, input),
|
||||||
|
|
|
@ -33,6 +33,8 @@ pub struct CollectionBuilder {
|
||||||
media_db: Option<PathBuf>,
|
media_db: Option<PathBuf>,
|
||||||
server: Option<bool>,
|
server: Option<bool>,
|
||||||
tr: Option<I18n>,
|
tr: Option<I18n>,
|
||||||
|
// temporary option for AnkiDroid
|
||||||
|
force_schema11: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CollectionBuilder {
|
impl CollectionBuilder {
|
||||||
|
@ -53,8 +55,8 @@ impl CollectionBuilder {
|
||||||
let server = self.server.unwrap_or_default();
|
let server = self.server.unwrap_or_default();
|
||||||
let media_folder = self.media_folder.clone().unwrap_or_default();
|
let media_folder = self.media_folder.clone().unwrap_or_default();
|
||||||
let media_db = self.media_db.clone().unwrap_or_default();
|
let media_db = self.media_db.clone().unwrap_or_default();
|
||||||
|
let force_schema11 = self.force_schema11.unwrap_or_default();
|
||||||
let storage = SqliteStorage::open_or_create(&col_path, &tr, server)?;
|
let storage = SqliteStorage::open_or_create(&col_path, &tr, server, force_schema11)?;
|
||||||
let col = Collection {
|
let col = Collection {
|
||||||
storage,
|
storage,
|
||||||
col_path,
|
col_path,
|
||||||
|
@ -88,6 +90,11 @@ impl CollectionBuilder {
|
||||||
self.tr = Some(tr);
|
self.tr = Some(tr);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_force_schema11(&mut self, force: bool) -> &mut Self {
|
||||||
|
self.force_schema11 = Some(force);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
@ -123,14 +123,14 @@ impl ExchangeData {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_ids(&self) -> Result<()> {
|
fn check_ids(&self) -> Result<()> {
|
||||||
let now = TimestampMillis::now().0;
|
let tomorrow = TimestampMillis::now().adding_secs(86_400).0;
|
||||||
if self
|
if self
|
||||||
.cards
|
.cards
|
||||||
.iter()
|
.iter()
|
||||||
.map(|card| card.id.0)
|
.map(|card| card.id.0)
|
||||||
.chain(self.notes.iter().map(|note| note.id.0))
|
.chain(self.notes.iter().map(|note| note.id.0))
|
||||||
.chain(self.revlog.iter().map(|entry| entry.id.0))
|
.chain(self.revlog.iter().map(|entry| entry.id.0))
|
||||||
.any(|timestamp| timestamp > now)
|
.any(|timestamp| timestamp > tomorrow)
|
||||||
{
|
{
|
||||||
Err(AnkiError::InvalidId)
|
Err(AnkiError::InvalidId)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -9,6 +9,7 @@ macro_rules! protobuf {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protobuf!(ankidroid, "ankidroid");
|
||||||
protobuf!(backend, "backend");
|
protobuf!(backend, "backend");
|
||||||
protobuf!(card_rendering, "card_rendering");
|
protobuf!(card_rendering, "card_rendering");
|
||||||
protobuf!(cards, "cards");
|
protobuf!(cards, "cards");
|
||||||
|
|
|
@ -578,11 +578,11 @@ impl SqlWriter<'_> {
|
||||||
write!(
|
write!(
|
||||||
self.sql,
|
self.sql,
|
||||||
concat!(
|
concat!(
|
||||||
"(SELECT min(id) > {cutoff} FROM revlog WHERE cid = c.id ",
|
"((SELECT coalesce(min(id) > {cutoff}, false) FROM revlog WHERE cid = c.id ",
|
||||||
// Exclude manual reschedulings
|
// Exclude manual reschedulings
|
||||||
"AND ease != 0) ",
|
"AND ease != 0) ",
|
||||||
// Logically redundant, speeds up query
|
// Logically redundant, speeds up query
|
||||||
"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff})"
|
"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff}))"
|
||||||
),
|
),
|
||||||
cutoff = cutoff,
|
cutoff = cutoff,
|
||||||
)
|
)
|
||||||
|
@ -785,8 +785,8 @@ mod test {
|
||||||
s(ctx, "introduced:3").0,
|
s(ctx, "introduced:3").0,
|
||||||
format!(
|
format!(
|
||||||
concat!(
|
concat!(
|
||||||
"((SELECT min(id) > {cutoff} FROM revlog WHERE cid = c.id AND ease != 0) ",
|
"(((SELECT coalesce(min(id) > {cutoff}, false) FROM revlog WHERE cid = c.id AND ease != 0) ",
|
||||||
"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff}))"
|
"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff})))"
|
||||||
),
|
),
|
||||||
cutoff = (timing.next_day_at.0 - (86_400 * 3)) * 1_000,
|
cutoff = (timing.next_day_at.0 - (86_400 * 3)) * 1_000,
|
||||||
)
|
)
|
||||||
|
|
|
@ -11,7 +11,7 @@ impl GraphsContext {
|
||||||
let start_of_today_ms = self.next_day_start.adding_secs(-86_400).as_millis().0;
|
let start_of_today_ms = self.next_day_start.adding_secs(-86_400).as_millis().0;
|
||||||
for review in self.revlog.iter().rev() {
|
for review in self.revlog.iter().rev() {
|
||||||
if review.id.0 < start_of_today_ms {
|
if review.id.0 < start_of_today_ms {
|
||||||
break;
|
continue;
|
||||||
}
|
}
|
||||||
if review.review_kind == RevlogReviewKind::Manual {
|
if review.review_kind == RevlogReviewKind::Manual {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
@ -762,7 +762,8 @@ mod test {
|
||||||
#[test]
|
#[test]
|
||||||
fn add_card() {
|
fn add_card() {
|
||||||
let tr = I18n::template_only();
|
let tr = I18n::template_only();
|
||||||
let storage = SqliteStorage::open_or_create(Path::new(":memory:"), &tr, false).unwrap();
|
let storage =
|
||||||
|
SqliteStorage::open_or_create(Path::new(":memory:"), &tr, false, false).unwrap();
|
||||||
let mut card = Card::default();
|
let mut card = Card::default();
|
||||||
storage.add_card(&mut card).unwrap();
|
storage.add_card(&mut card).unwrap();
|
||||||
let id1 = card.id;
|
let id1 = card.id;
|
||||||
|
|
|
@ -204,7 +204,12 @@ fn trace(s: &str) {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteStorage {
|
impl SqliteStorage {
|
||||||
pub(crate) fn open_or_create(path: &Path, tr: &I18n, server: bool) -> Result<Self> {
|
pub(crate) fn open_or_create(
|
||||||
|
path: &Path,
|
||||||
|
tr: &I18n,
|
||||||
|
server: bool,
|
||||||
|
force_schema11: bool,
|
||||||
|
) -> Result<Self> {
|
||||||
let db = open_or_create_collection_db(path)?;
|
let db = open_or_create_collection_db(path)?;
|
||||||
let (create, ver) = schema_version(&db)?;
|
let (create, ver) = schema_version(&db)?;
|
||||||
|
|
||||||
|
@ -249,6 +254,13 @@ impl SqliteStorage {
|
||||||
|
|
||||||
let storage = Self { db };
|
let storage = Self { db };
|
||||||
|
|
||||||
|
if force_schema11 {
|
||||||
|
if create || upgrade {
|
||||||
|
storage.commit_trx()?;
|
||||||
|
}
|
||||||
|
return storage_with_schema11(storage, ver);
|
||||||
|
}
|
||||||
|
|
||||||
if create || upgrade {
|
if create || upgrade {
|
||||||
storage.upgrade_to_latest_schema(ver, server)?;
|
storage.upgrade_to_latest_schema(ver, server)?;
|
||||||
}
|
}
|
||||||
|
@ -369,3 +381,20 @@ impl SqliteStorage {
|
||||||
self.db.query_row(sql, [], |r| r.get(0)).map_err(Into::into)
|
self.db.query_row(sql, [], |r| r.get(0)).map_err(Into::into)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn storage_with_schema11(storage: SqliteStorage, ver: u8) -> Result<SqliteStorage> {
|
||||||
|
if ver != 11 {
|
||||||
|
if ver != SCHEMA_MAX_VERSION {
|
||||||
|
// partially upgraded; need to fully upgrade before downgrading
|
||||||
|
storage.begin_trx()?;
|
||||||
|
storage.upgrade_to_latest_schema(ver, false)?;
|
||||||
|
storage.commit_trx()?;
|
||||||
|
}
|
||||||
|
storage.downgrade_to(SchemaVersion::V11)?;
|
||||||
|
}
|
||||||
|
// Requery uses "TRUNCATE" by default if WAL is not enabled.
|
||||||
|
// We copy this behaviour here. See https://github.com/ankidroid/Anki-Android/pull/7977 for
|
||||||
|
// analysis. We may be able to enable WAL at a later time.
|
||||||
|
storage.db.pragma_update(None, "journal_mode", "TRUNCATE")?;
|
||||||
|
Ok(storage)
|
||||||
|
}
|
||||||
|
|
|
@ -80,6 +80,10 @@ impl TimestampMillis {
|
||||||
pub fn as_secs(self) -> TimestampSecs {
|
pub fn as_secs(self) -> TimestampSecs {
|
||||||
TimestampSecs(self.0 / 1000)
|
TimestampSecs(self.0 / 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn adding_secs(self, secs: i64) -> Self {
|
||||||
|
Self(self.0 + secs * 1000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn elapsed() -> time::Duration {
|
fn elapsed() -> time::Duration {
|
||||||
|
|
|
@ -32,6 +32,34 @@ $vars: (
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
transition: (
|
||||||
|
default: (
|
||||||
|
"Default duration of transitions in milliseconds",
|
||||||
|
(
|
||||||
|
default: 180ms,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
medium: (
|
||||||
|
"Slightly longer transition duration in milliseconds",
|
||||||
|
(
|
||||||
|
default: 500ms,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
slow: (
|
||||||
|
"Long transition duration in milliseconds",
|
||||||
|
(
|
||||||
|
default: 1000ms,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
blur: (
|
||||||
|
default: (
|
||||||
|
"Default background blur value",
|
||||||
|
(
|
||||||
|
default: 20px,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
colors: (
|
colors: (
|
||||||
fg: (
|
fg: (
|
||||||
|
@ -107,6 +135,13 @@ $vars: (
|
||||||
dark: palette(darkgray, 6),
|
dark: palette(darkgray, 6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
glass: (
|
||||||
|
"Transparent background for surfaces containing text",
|
||||||
|
(
|
||||||
|
light: color.scale(white, $alpha: -60%),
|
||||||
|
dark: color.scale(palette(darkgray, 4), $alpha: -60%),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
border: (
|
border: (
|
||||||
default: (
|
default: (
|
||||||
|
|
|
@ -45,7 +45,7 @@ html {
|
||||||
|
|
||||||
button {
|
button {
|
||||||
/* override transition for instant hover response */
|
/* override transition for instant hover response */
|
||||||
transition: color 0.15s ease-in-out, box-shadow 0.15s ease-in-out !important;
|
transition: color var(--transition) ease-in-out, box-shadow var(--transition) ease-in-out !important;
|
||||||
border-radius: prop(border-radius);
|
border-radius: prop(border-radius);
|
||||||
@include button.base;
|
@include button.base;
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,6 @@ button {
|
||||||
@include elevation(1, $opacity-boost: -0.08);
|
@include elevation(1, $opacity-boost: -0.08);
|
||||||
&:hover {
|
&:hover {
|
||||||
@include elevation(2);
|
@include elevation(2);
|
||||||
transition: box-shadow 0.2s linear;
|
transition: box-shadow var(--transition) linear;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ body {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
background: var(--canvas);
|
background: var(--canvas);
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
transition: opacity 0.5s ease-out;
|
transition: opacity var(--transition-medium) ease-out;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
12
tools/rebuild-web
Executable file
12
tools/rebuild-web
Executable file
|
@ -0,0 +1,12 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
# Manually trigger a rebuild and reload of Anki's web stack
|
||||||
|
|
||||||
|
# NOTE: This script needs to be run from the project root
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
./ninja qt/aqt
|
||||||
|
./out/pyenv/bin/python tools/reload_webviews.py
|
55
tools/reload_webviews.py
Executable file
55
tools/reload_webviews.py
Executable file
|
@ -0,0 +1,55 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
"""
|
||||||
|
Trigger a reload of Anki's web views using QtWebEngine' Chromium
|
||||||
|
Remote Debugging interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import PyChromeDevTools # type: ignore[import]
|
||||||
|
|
||||||
|
DEFAULT_HOST = "localhost"
|
||||||
|
DEFAULT_PORT = 8080
|
||||||
|
|
||||||
|
|
||||||
|
def print_error(message: str):
|
||||||
|
print(f"Error: {message}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser("reload_webviews")
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
help=f"Host via which the Chrome session can be reached, e.g. {DEFAULT_HOST}",
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_HOST,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
help=f"Port via which the Chrome session can be reached, e.g. {DEFAULT_PORT}",
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_PORT,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
chrome = PyChromeDevTools.ChromeInterface(host=args.host, port=args.port)
|
||||||
|
except Exception as e:
|
||||||
|
print_error(
|
||||||
|
f"Could not establish connection to Chromium remote debugger. Exception:\n{e}"
|
||||||
|
)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
if chrome.tabs is None:
|
||||||
|
print_error("Was unable to get active web views.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
for tab_index, tab_data in enumerate(chrome.tabs):
|
||||||
|
print(f"Reloading page: {tab_data['title']}")
|
||||||
|
chrome.connect(tab=tab_index, update_tabs=False)
|
||||||
|
chrome.Page.reload()
|
|
@ -1,13 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Monitor the ts folder and rebuild aqt's data each time
|
|
||||||
# it changes, for testing pages locally.
|
|
||||||
#
|
|
||||||
# On a Mac, useful to combine with ts-run.
|
|
||||||
|
|
||||||
# run once at startup
|
|
||||||
cmd='printf \\033c\\n; bazel build qt:runanki'
|
|
||||||
sh -c "$cmd"
|
|
||||||
|
|
||||||
# then monitor for changes
|
|
||||||
fswatch -r -o ts | xargs -n1 -I{} sh -c "$cmd"
|
|
23
tools/web-watch
Executable file
23
tools/web-watch
Executable file
|
@ -0,0 +1,23 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
# Monitor all web-related folders and rebuild and reload Anki's web stack
|
||||||
|
# when a change is detected.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MONITORED_FOLDERS=("ts/" "sass/" "qt/aqt/data/web/")
|
||||||
|
MONITORED_EVENTS=("Created" "Updated" "Removed")
|
||||||
|
|
||||||
|
on_change_detected="printf \\033c\\n; \"./tools/rebuild-web\""
|
||||||
|
|
||||||
|
event_args=""
|
||||||
|
for event in "${MONITORED_EVENTS[@]}"; do
|
||||||
|
event_args+="--event ${event} "
|
||||||
|
done
|
||||||
|
|
||||||
|
# poll_monitor comes with a slight performance penalty, but seems to more
|
||||||
|
# reliably identify file system events across both macOS and Linux
|
||||||
|
fswatch -r -o -m poll_monitor ${event_args[@]} \
|
||||||
|
"${MONITORED_FOLDERS[@]}" | xargs -n1 -I{} sh -c "$on_change_detected"
|
|
@ -75,7 +75,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
height: var(--client-height);
|
height: var(--client-height);
|
||||||
box-shadow: var(--box-shadow);
|
box-shadow: var(--box-shadow);
|
||||||
transition: box-shadow 0.1s ease-in-out;
|
transition: box-shadow var(--transition) ease-in-out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -63,7 +63,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
@include elevation(4);
|
@include elevation(4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
transition: box-shadow 0.2s ease-in-out;
|
transition: box-shadow var(--transition) ease-in-out;
|
||||||
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
@ -72,7 +73,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
color: var(--fg-faint);
|
color: var(--fg-faint);
|
||||||
transition: color 0.2s linear;
|
transition: color var(--transition) linear;
|
||||||
&:hover {
|
&:hover {
|
||||||
transition: none;
|
transition: none;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
@import "sass/base";
|
@import "sass/base";
|
||||||
|
|
||||||
// override Bootstrap transition duration
|
// override Bootstrap transition duration
|
||||||
$carousel-transition: 0.2s;
|
$carousel-transition: var(--transition);
|
||||||
|
|
||||||
@import "bootstrap/scss/buttons";
|
@import "bootstrap/scss/buttons";
|
||||||
@import "bootstrap/scss/button-group";
|
@import "bootstrap/scss/button-group";
|
||||||
|
|
|
@ -4,9 +4,12 @@
|
||||||
import { getSelection, isSelectionCollapsed } from "@tslib/cross-browser";
|
import { getSelection, isSelectionCollapsed } from "@tslib/cross-browser";
|
||||||
import { elementIsEmpty, nodeIsElement, nodeIsText } from "@tslib/dom";
|
import { elementIsEmpty, nodeIsElement, nodeIsText } from "@tslib/dom";
|
||||||
import { on } from "@tslib/events";
|
import { on } from "@tslib/events";
|
||||||
|
import type { Unsubscriber } from "svelte/store";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
import { moveChildOutOfElement } from "../domlib/move-nodes";
|
import { moveChildOutOfElement } from "../domlib/move-nodes";
|
||||||
import { placeCaretAfter } from "../domlib/place-caret";
|
import { placeCaretAfter } from "../domlib/place-caret";
|
||||||
|
import { isComposing } from "../sveltelib/composition";
|
||||||
import type { FrameElement } from "./frame-element";
|
import type { FrameElement } from "./frame-element";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -53,7 +56,6 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleElement = target;
|
const handleElement = target;
|
||||||
const placement = handleElement instanceof FrameStart ? "beforebegin" : "afterend";
|
|
||||||
const frameElement = handleElement.parentElement as FrameElement;
|
const frameElement = handleElement.parentElement as FrameElement;
|
||||||
|
|
||||||
for (const node of mutation.addedNodes) {
|
for (const node of mutation.addedNodes) {
|
||||||
|
@ -75,7 +77,7 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
|
||||||
referenceNode = moveChildOutOfElement(
|
referenceNode = moveChildOutOfElement(
|
||||||
frameElement,
|
frameElement,
|
||||||
node,
|
node,
|
||||||
placement,
|
handleElement.placement,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,25 +86,16 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
|
||||||
!nodeIsText(target)
|
!nodeIsText(target)
|
||||||
|| !isFrameHandle(target.parentElement)
|
|| !isFrameHandle(target.parentElement)
|
||||||
|| skippableNode(target.parentElement, target)
|
|| skippableNode(target.parentElement, target)
|
||||||
|
|| target.parentElement.unsubscribe
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (get(isComposing)) {
|
||||||
const handleElement = target.parentElement;
|
target.parentElement.subscribeToCompositionEvent();
|
||||||
const placement = handleElement instanceof FrameStart ? "beforebegin" : "afterend";
|
continue;
|
||||||
const frameElement = handleElement.parentElement! as FrameElement;
|
|
||||||
|
|
||||||
const cleaned = target.data.replace(spaceRegex, "");
|
|
||||||
const text = new Text(cleaned);
|
|
||||||
|
|
||||||
if (placement === "beforebegin") {
|
|
||||||
frameElement.before(text);
|
|
||||||
} else {
|
|
||||||
frameElement.after(text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleElement.refreshSpace();
|
referenceNode = target.parentElement.moveTextOutOfFrame(target.data);
|
||||||
referenceNode = text;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,6 +107,8 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
|
||||||
const handleObserver = new MutationObserver(restoreHandleContent);
|
const handleObserver = new MutationObserver(restoreHandleContent);
|
||||||
const handles: Set<FrameHandle> = new Set();
|
const handles: Set<FrameHandle> = new Set();
|
||||||
|
|
||||||
|
type Placement = Extract<InsertPosition, "beforebegin" | "afterend">;
|
||||||
|
|
||||||
export abstract class FrameHandle extends HTMLElement {
|
export abstract class FrameHandle extends HTMLElement {
|
||||||
static get observedAttributes(): string[] {
|
static get observedAttributes(): string[] {
|
||||||
return ["data-frames"];
|
return ["data-frames"];
|
||||||
|
@ -128,6 +123,8 @@ export abstract class FrameHandle extends HTMLElement {
|
||||||
*/
|
*/
|
||||||
partiallySelected = false;
|
partiallySelected = false;
|
||||||
frames?: string;
|
frames?: string;
|
||||||
|
abstract placement: Placement;
|
||||||
|
unsubscribe: Unsubscriber | null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
@ -136,6 +133,7 @@ export abstract class FrameHandle extends HTMLElement {
|
||||||
subtree: true,
|
subtree: true,
|
||||||
characterData: true,
|
characterData: true,
|
||||||
});
|
});
|
||||||
|
this.unsubscribe = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback(name: string, old: string, newValue: string): void {
|
attributeChangedCallback(name: string, old: string, newValue: string): void {
|
||||||
|
@ -197,13 +195,56 @@ export abstract class FrameHandle extends HTMLElement {
|
||||||
|
|
||||||
this.removeMoveIn?.();
|
this.removeMoveIn?.();
|
||||||
this.removeMoveIn = undefined;
|
this.removeMoveIn = undefined;
|
||||||
|
this.unsubscribeToCompositionEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract notifyMoveIn(offset: number): void;
|
abstract notifyMoveIn(offset: number): void;
|
||||||
|
|
||||||
|
moveTextOutOfFrame(data: string): Text {
|
||||||
|
const frameElement = this.parentElement! as FrameElement;
|
||||||
|
const cleaned = data.replace(spaceRegex, "");
|
||||||
|
const text = new Text(cleaned);
|
||||||
|
|
||||||
|
if (this.placement === "beforebegin") {
|
||||||
|
frameElement.before(text);
|
||||||
|
} else if (this.placement === "afterend") {
|
||||||
|
frameElement.after(text);
|
||||||
|
}
|
||||||
|
this.refreshSpace();
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/ankitects/anki/issues/2251
|
||||||
|
*
|
||||||
|
* Work around the issue by not moving the input string while an IME session
|
||||||
|
* is active, and moving the final output from IME only after the session ends.
|
||||||
|
*/
|
||||||
|
subscribeToCompositionEvent(): void {
|
||||||
|
this.unsubscribe = isComposing.subscribe((composing) => {
|
||||||
|
if (!composing) {
|
||||||
|
if (this.firstChild && nodeIsText(this.firstChild)) {
|
||||||
|
placeCaretAfter(this.moveTextOutOfFrame(this.firstChild.data));
|
||||||
|
}
|
||||||
|
this.unsubscribeToCompositionEvent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribeToCompositionEvent(): void {
|
||||||
|
this.unsubscribe?.();
|
||||||
|
this.unsubscribe = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FrameStart extends FrameHandle {
|
export class FrameStart extends FrameHandle {
|
||||||
static tagName = "frame-start";
|
static tagName = "frame-start";
|
||||||
|
placement: Placement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.placement = "beforebegin";
|
||||||
|
}
|
||||||
|
|
||||||
getFrameRange(): Range {
|
getFrameRange(): Range {
|
||||||
const range = new Range();
|
const range = new Range();
|
||||||
|
@ -245,6 +286,12 @@ export class FrameStart extends FrameHandle {
|
||||||
|
|
||||||
export class FrameEnd extends FrameHandle {
|
export class FrameEnd extends FrameHandle {
|
||||||
static tagName = "frame-end";
|
static tagName = "frame-end";
|
||||||
|
placement: Placement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.placement = "afterend";
|
||||||
|
}
|
||||||
|
|
||||||
getFrameRange(): Range {
|
getFrameRange(): Range {
|
||||||
const range = new Range();
|
const range = new Range();
|
||||||
|
|
|
@ -16,6 +16,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import { context as noteEditorContext } from "./NoteEditor.svelte";
|
import { context as noteEditorContext } from "./NoteEditor.svelte";
|
||||||
import { editingInputIsRichText } from "./rich-text-input";
|
import { editingInputIsRichText } from "./rich-text-input";
|
||||||
|
|
||||||
|
export let alwaysEnabled = false;
|
||||||
|
|
||||||
const { focusedInput, fields } = noteEditorContext.get();
|
const { focusedInput, fields } = noteEditorContext.get();
|
||||||
|
|
||||||
// Workaround for Cmd+Option+Shift+C not working on macOS. The keyup approach works
|
// Workaround for Cmd+Option+Shift+C not working on macOS. The keyup approach works
|
||||||
|
@ -67,7 +69,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$: disabled = !$focusedInput || !editingInputIsRichText($focusedInput);
|
$: disabled =
|
||||||
|
!alwaysEnabled && (!$focusedInput || !editingInputIsRichText($focusedInput));
|
||||||
|
|
||||||
const incrementKeyCombination = "Control+Shift+C";
|
const incrementKeyCombination = "Control+Shift+C";
|
||||||
const sameKeyCombination = "Control+Alt+Shift+C";
|
const sameKeyCombination = "Control+Alt+Shift+C";
|
||||||
|
|
|
@ -18,7 +18,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
.collapse-badge {
|
.collapse-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
transition: opacity 0.2s ease-in-out, transform 80ms ease-in;
|
transition: opacity var(--transition) ease-in-out,
|
||||||
|
transform var(--transition) ease-in;
|
||||||
&.highlighted {
|
&.highlighted {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
||||||
<ClozeButtons on:surround />
|
<ClozeButtons on:surround alwaysEnabled={true} />
|
||||||
|
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
|
@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import type CodeMirrorLib from "codemirror";
|
import type CodeMirrorLib from "codemirror";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
|
import { isComposing } from "sveltelib/composition";
|
||||||
|
|
||||||
import Popover from "../../components/Popover.svelte";
|
import Popover from "../../components/Popover.svelte";
|
||||||
import Shortcut from "../../components/Shortcut.svelte";
|
import Shortcut from "../../components/Shortcut.svelte";
|
||||||
|
@ -79,6 +80,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
const code = writable("");
|
const code = writable("");
|
||||||
|
|
||||||
function showOverlay(image: HTMLImageElement, pos?: CodeMirrorLib.Position) {
|
function showOverlay(image: HTMLImageElement, pos?: CodeMirrorLib.Position) {
|
||||||
|
if ($isComposing) {
|
||||||
|
// Should be canceled while an IME composition session is active
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [promise, allowResolve] = promiseWithResolver<void>();
|
const [promise, allowResolve] = promiseWithResolver<void>();
|
||||||
|
|
||||||
allowPromise = promise;
|
allowPromise = promise;
|
||||||
|
|
|
@ -87,7 +87,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
svg {
|
svg {
|
||||||
transition: opacity 1s;
|
transition: opacity var(--transition-slow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.counts-outer {
|
.counts-outer {
|
||||||
|
|
|
@ -21,8 +21,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use "sass/elevation" as *;
|
@use "sass/elevation" as *;
|
||||||
.graph {
|
.graph {
|
||||||
page-break-inside: avoid;
|
|
||||||
|
|
||||||
/* See graph-styles.ts for constants referencing global styles */
|
/* See graph-styles.ts for constants referencing global styles */
|
||||||
:global(.graph-element-clickable) {
|
:global(.graph-element-clickable) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
@ -78,6 +78,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media only print {
|
||||||
|
// grid layout does not honor page-break-inside
|
||||||
|
display: block;
|
||||||
|
margin-top: 3em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
|
|
|
@ -148,7 +148,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
&.loading {
|
&.loading {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
transition: opacity 1s;
|
transition: opacity var(--transition-slow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.3s;
|
transition: opacity var(--transition);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
background: var(--canvas-overlay);
|
background: var(--canvas-overlay);
|
||||||
|
|
||||||
|
|
|
@ -75,14 +75,15 @@ export function buildHistogram(
|
||||||
.thresholds(scale.ticks(desiredBars))(data.daysAdded.entries() as any);
|
.thresholds(scale.ticks(desiredBars))(data.daysAdded.entries() as any);
|
||||||
|
|
||||||
// empty graph?
|
// empty graph?
|
||||||
if (!sum(bins, (bin) => bin.length)) {
|
const accessor = getNumericMapBinValue as any;
|
||||||
|
if (!sum(bins, accessor)) {
|
||||||
return [null, []];
|
return [null, []];
|
||||||
}
|
}
|
||||||
|
|
||||||
const adjustedRange = scaleLinear().range([0.7, 0.3]);
|
const adjustedRange = scaleLinear().range([0.7, 0.3]);
|
||||||
const colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]);
|
const colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]);
|
||||||
|
|
||||||
const totalInPeriod = sum(bins, (bin) => bin.length);
|
const totalInPeriod = sum(bins, accessor);
|
||||||
const periodDays = Math.abs(xMin!);
|
const periodDays = Math.abs(xMin!);
|
||||||
const cardsPerDay = Math.round(totalInPeriod / periodDays);
|
const cardsPerDay = Math.round(totalInPeriod / periodDays);
|
||||||
const tableData = [
|
const tableData = [
|
||||||
|
@ -102,7 +103,7 @@ export function buildHistogram(
|
||||||
_percent: number,
|
_percent: number,
|
||||||
): string {
|
): string {
|
||||||
const day = dayLabel(bin.x0!, bin.x1!);
|
const day = dayLabel(bin.x0!, bin.x1!);
|
||||||
const cards = tr.statisticsCards({ cards: bin.length });
|
const cards = tr.statisticsCards({ cards: accessor(bin) });
|
||||||
const total = tr.statisticsRunningTotal();
|
const total = tr.statisticsRunningTotal();
|
||||||
const totalCards = tr.statisticsCards({ cards: cumulative });
|
const totalCards = tr.statisticsCards({ cards: cumulative });
|
||||||
return `${day}:<br>${cards}<br>${total}: ${totalCards}`;
|
return `${day}:<br>${cards}<br>${total}: ${totalCards}`;
|
||||||
|
|
|
@ -145,6 +145,9 @@ export async function _updateQA(
|
||||||
|
|
||||||
await _runHook(onUpdateHook);
|
await _runHook(onUpdateHook);
|
||||||
|
|
||||||
|
// dynamic toolbar background
|
||||||
|
bridgeCommand("updateToolbar");
|
||||||
|
|
||||||
// wait for mathjax to ready
|
// wait for mathjax to ready
|
||||||
await MathJax.startup.promise
|
await MathJax.startup.promise
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
|
@ -10,7 +10,10 @@ hr {
|
||||||
body {
|
body {
|
||||||
margin: 20px;
|
margin: 20px;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
background-color: var(--canvas);
|
// default background setting to fit with toolbar
|
||||||
|
background-size: 100vw;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// explicit nightMode definition required
|
// explicit nightMode definition required
|
||||||
|
|
12
ts/sveltelib/composition.ts
Normal file
12
ts/sveltelib/composition.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether an IME composition session is currently active
|
||||||
|
*/
|
||||||
|
export const isComposing = writable(false);
|
||||||
|
|
||||||
|
window.addEventListener("compositionstart", () => isComposing.set(true));
|
||||||
|
window.addEventListener("compositionend", () => isComposing.set(false));
|
|
@ -3791,9 +3791,9 @@ json-stable-stringify-without-jsonify@^1.0.1:
|
||||||
integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
|
integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
|
||||||
|
|
||||||
json5@^1.0.1:
|
json5@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
|
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
|
||||||
integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==
|
integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==
|
||||||
dependencies:
|
dependencies:
|
||||||
minimist "^1.2.0"
|
minimist "^1.2.0"
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue