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')
|
||||
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"
|
||||
else
|
||||
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]]
|
||||
name = "bzip2"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6afcd980b5f3a45017c57e57a2fcccbb351cc43a356ce117ef760ef8052b89b0"
|
||||
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
"libc",
|
||||
|
@ -3602,9 +3602,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
|
|||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.23.0"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46"
|
||||
checksum = "38a54aca0c15d014013256222ba0ebed095673f89345dd79119d912eb561b7a8"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"bytes",
|
||||
|
|
|
@ -13,7 +13,7 @@ camino = "1.1.1"
|
|||
flate2 = "1.0.25"
|
||||
sha2 = { version = "0.10.6" }
|
||||
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" }
|
||||
xz2 = "0.1.7"
|
||||
zip = "0.6.3"
|
||||
|
|
|
@ -12,7 +12,7 @@ pub struct SyncSubmodule {
|
|||
|
||||
impl BuildAction for SyncSubmodule {
|
||||
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) {
|
||||
|
|
|
@ -22,6 +22,10 @@ pub fn build_artifacts(args: BuildArtifactsArgs) {
|
|||
fs::remove_dir_all(&artifacts).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(
|
||||
Command::new(&args.pyoxidizer_bin)
|
||||
|
@ -34,7 +38,7 @@ pub fn build_artifacts(args: BuildArtifactsArgs) {
|
|||
"out/bundle/pyenv",
|
||||
"--var",
|
||||
"build",
|
||||
bundle_root.join("build").as_str(),
|
||||
build_folder.as_str(),
|
||||
])
|
||||
.env("CARGO_MANIFEST_DIR", "qt/bundle")
|
||||
.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
|
||||
|
||||
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
|
||||
|
||||
|
@ -42,6 +42,13 @@ sudo apt install libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
|
|||
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
|
||||
|
||||
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-reduce-motion = Reduce motion
|
||||
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_LINKS = 15;
|
||||
SERVICE_INDEX_IMPORT_EXPORT = 16;
|
||||
SERVICE_INDEX_ANKIDROID = 17;
|
||||
}
|
||||
|
||||
message BackendInit {
|
||||
|
@ -64,6 +65,7 @@ message BackendError {
|
|||
IMPORT_ERROR = 16;
|
||||
DELETED = 17;
|
||||
CARD_TYPE_ERROR = 18;
|
||||
ANKIDROID_PANIC_ERROR = 19;
|
||||
}
|
||||
|
||||
// error description, usually localized, suitable for displaying to the user
|
||||
|
|
|
@ -34,6 +34,9 @@ message OpenCollectionRequest {
|
|||
string collection_path = 1;
|
||||
string media_folder_path = 2;
|
||||
string media_db_path = 3;
|
||||
|
||||
// temporary option for AnkiDroid
|
||||
bool force_schema11 = 99;
|
||||
}
|
||||
|
||||
message CloseCollectionRequest {
|
||||
|
|
|
@ -309,6 +309,7 @@ class Collection(DeprecatedNamesMixin):
|
|||
collection_path=self.path,
|
||||
media_folder_path=media_dir,
|
||||
media_db_path=media_db,
|
||||
force_schema11=False,
|
||||
)
|
||||
self.db = DBProxy(weakref.proxy(self._backend))
|
||||
self.db.begin()
|
||||
|
|
|
@ -194,6 +194,8 @@ for service in anki.backend_pb2.ServiceIndex.DESCRIPTOR.values:
|
|||
base = service.name.replace("SERVICE_INDEX_", "")
|
||||
service_pkg = service_modules.get(base)
|
||||
service_var = "_" + base.replace("_", "") + "SERVICE"
|
||||
if service_var == "_ANKIDROIDSERVICE":
|
||||
continue
|
||||
service_obj = getattr(service_pkg, service_var)
|
||||
service_index = service.number
|
||||
render_service(service_obj, service_index)
|
||||
|
|
|
@ -9,6 +9,7 @@ mypy-protobuf
|
|||
pip-tools
|
||||
pylint
|
||||
pytest
|
||||
PyChromeDevTools
|
||||
fluent.syntax
|
||||
types-decorator
|
||||
types-flask
|
||||
|
|
|
@ -368,6 +368,9 @@ protobuf==4.21.9 \
|
|||
# via
|
||||
# -r requirements.bundle.txt
|
||||
# mypy-protobuf
|
||||
pychromedevtools==0.4 \
|
||||
--hash=sha256:453f889b11c58fed348206d1b6e91a0bbfe23a319365c586ae462214ecb513ce
|
||||
# via -r requirements.dev.in
|
||||
pylint==2.15.5 \
|
||||
--hash=sha256:3b120505e5af1d06a5ad76b55d8660d44bf0f2fc3c59c2bdd94e39188ee3a4df \
|
||||
--hash=sha256:c2108037eb074334d9e874dc3c783752cc03d0796c88c9a9af282d0f161a1004
|
||||
|
@ -420,7 +423,9 @@ pytoml==0.1.21 \
|
|||
requests==2.28.1 \
|
||||
--hash=sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983 \
|
||||
--hash=sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349
|
||||
# via -r requirements.bundle.txt
|
||||
# via
|
||||
# -r requirements.bundle.txt
|
||||
# pychromedevtools
|
||||
send2trash==1.8.0 \
|
||||
--hash=sha256:d2c24762fd3759860a0aff155e45871447ea58d2be6bdd39b5c8f966a0c99c2d \
|
||||
--hash=sha256:f20eaadfdb517eaca5ce077640cb261c7d2698385a6a0f072a4a5447fd49fa08
|
||||
|
@ -525,6 +530,10 @@ waitress==2.1.2 \
|
|||
--hash=sha256:7500c9625927c8ec60f54377d590f67b30c8e70ef4b8894214ac6e4cad233d2a \
|
||||
--hash=sha256:780a4082c5fbc0fde6a2fcfe5e26e6efc1e8f425730863c04085769781f51eba
|
||||
# via -r requirements.bundle.txt
|
||||
websocket-client==1.4.2 \
|
||||
--hash=sha256:d6b06432f184438d99ac1f456eaf22fe1ade524c3dd16e661142dc54e9cba574 \
|
||||
--hash=sha256:d6e8f90ca8e2dd4e8027c4561adeb9456b54044312dba655e7cae652ceb9ae59
|
||||
# via pychromedevtools
|
||||
werkzeug==2.2.2 \
|
||||
--hash=sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f \
|
||||
--hash=sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
pyqt6==6.4.0
|
||||
pyqt6-qt6==6.4.0
|
||||
pyqt6-qt6==6.4.2
|
||||
pyqt6-webengine==6.4.0
|
||||
pyqt6-webengine-qt6==6.4.0
|
||||
pyqt6-webengine-qt6==6.4.2
|
||||
pyqt6_sip==13.4.0
|
||||
|
|
|
@ -6,11 +6,11 @@ pyqt6==6.4.0 \
|
|||
# via
|
||||
# -r requirements.qt6_4.in
|
||||
# pyqt6-webengine
|
||||
pyqt6-qt6==6.4.0 \
|
||||
--hash=sha256:38cfedf942f6982e2492234c4912a6f9ae0d54430313ba32297b7d673adaa11d \
|
||||
--hash=sha256:9f53036e3c7e0f17eabf6e89689279f3fc4895747b29c0c22d547ba57a087a8b \
|
||||
--hash=sha256:adee1f98678adebf14cdf4ea1f95cf00b6a644c14e9a79136166d0060de72dfc \
|
||||
--hash=sha256:fe846c6f89c4ca720ec03c85ec31ac6cc3ffbe8bf5e780f25f99a4cac3372f7c
|
||||
pyqt6-qt6==6.4.2 \
|
||||
--hash=sha256:9f07c3c100cb46cca4074965e7494d4df4f0fc016497d5303c1fe135822876e1 \
|
||||
--hash=sha256:a29b8c858babd523e80c8db5f8fd19792641588ec04eab49af18b7a4423eb99f \
|
||||
--hash=sha256:c0e91d0275d428496cacff717a9b719c52bfa52b21f124d638b79cc2217bc81e \
|
||||
--hash=sha256:d19c4e72615762cd6f0b043f23fa5f0b02656091427ce6de1efccd58e10e6a53
|
||||
# via
|
||||
# -r requirements.qt6_4.in
|
||||
# pyqt6
|
||||
|
@ -41,11 +41,11 @@ pyqt6-webengine==6.4.0 \
|
|||
--hash=sha256:7f6cde52b7b8c00ef2a1522ad92cde66f2bd3a3066646efe4ef96a4907b1b1cd \
|
||||
--hash=sha256:9658919bc1c5279a6fae9e6990448dfe483e136e957e6fb14e8f6265f4e9d1da
|
||||
# via -r requirements.qt6_4.in
|
||||
pyqt6-webengine-qt6==6.4.0 \
|
||||
--hash=sha256:572e7fee6de616191b98dd974ced8bd732e86dc1856c1ada7ad734402e37285c \
|
||||
--hash=sha256:689127e483ab76744477762ab936de9541e7fc368ab4f4ee463a9099bf8bc5be \
|
||||
--hash=sha256:971aedd051c77c17c59e724692636a4a0883c70dff3dbd172ae7cfb2fe7ddcc4 \
|
||||
--hash=sha256:f13b3582c7f170017ecd52ec4c2e735c859316f05820e1bb4a2910c530611af4
|
||||
pyqt6-webengine-qt6==6.4.2 \
|
||||
--hash=sha256:071f8c96433c27d10110782dc98cd2d8fee4a9e60fe4ab50e5f2abea48876ae1 \
|
||||
--hash=sha256:1111a5b580332768b5f4ab08becd639f9298f9a780da59ba2c317f2327e6b191 \
|
||||
--hash=sha256:4eeeb50a3b92c873996036b168d8b5e42da7db4bef5f7f2de4d863c2958dda5e \
|
||||
--hash=sha256:e6cbd4193af5d6e7cd82ff2fb04a5d66bc886554bbda00295e9709b0d6447e9d
|
||||
# via
|
||||
# -r requirements.qt6_4.in
|
||||
# pyqt6-webengine
|
||||
|
|
|
@ -12,7 +12,9 @@ if sys.version_info[0] < 3 or sys.version_info[1] < 9:
|
|||
try:
|
||||
"テスト".encode(sys.getfilesystemencoding())
|
||||
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
|
||||
|
||||
|
|
|
@ -303,7 +303,7 @@ class SidebarTreeView(QTreeView):
|
|||
) -> None:
|
||||
if self.current_search and (item := self.model().item_for_index(idx)):
|
||||
if item.is_highlighted():
|
||||
brush = QBrush(theme_manager.qcolor(colors.STATE_SUSPENDED))
|
||||
brush = QBrush(theme_manager.qcolor(colors.HIGHLIGHT_BG))
|
||||
painter.save()
|
||||
painter.fillRect(options.rect, brush)
|
||||
painter.restore()
|
||||
|
|
|
@ -15,7 +15,9 @@ table {
|
|||
&:hover {
|
||||
@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 {
|
||||
|
|
|
@ -18,10 +18,6 @@ body {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
#innertable {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
#middle td[align="center"] {
|
||||
padding-top: 10px;
|
||||
position: relative;
|
||||
|
@ -30,7 +26,7 @@ body {
|
|||
button {
|
||||
min-width: 60px;
|
||||
white-space: nowrap;
|
||||
margin: 0.5em;
|
||||
margin: 9px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
@ -51,10 +47,6 @@ button {
|
|||
font-weight: normal;
|
||||
}
|
||||
|
||||
#ansbut {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
:focus {
|
||||
border-color: color(border-focus);
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
#header {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 6px;
|
||||
margin-top: 0;
|
||||
padding: 9px;
|
||||
}
|
||||
|
|
|
@ -6,25 +6,52 @@
|
|||
@use "sass/elevation" as *;
|
||||
@use "sass/button-mixins" as button;
|
||||
|
||||
#header {
|
||||
padding-bottom: 4px;
|
||||
margin-top: -3px;
|
||||
.header {
|
||||
height: 41px;
|
||||
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;
|
||||
border-radius: prop(border-radius);
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: prop(border-radius-large);
|
||||
border-bottom-right-radius: prop(border-radius-large);
|
||||
@include button.base($with-hover: false, $with-active: false);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
@include elevation(1, $opacity-boost: -0.1);
|
||||
|
||||
@include elevation(1, $opacity-boost: -0.08);
|
||||
&:hover {
|
||||
@include elevation(2);
|
||||
// elevated state (deck browser, overview)
|
||||
body:not(.flat) & {
|
||||
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 {
|
||||
|
@ -32,6 +59,11 @@ body {
|
|||
padding: 0;
|
||||
-webkit-user-select: none;
|
||||
overflow: hidden;
|
||||
|
||||
&.collapsed {
|
||||
transform: translateY(-100vh);
|
||||
}
|
||||
transition: transform var(--transition) ease-in-out;
|
||||
}
|
||||
|
||||
* {
|
||||
|
@ -40,12 +72,16 @@ body {
|
|||
|
||||
.hitem {
|
||||
font-weight: bold;
|
||||
padding: 8px 14px;
|
||||
padding: 5px 12px;
|
||||
text-decoration: none;
|
||||
color: color(fg);
|
||||
display: inline-block;
|
||||
@include button.base;
|
||||
border: none;
|
||||
|
||||
body:not(.flat) &,
|
||||
&:hover {
|
||||
@include button.base($border: false);
|
||||
background: var(--canvas-elevated);
|
||||
}
|
||||
&:first-child {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
@ -75,7 +111,7 @@ body {
|
|||
display: inline-block;
|
||||
visibility: visible !important;
|
||||
animation-timing-function: linear;
|
||||
transition: all 0.2s ease-in;
|
||||
transition: all var(--transition) ease-in;
|
||||
}
|
||||
|
||||
#sync-spinner {
|
||||
|
|
|
@ -15,13 +15,21 @@
|
|||
body {
|
||||
color: var(--fg);
|
||||
background: var(--canvas);
|
||||
transition: opacity 0.5s ease-out;
|
||||
transition: opacity var(--transition-medium) ease-out;
|
||||
margin: 2em;
|
||||
overscroll-behavior: none;
|
||||
&:not(.isMac),
|
||||
&:not(.isMac) * {
|
||||
@include scrollbar.custom;
|
||||
}
|
||||
&.reduced-motion,
|
||||
&.reduced-motion * {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
&.no-blur * {
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
|
|
|
@ -27,3 +27,63 @@ function updateSyncColor(state: SyncState) {
|
|||
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>
|
||||
<y>0</y>
|
||||
<width>640</width>
|
||||
<height>640</height>
|
||||
<height>660</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
|
@ -113,6 +113,13 @@
|
|||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item alignment="Qt::AlignLeft">
|
||||
<widget class="QCheckBox" name="collapse_toolbar">
|
||||
<property name="text">
|
||||
<string>preferences_collapse_toolbar</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="useCurrent">
|
||||
<item>
|
||||
|
@ -666,7 +673,7 @@
|
|||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item alignment="Qt::AlignCenter">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_21">
|
||||
<property name="text">
|
||||
<string>preferences_some_settings_will_take_effect_after</string>
|
||||
|
@ -696,6 +703,7 @@
|
|||
<tabstop>ignore_accents_in_search</tabstop>
|
||||
<tabstop>legacy_import_export</tabstop>
|
||||
<tabstop>reduce_motion</tabstop>
|
||||
<tabstop>collapse_toolbar</tabstop>
|
||||
<tabstop>useCurrent</tabstop>
|
||||
<tabstop>default_search_text</tabstop>
|
||||
<tabstop>uiScale</tabstop>
|
||||
|
|
|
@ -66,6 +66,7 @@ from aqt.qt import sip
|
|||
from aqt.sync import sync_collection, sync_login
|
||||
from aqt.taskman import TaskManager
|
||||
from aqt.theme import Theme, theme_manager
|
||||
from aqt.toolbar import Toolbar, ToolbarWebView
|
||||
from aqt.undo import UndoActionsInfo
|
||||
from aqt.utils import (
|
||||
HelpPage,
|
||||
|
@ -143,6 +144,27 @@ class MainWebView(AnkiWebView):
|
|||
# currently safe for us to import more than one file at once
|
||||
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):
|
||||
col: Collection
|
||||
|
@ -707,10 +729,16 @@ class AnkiQt(QMainWindow):
|
|||
|
||||
def _reviewState(self, oldState: MainWindowState) -> None:
|
||||
self.reviewer.show()
|
||||
if self.pm.collapse_toolbar():
|
||||
self.toolbarWeb.collapse()
|
||||
else:
|
||||
self.toolbarWeb.flatten()
|
||||
|
||||
def _reviewCleanup(self, newState: MainWindowState) -> None:
|
||||
if newState != "resetRequired" and newState != "review":
|
||||
self.reviewer.cleanup()
|
||||
self.toolbarWeb.elevate()
|
||||
self.toolbarWeb.expand()
|
||||
|
||||
# Resetting state
|
||||
##########################################################################
|
||||
|
@ -844,10 +872,8 @@ title="{}" {}>{}</button>""".format(
|
|||
self.form = aqt.forms.main.Ui_MainWindow()
|
||||
self.form.setupUi(self)
|
||||
# toolbar
|
||||
tweb = self.toolbarWeb = AnkiWebView(title="top toolbar")
|
||||
tweb.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
|
||||
tweb.disable_zoom()
|
||||
self.toolbar = aqt.toolbar.Toolbar(self, tweb)
|
||||
tweb = self.toolbarWeb = ToolbarWebView(self, title="top toolbar")
|
||||
self.toolbar = Toolbar(self, tweb)
|
||||
# main area
|
||||
self.web = MainWebView(self)
|
||||
# bottom area
|
||||
|
@ -1332,6 +1358,10 @@ title="{}" {}>{}</button>""".format(
|
|||
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
|
||||
##########################################################################
|
||||
|
||||
|
@ -1382,13 +1412,6 @@ title="{}" {}>{}</button>""".format(
|
|||
True,
|
||||
parent=self,
|
||||
)
|
||||
self.progress.timer(
|
||||
12 * 60 * 1000,
|
||||
self.refresh_certs,
|
||||
repeat=True,
|
||||
requiresCollection=False,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
def onRefreshTimer(self) -> None:
|
||||
if self.state == "deckBrowser":
|
||||
|
@ -1404,15 +1427,6 @@ title="{}" {}>{}</button>""".format(
|
|||
if elap > minutes * 60:
|
||||
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
|
||||
##########################################################################
|
||||
|
||||
|
|
|
@ -92,6 +92,9 @@ class MPVBase:
|
|||
"--gapless-audio=no",
|
||||
]
|
||||
|
||||
if is_win:
|
||||
default_argv += ["--af-add=lavfi=[apad=pad_dur=0.150]"]
|
||||
|
||||
def __init__(self, window_id=None, debug=False):
|
||||
self.window_id = window_id
|
||||
self.debug = debug
|
||||
|
@ -143,7 +146,7 @@ class MPVBase:
|
|||
--input-unix-socket option.
|
||||
"""
|
||||
if is_win:
|
||||
self._sock_filename = "ankimpv"
|
||||
self._sock_filename = "ankimpv{}".format(os.getpid())
|
||||
return
|
||||
fd, self._sock_filename = tempfile.mkstemp(prefix="mpv.")
|
||||
os.close(fd)
|
||||
|
@ -156,12 +159,11 @@ class MPVBase:
|
|||
start = time.time()
|
||||
while self.is_running() and time.time() < start + 10:
|
||||
time.sleep(0.1)
|
||||
|
||||
if is_win:
|
||||
# named pipe
|
||||
try:
|
||||
self._sock = win32file.CreateFile(
|
||||
r"\\.\pipe\ankimpv",
|
||||
r"\\.\pipe\{}".format(self._sock_filename),
|
||||
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
|
||||
0,
|
||||
None,
|
||||
|
|
|
@ -7,6 +7,7 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _fix_pywin32() -> None:
|
||||
|
@ -49,6 +50,24 @@ def _patch_pkgutil() -> None:
|
|||
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:
|
||||
if not getattr(sys, "frozen", False):
|
||||
return
|
||||
|
@ -59,6 +78,7 @@ def packaged_build_setup() -> None:
|
|||
_fix_pywin32()
|
||||
|
||||
_patch_pkgutil()
|
||||
_patch_certifi()
|
||||
|
||||
# escape hatch for debugging issues with packaged build startup
|
||||
if os.getenv("ANKI_STARTUP_REPL"):
|
||||
|
|
|
@ -208,6 +208,7 @@ class Preferences(QDialog):
|
|||
def setup_global(self) -> None:
|
||||
"Setup options global to all profiles."
|
||||
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))
|
||||
themes = [
|
||||
tr.preferences_theme_label(theme=theme)
|
||||
|
@ -238,7 +239,7 @@ class Preferences(QDialog):
|
|||
restart_required = True
|
||||
|
||||
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())
|
||||
|
||||
if restart_required:
|
||||
|
|
|
@ -524,6 +524,12 @@ create table if not exists profiles
|
|||
def set_reduced_motion(self, on: bool) -> None:
|
||||
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:
|
||||
return self.meta.get("last_addon_update_check", 0)
|
||||
|
||||
|
|
|
@ -543,6 +543,8 @@ class Reviewer:
|
|||
self.showContextMenu()
|
||||
elif url.startswith("play:"):
|
||||
play_clicked_audio(url, self.card)
|
||||
elif url.startswith("updateToolbar"):
|
||||
self.mw.toolbarWeb.update_background_image()
|
||||
else:
|
||||
print("unrecognized anki link:", url)
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# Copyright: Ankitects Pty Ltd and contributors
|
||||
# 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.theme import ThemeManager
|
||||
|
||||
|
@ -101,8 +102,10 @@ QMenu::indicator {{
|
|||
|
||||
|
||||
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"""
|
||||
QPushButton {{ padding-left: 15px; padding-right: 15px; }}
|
||||
QPushButton {{ padding-left: {button_pad}px; padding-right: {button_pad}px; }}
|
||||
QPushButton,
|
||||
QTabBar::tab:!selected,
|
||||
QComboBox:!editable,
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
from typing import cast
|
||||
|
||||
from aqt import colors
|
||||
from aqt import colors, props
|
||||
from aqt.qt import *
|
||||
from aqt.theme import theme_manager
|
||||
|
||||
|
@ -173,7 +173,7 @@ class Switch(QAbstractButton):
|
|||
|
||||
def _animate_toggle(self) -> None:
|
||||
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.setEndValue(self.end_position)
|
||||
# hide label during animation
|
||||
|
|
|
@ -25,6 +25,7 @@ from aqt.qt import (
|
|||
QStyleFactory,
|
||||
Qt,
|
||||
qtmajor,
|
||||
qtminor,
|
||||
)
|
||||
|
||||
|
||||
|
@ -175,6 +176,8 @@ class ThemeManager:
|
|||
classes.append("macos-dark-mode")
|
||||
if aqt.mw.pm.reduced_motion():
|
||||
classes.append("reduced-motion")
|
||||
if qtmajor == 5 and qtminor < 15:
|
||||
classes.append("no-blur")
|
||||
return " ".join(classes)
|
||||
|
||||
def body_classes_for_card_ord(
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
import aqt
|
||||
from anki.sync import SyncStatus
|
||||
|
@ -25,6 +26,76 @@ class BottomToolbar:
|
|||
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:
|
||||
def __init__(self, mw: aqt.AnkiQt, web: AnkiWebView) -> None:
|
||||
self.mw = mw
|
||||
|
@ -32,7 +103,6 @@ class Toolbar:
|
|||
self.link_handlers: dict[str, Callable] = {
|
||||
"study": self._studyLinkHandler,
|
||||
}
|
||||
self.web.setFixedHeight(30)
|
||||
self.web.requiresCol = False
|
||||
|
||||
def draw(
|
||||
|
@ -44,8 +114,13 @@ class Toolbar:
|
|||
web_context = web_context or TopToolbar(self)
|
||||
link_handler = link_handler or self._linkHandler
|
||||
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._body % self._centerLinks(),
|
||||
body,
|
||||
css=["css/toolbar.css"],
|
||||
js=["js/vendor/jquery.min.js", "js/toolbar.js"],
|
||||
context=web_context,
|
||||
|
@ -134,6 +209,22 @@ class Toolbar:
|
|||
|
||||
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
|
||||
######################################################################
|
||||
|
||||
|
@ -195,12 +286,11 @@ class Toolbar:
|
|||
######################################################################
|
||||
|
||||
_body = """
|
||||
<center id=outer>
|
||||
<table id=header>
|
||||
<tr>
|
||||
<td class=tdcenter align=center>%s</td>
|
||||
</tr></table>
|
||||
</center>
|
||||
<div class="header">
|
||||
<div class="left-tray">{left_tray_content}</div>
|
||||
<div class="toolbar">{toolbar_content}</div>
|
||||
<div class="right-tray">{right_tray_content}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
@ -634,7 +634,6 @@ html {{ {font} }}
|
|||
from aqt import mw
|
||||
|
||||
if qvar is None:
|
||||
|
||||
mw.progress.single_shot(1000, mw.reset)
|
||||
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();
|
||||
if path_str.contains("libankihelper") {
|
||||
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)?;
|
||||
} else {
|
||||
if path_str.contains("__pycache__") {
|
||||
|
|
|
@ -55,6 +55,8 @@ def handle_resource(policy, resource):
|
|||
for prefix in included_resource_packages:
|
||||
if resource.package.startswith(prefix):
|
||||
resource.add_include = True
|
||||
if resource.package == "certifi":
|
||||
resource.add_location = "filesystem-relative:lib"
|
||||
for suffix in excluded_resource_suffixes:
|
||||
if resource.name.endswith(suffix):
|
||||
resource.add_include = False
|
||||
|
|
|
@ -47,10 +47,14 @@ for line in re.split(r"[;\{\}]|\*\/", data):
|
|||
print("failed to match", line)
|
||||
continue
|
||||
|
||||
# convert variable names to Qt style
|
||||
var = m.group(1).replace("-", "_").upper()
|
||||
val = m.group(2)
|
||||
|
||||
if reached_props:
|
||||
# remove trailing ms from time props
|
||||
val = re.sub(r"^(\d+)ms$", r"\1", val)
|
||||
|
||||
if not var in props:
|
||||
props.setdefault(var, {})["comment"] = comment
|
||||
props[var]["light"] = val
|
||||
|
|
|
@ -795,6 +795,36 @@ gui_hooks.webview_did_inject_style_into_page.append(mytest)
|
|||
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(
|
||||
name="top_toolbar_did_redraw",
|
||||
args=["top_toolbar: aqt.toolbar.Toolbar"],
|
||||
|
|
|
@ -32,7 +32,7 @@ which = "4.3.0"
|
|||
|
||||
[dev-dependencies]
|
||||
env_logger = "0.10.0"
|
||||
tokio = { version = "1.22", features = ["macros"] }
|
||||
tokio = { version = "1.23", features = ["macros"] }
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "=0.11.3"
|
||||
|
@ -54,7 +54,7 @@ ammonia = "3.3.0"
|
|||
async-trait = "0.1.59"
|
||||
blake3 = "1.3.3"
|
||||
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"
|
||||
convert_case = "0.6.0"
|
||||
dissimilar = "1.0.4"
|
||||
|
@ -90,7 +90,7 @@ sha1 = "0.10.5"
|
|||
snafu = { version = "0.7.3", features = ["backtraces"] }
|
||||
strum = { version = "0.24.1", features = ["derive"] }
|
||||
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"] }
|
||||
tracing = { version = "0.1.37", features = ["max_level_trace", "release_max_level_debug"] }
|
||||
tracing-appender = "0.2.2"
|
||||
|
|
|
@ -20,7 +20,7 @@ linkcheck = { git = "https://github.com/ankitects/linkcheck.git", rev = "2f20798
|
|||
futures = "0.3.25"
|
||||
itertools = "0.10.5"
|
||||
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" }
|
||||
|
||||
[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);
|
||||
builder
|
||||
.set_force_schema11(input.force_schema11)
|
||||
.set_media_paths(input.media_folder_path, input.media_db_path)
|
||||
.set_server(self.server)
|
||||
.set_tr(self.tr.clone());
|
||||
|
|
|
@ -8,7 +8,14 @@ use rusqlite::{
|
|||
};
|
||||
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)]
|
||||
#[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 {
|
||||
fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {
|
||||
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>> {
|
||||
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 resp = match req {
|
||||
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)?
|
||||
}
|
||||
};
|
||||
Ok(serde_json::to_vec(&resp)?)
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
fn update_state_after_modification(col: &mut Collection, sql: &str) {
|
||||
|
@ -128,6 +175,20 @@ fn is_dql(sql: &str) -> bool {
|
|||
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> {
|
||||
let mut stmt = ctx.db.prepare_cached(sql)?;
|
||||
let columns = stmt.column_count();
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
#![allow(clippy::unnecessary_wraps)]
|
||||
|
||||
mod adding;
|
||||
mod ankidroid;
|
||||
mod card;
|
||||
mod cardrendering;
|
||||
mod collection;
|
||||
|
@ -42,6 +43,7 @@ use tokio::runtime::{
|
|||
};
|
||||
|
||||
use self::{
|
||||
ankidroid::AnkidroidService,
|
||||
card::CardsService,
|
||||
cardrendering::CardRenderingService,
|
||||
collection::CollectionService,
|
||||
|
@ -120,6 +122,7 @@ impl Backend {
|
|||
ServiceIndex::from_i32(service as i32)
|
||||
.or_invalid("invalid service")
|
||||
.and_then(|service| match service {
|
||||
ServiceIndex::Ankidroid => AnkidroidService::run_method(self, method, input),
|
||||
ServiceIndex::Scheduler => SchedulerService::run_method(self, method, input),
|
||||
ServiceIndex::Decks => DecksService::run_method(self, method, input),
|
||||
ServiceIndex::Notes => NotesService::run_method(self, method, input),
|
||||
|
|
|
@ -33,6 +33,8 @@ pub struct CollectionBuilder {
|
|||
media_db: Option<PathBuf>,
|
||||
server: Option<bool>,
|
||||
tr: Option<I18n>,
|
||||
// temporary option for AnkiDroid
|
||||
force_schema11: Option<bool>,
|
||||
}
|
||||
|
||||
impl CollectionBuilder {
|
||||
|
@ -53,8 +55,8 @@ impl CollectionBuilder {
|
|||
let server = self.server.unwrap_or_default();
|
||||
let media_folder = self.media_folder.clone().unwrap_or_default();
|
||||
let media_db = self.media_db.clone().unwrap_or_default();
|
||||
|
||||
let storage = SqliteStorage::open_or_create(&col_path, &tr, server)?;
|
||||
let force_schema11 = self.force_schema11.unwrap_or_default();
|
||||
let storage = SqliteStorage::open_or_create(&col_path, &tr, server, force_schema11)?;
|
||||
let col = Collection {
|
||||
storage,
|
||||
col_path,
|
||||
|
@ -88,6 +90,11 @@ impl CollectionBuilder {
|
|||
self.tr = Some(tr);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_force_schema11(&mut self, force: bool) -> &mut Self {
|
||||
self.force_schema11 = Some(force);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
@ -123,14 +123,14 @@ impl ExchangeData {
|
|||
}
|
||||
|
||||
fn check_ids(&self) -> Result<()> {
|
||||
let now = TimestampMillis::now().0;
|
||||
let tomorrow = TimestampMillis::now().adding_secs(86_400).0;
|
||||
if self
|
||||
.cards
|
||||
.iter()
|
||||
.map(|card| card.id.0)
|
||||
.chain(self.notes.iter().map(|note| note.id.0))
|
||||
.chain(self.revlog.iter().map(|entry| entry.id.0))
|
||||
.any(|timestamp| timestamp > now)
|
||||
.any(|timestamp| timestamp > tomorrow)
|
||||
{
|
||||
Err(AnkiError::InvalidId)
|
||||
} else {
|
||||
|
|
|
@ -9,6 +9,7 @@ macro_rules! protobuf {
|
|||
};
|
||||
}
|
||||
|
||||
protobuf!(ankidroid, "ankidroid");
|
||||
protobuf!(backend, "backend");
|
||||
protobuf!(card_rendering, "card_rendering");
|
||||
protobuf!(cards, "cards");
|
||||
|
|
|
@ -578,11 +578,11 @@ impl SqlWriter<'_> {
|
|||
write!(
|
||||
self.sql,
|
||||
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
|
||||
"AND ease != 0) ",
|
||||
// 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,
|
||||
)
|
||||
|
@ -785,8 +785,8 @@ mod test {
|
|||
s(ctx, "introduced:3").0,
|
||||
format!(
|
||||
concat!(
|
||||
"((SELECT min(id) > {cutoff} FROM revlog WHERE cid = c.id AND ease != 0) ",
|
||||
"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff}))"
|
||||
"(((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})))"
|
||||
),
|
||||
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;
|
||||
for review in self.revlog.iter().rev() {
|
||||
if review.id.0 < start_of_today_ms {
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
if review.review_kind == RevlogReviewKind::Manual {
|
||||
continue;
|
||||
|
|
|
@ -762,7 +762,8 @@ mod test {
|
|||
#[test]
|
||||
fn add_card() {
|
||||
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();
|
||||
storage.add_card(&mut card).unwrap();
|
||||
let id1 = card.id;
|
||||
|
|
|
@ -204,7 +204,12 @@ fn trace(s: &str) {
|
|||
}
|
||||
|
||||
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 (create, ver) = schema_version(&db)?;
|
||||
|
||||
|
@ -249,6 +254,13 @@ impl SqliteStorage {
|
|||
|
||||
let storage = Self { db };
|
||||
|
||||
if force_schema11 {
|
||||
if create || upgrade {
|
||||
storage.commit_trx()?;
|
||||
}
|
||||
return storage_with_schema11(storage, ver);
|
||||
}
|
||||
|
||||
if create || upgrade {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
TimestampSecs(self.0 / 1000)
|
||||
}
|
||||
|
||||
pub fn adding_secs(self, secs: i64) -> Self {
|
||||
Self(self.0 + secs * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
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: (
|
||||
fg: (
|
||||
|
@ -107,6 +135,13 @@ $vars: (
|
|||
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: (
|
||||
default: (
|
||||
|
|
|
@ -45,7 +45,7 @@ html {
|
|||
|
||||
button {
|
||||
/* 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);
|
||||
@include button.base;
|
||||
}
|
||||
|
|
|
@ -33,6 +33,6 @@ button {
|
|||
@include elevation(1, $opacity-boost: -0.08);
|
||||
&:hover {
|
||||
@include elevation(2);
|
||||
transition: box-shadow 0.2s linear;
|
||||
transition: box-shadow var(--transition) linear;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ body {
|
|||
color: var(--fg);
|
||||
background: var(--canvas);
|
||||
margin: 1em;
|
||||
transition: opacity 0.5s ease-out;
|
||||
transition: opacity var(--transition-medium) ease-out;
|
||||
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;
|
||||
height: var(--client-height);
|
||||
box-shadow: var(--box-shadow);
|
||||
transition: box-shadow 0.1s ease-in-out;
|
||||
transition: box-shadow var(--transition) ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -63,7 +63,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
@include elevation(4);
|
||||
}
|
||||
}
|
||||
transition: box-shadow 0.2s ease-in-out;
|
||||
transition: box-shadow var(--transition) ease-in-out;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
h1 {
|
||||
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;
|
||||
bottom: 4px;
|
||||
color: var(--fg-faint);
|
||||
transition: color 0.2s linear;
|
||||
transition: color var(--transition) linear;
|
||||
&:hover {
|
||||
transition: none;
|
||||
color: var(--fg);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@import "sass/base";
|
||||
|
||||
// override Bootstrap transition duration
|
||||
$carousel-transition: 0.2s;
|
||||
$carousel-transition: var(--transition);
|
||||
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/button-group";
|
||||
|
|
|
@ -4,9 +4,12 @@
|
|||
import { getSelection, isSelectionCollapsed } from "@tslib/cross-browser";
|
||||
import { elementIsEmpty, nodeIsElement, nodeIsText } from "@tslib/dom";
|
||||
import { on } from "@tslib/events";
|
||||
import type { Unsubscriber } from "svelte/store";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
import { moveChildOutOfElement } from "../domlib/move-nodes";
|
||||
import { placeCaretAfter } from "../domlib/place-caret";
|
||||
import { isComposing } from "../sveltelib/composition";
|
||||
import type { FrameElement } from "./frame-element";
|
||||
|
||||
/**
|
||||
|
@ -53,7 +56,6 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
|
|||
}
|
||||
|
||||
const handleElement = target;
|
||||
const placement = handleElement instanceof FrameStart ? "beforebegin" : "afterend";
|
||||
const frameElement = handleElement.parentElement as FrameElement;
|
||||
|
||||
for (const node of mutation.addedNodes) {
|
||||
|
@ -75,7 +77,7 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
|
|||
referenceNode = moveChildOutOfElement(
|
||||
frameElement,
|
||||
node,
|
||||
placement,
|
||||
handleElement.placement,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -84,25 +86,16 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
|
|||
!nodeIsText(target)
|
||||
|| !isFrameHandle(target.parentElement)
|
||||
|| skippableNode(target.parentElement, target)
|
||||
|| target.parentElement.unsubscribe
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const handleElement = target.parentElement;
|
||||
const placement = handleElement instanceof FrameStart ? "beforebegin" : "afterend";
|
||||
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);
|
||||
if (get(isComposing)) {
|
||||
target.parentElement.subscribeToCompositionEvent();
|
||||
continue;
|
||||
}
|
||||
|
||||
handleElement.refreshSpace();
|
||||
referenceNode = text;
|
||||
referenceNode = target.parentElement.moveTextOutOfFrame(target.data);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -114,6 +107,8 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
|
|||
const handleObserver = new MutationObserver(restoreHandleContent);
|
||||
const handles: Set<FrameHandle> = new Set();
|
||||
|
||||
type Placement = Extract<InsertPosition, "beforebegin" | "afterend">;
|
||||
|
||||
export abstract class FrameHandle extends HTMLElement {
|
||||
static get observedAttributes(): string[] {
|
||||
return ["data-frames"];
|
||||
|
@ -128,6 +123,8 @@ export abstract class FrameHandle extends HTMLElement {
|
|||
*/
|
||||
partiallySelected = false;
|
||||
frames?: string;
|
||||
abstract placement: Placement;
|
||||
unsubscribe: Unsubscriber | null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
@ -136,6 +133,7 @@ export abstract class FrameHandle extends HTMLElement {
|
|||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, old: string, newValue: string): void {
|
||||
|
@ -197,13 +195,56 @@ export abstract class FrameHandle extends HTMLElement {
|
|||
|
||||
this.removeMoveIn?.();
|
||||
this.removeMoveIn = undefined;
|
||||
this.unsubscribeToCompositionEvent();
|
||||
}
|
||||
|
||||
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 {
|
||||
static tagName = "frame-start";
|
||||
placement: Placement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.placement = "beforebegin";
|
||||
}
|
||||
|
||||
getFrameRange(): Range {
|
||||
const range = new Range();
|
||||
|
@ -245,6 +286,12 @@ export class FrameStart extends FrameHandle {
|
|||
|
||||
export class FrameEnd extends FrameHandle {
|
||||
static tagName = "frame-end";
|
||||
placement: Placement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.placement = "afterend";
|
||||
}
|
||||
|
||||
getFrameRange(): 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 { editingInputIsRichText } from "./rich-text-input";
|
||||
|
||||
export let alwaysEnabled = false;
|
||||
|
||||
const { focusedInput, fields } = noteEditorContext.get();
|
||||
|
||||
// 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 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 {
|
||||
display: inline-block;
|
||||
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 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<ClozeButtons on:surround />
|
||||
<ClozeButtons on:surround alwaysEnabled={true} />
|
||||
|
||||
<ButtonGroup>
|
||||
<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 { tick } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
import { isComposing } from "sveltelib/composition";
|
||||
|
||||
import Popover from "../../components/Popover.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("");
|
||||
|
||||
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>();
|
||||
|
||||
allowPromise = promise;
|
||||
|
|
|
@ -87,7 +87,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<style lang="scss">
|
||||
svg {
|
||||
transition: opacity 1s;
|
||||
transition: opacity var(--transition-slow);
|
||||
}
|
||||
|
||||
.counts-outer {
|
||||
|
|
|
@ -21,8 +21,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
<style lang="scss">
|
||||
@use "sass/elevation" as *;
|
||||
.graph {
|
||||
page-break-inside: avoid;
|
||||
|
||||
/* See graph-styles.ts for constants referencing global styles */
|
||||
:global(.graph-element-clickable) {
|
||||
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) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media only print {
|
||||
// grid layout does not honor page-break-inside
|
||||
display: block;
|
||||
margin-top: 3em;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
|
|
|
@ -148,7 +148,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
&.loading {
|
||||
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;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
transition: opacity var(--transition);
|
||||
color: var(--fg);
|
||||
background: var(--canvas-overlay);
|
||||
|
||||
|
|
|
@ -75,14 +75,15 @@ export function buildHistogram(
|
|||
.thresholds(scale.ticks(desiredBars))(data.daysAdded.entries() as any);
|
||||
|
||||
// empty graph?
|
||||
if (!sum(bins, (bin) => bin.length)) {
|
||||
const accessor = getNumericMapBinValue as any;
|
||||
if (!sum(bins, accessor)) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
const adjustedRange = scaleLinear().range([0.7, 0.3]);
|
||||
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 cardsPerDay = Math.round(totalInPeriod / periodDays);
|
||||
const tableData = [
|
||||
|
@ -102,7 +103,7 @@ export function buildHistogram(
|
|||
_percent: number,
|
||||
): string {
|
||||
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 totalCards = tr.statisticsCards({ cards: cumulative });
|
||||
return `${day}:<br>${cards}<br>${total}: ${totalCards}`;
|
||||
|
|
|
@ -145,6 +145,9 @@ export async function _updateQA(
|
|||
|
||||
await _runHook(onUpdateHook);
|
||||
|
||||
// dynamic toolbar background
|
||||
bridgeCommand("updateToolbar");
|
||||
|
||||
// wait for mathjax to ready
|
||||
await MathJax.startup.promise
|
||||
.then(() => {
|
||||
|
|
|
@ -10,7 +10,10 @@ hr {
|
|||
body {
|
||||
margin: 20px;
|
||||
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
|
||||
|
|
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=
|
||||
|
||||
json5@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
|
||||
integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
|
||||
integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==
|
||||
dependencies:
|
||||
minimist "^1.2.0"
|
||||
|
||||
|
|
Loading…
Reference in a new issue