diff --git a/.buildkite/linux/check_contributors b/.buildkite/linux/check_contributors index 925b36575..e84b4c9fc 100755 --- a/.buildkite/linux/check_contributors +++ b/.buildkite/linux/check_contributors @@ -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:" diff --git a/.version b/.version index 7ab0fb4b2..260ff3aae 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.1.56 \ No newline at end of file +2.1.57 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a0f00e07d..1754bd37d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/build/archives/Cargo.toml b/build/archives/Cargo.toml index a7fda4cb7..edb4af65a 100644 --- a/build/archives/Cargo.toml +++ b/build/archives/Cargo.toml @@ -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" diff --git a/build/ninja_gen/src/git.rs b/build/ninja_gen/src/git.rs index c28571f12..f5a668c76 100644 --- a/build/ninja_gen/src/git.rs +++ b/build/ninja_gen/src/git.rs @@ -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) { diff --git a/build/runner/src/bundle/artifacts.rs b/build/runner/src/bundle/artifacts.rs index 9de0f317e..b9e4dcf0f 100644 --- a/build/runner/src/bundle/artifacts.rs +++ b/build/runner/src/bundle/artifacts.rs @@ -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") diff --git a/docs/linux.md b/docs/linux.md index 5fbda5c98..2c0e85cb1 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -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. diff --git a/ftl/core-repo b/ftl/core-repo index 2d13bfbe9..dae74b578 160000 --- a/ftl/core-repo +++ b/ftl/core-repo @@ -1 +1 @@ -Subproject commit 2d13bfbe9e60dd365c4300f1e0589100b8b044af +Subproject commit dae74b5783acf77e2ec9a9f764da254ed041563d diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index 779dc4f70..367ad74ba 100644 --- a/ftl/core/preferences.ftl +++ b/ftl/core/preferences.ftl @@ -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 diff --git a/ftl/qt-repo b/ftl/qt-repo index b0336a1f6..4c7dd7b6c 160000 --- a/ftl/qt-repo +++ b/ftl/qt-repo @@ -1 +1 @@ -Subproject commit b0336a1f67e8379c2a0e7359bc8e8c2a89719652 +Subproject commit 4c7dd7b6cc9d4b666d04eda7cba8a94f68d1660e diff --git a/proto/anki/ankidroid.proto b/proto/anki/ankidroid.proto new file mode 100644 index 000000000..0544eb5e7 --- /dev/null +++ b/proto/anki/ankidroid.proto @@ -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), 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; +} \ No newline at end of file diff --git a/proto/anki/backend.proto b/proto/anki/backend.proto index f57e24c7a..f87ca224a 100644 --- a/proto/anki/backend.proto +++ b/proto/anki/backend.proto @@ -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 diff --git a/proto/anki/collection.proto b/proto/anki/collection.proto index f8902bae7..ff53f881c 100644 --- a/proto/anki/collection.proto +++ b/proto/anki/collection.proto @@ -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 { diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 2f3f2015c..c9bc72363 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -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() diff --git a/pylib/tools/genbackend.py b/pylib/tools/genbackend.py index 919f433d7..36856ccbb 100644 --- a/pylib/tools/genbackend.py +++ b/pylib/tools/genbackend.py @@ -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) diff --git a/python/requirements.dev.in b/python/requirements.dev.in index 3d144ece8..18f16d7c7 100644 --- a/python/requirements.dev.in +++ b/python/requirements.dev.in @@ -9,6 +9,7 @@ mypy-protobuf pip-tools pylint pytest +PyChromeDevTools fluent.syntax types-decorator types-flask diff --git a/python/requirements.dev.txt b/python/requirements.dev.txt index 6257abd79..085ba1878 100644 --- a/python/requirements.dev.txt +++ b/python/requirements.dev.txt @@ -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 diff --git a/python/requirements.qt6_4.in b/python/requirements.qt6_4.in index 2c8e40a4b..dbacbad4a 100644 --- a/python/requirements.qt6_4.in +++ b/python/requirements.qt6_4.in @@ -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 diff --git a/python/requirements.qt6_4.txt b/python/requirements.qt6_4.txt index c92e74650..5712bab4d 100644 --- a/python/requirements.qt6_4.txt +++ b/python/requirements.qt6_4.txt @@ -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 diff --git a/qt/aqt/__init__.py b/qt/aqt/__init__.py index e181c61eb..c5f54ae2b 100644 --- a/qt/aqt/__init__.py +++ b/qt/aqt/__init__.py @@ -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 diff --git a/qt/aqt/browser/sidebar/tree.py b/qt/aqt/browser/sidebar/tree.py index e778ed73b..bfba86396 100644 --- a/qt/aqt/browser/sidebar/tree.py +++ b/qt/aqt/browser/sidebar/tree.py @@ -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() diff --git a/qt/aqt/data/web/css/deckbrowser.scss b/qt/aqt/data/web/css/deckbrowser.scss index 7bfac78dc..00e875290 100644 --- a/qt/aqt/data/web/css/deckbrowser.scss +++ b/qt/aqt/data/web/css/deckbrowser.scss @@ -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 { diff --git a/qt/aqt/data/web/css/reviewer-bottom.scss b/qt/aqt/data/web/css/reviewer-bottom.scss index 0e97d1b43..2c1eb0dad 100644 --- a/qt/aqt/data/web/css/reviewer-bottom.scss +++ b/qt/aqt/data/web/css/reviewer-bottom.scss @@ -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); } diff --git a/qt/aqt/data/web/css/toolbar-bottom.scss b/qt/aqt/data/web/css/toolbar-bottom.scss index 5eb082dde..28e0f1f41 100644 --- a/qt/aqt/data/web/css/toolbar-bottom.scss +++ b/qt/aqt/data/web/css/toolbar-bottom.scss @@ -3,7 +3,6 @@ #header { border-bottom: 0; - margin-bottom: 6px; margin-top: 0; padding: 9px; } diff --git a/qt/aqt/data/web/css/toolbar.scss b/qt/aqt/data/web/css/toolbar.scss index 76b0c74f3..b80cce550 100644 --- a/qt/aqt/data/web/css/toolbar.scss +++ b/qt/aqt/data/web/css/toolbar.scss @@ -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 { diff --git a/qt/aqt/data/web/css/webview.scss b/qt/aqt/data/web/css/webview.scss index 3f97e21e8..dd954efee 100644 --- a/qt/aqt/data/web/css/webview.scss +++ b/qt/aqt/data/web/css/webview.scss @@ -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 { diff --git a/qt/aqt/data/web/js/toolbar.ts b/qt/aqt/data/web/js/toolbar.ts index b8e4f6be8..e01c67920 100644 --- a/qt/aqt/data/web/js/toolbar.ts +++ b/qt/aqt/data/web/js/toolbar.ts @@ -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(".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); diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index af8c46fff..c550fd45e 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -7,7 +7,7 @@ 0 0 640 - 640 + 660 @@ -113,6 +113,13 @@ + + + + preferences_collapse_toolbar + + + @@ -666,7 +673,7 @@ - + preferences_some_settings_will_take_effect_after @@ -696,6 +703,7 @@ ignore_accents_in_search legacy_import_export reduce_motion + collapse_toolbar useCurrent default_search_text uiScale diff --git a/qt/aqt/main.py b/qt/aqt/main.py index fd5ac5c5d..53660e6a7 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -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="{}" {}>{}""".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="{}" {}>{}""".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="{}" {}>{}""".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="{}" {}>{}""".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 ########################################################################## diff --git a/qt/aqt/mpv.py b/qt/aqt/mpv.py index f62355dd6..cae1201f9 100644 --- a/qt/aqt/mpv.py +++ b/qt/aqt/mpv.py @@ -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, diff --git a/qt/aqt/package.py b/qt/aqt/package.py index 1cee7d5b4..6bece47d1 100644 --- a/qt/aqt/package.py +++ b/qt/aqt/package.py @@ -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"): diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index 9f485764b..0b9048ff6 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -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: diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py index 335df1758..6cb53b5b2 100644 --- a/qt/aqt/profiles.py +++ b/qt/aqt/profiles.py @@ -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) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 5c300ae3f..dda339e93 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -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) diff --git a/qt/aqt/stylesheets.py b/qt/aqt/stylesheets.py index e67f518c7..1087524c0 100644 --- a/qt/aqt/stylesheets.py +++ b/qt/aqt/stylesheets.py @@ -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, diff --git a/qt/aqt/switch.py b/qt/aqt/switch.py index d846ad6fb..a68e53860 100644 --- a/qt/aqt/switch.py +++ b/qt/aqt/switch.py @@ -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 diff --git a/qt/aqt/theme.py b/qt/aqt/theme.py index 9352bbf5c..4ca47bd44 100644 --- a/qt/aqt/theme.py +++ b/qt/aqt/theme.py @@ -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( diff --git a/qt/aqt/toolbar.py b/qt/aqt/toolbar.py index cae262e5b..6e1bbd2b5 100644 --- a/qt/aqt/toolbar.py +++ b/qt/aqt/toolbar.py @@ -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"""
{item}
""" for item in content) + # Sync ###################################################################### @@ -195,12 +286,11 @@ class Toolbar: ###################################################################### _body = """ -
- - - - -
+
+
{left_tray_content}
+
{toolbar_content}
+
{right_tray_content}
+
""" diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index 5bb7de371..5bba81fc7 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -634,7 +634,6 @@ html {{ {font} }} from aqt import mw if qvar is None: - mw.progress.single_shot(1000, mw.reset) return diff --git a/qt/bundle/mac/src/main.rs b/qt/bundle/mac/src/main.rs index 399249b3d..597f10157 100644 --- a/qt/bundle/mac/src/main.rs +++ b/qt/bundle/mac/src/main.rs @@ -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__") { diff --git a/qt/bundle/pyoxidizer.bzl b/qt/bundle/pyoxidizer.bzl index 0fba834b1..57721ef77 100644 --- a/qt/bundle/pyoxidizer.bzl +++ b/qt/bundle/pyoxidizer.bzl @@ -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 diff --git a/qt/tools/extract_sass_vars.py b/qt/tools/extract_sass_vars.py index aff1031fb..bc2422596 100644 --- a/qt/tools/extract_sass_vars.py +++ b/qt/tools/extract_sass_vars.py @@ -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 diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 145de2cb0..f4fda4d17 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -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"], diff --git a/rslib/Cargo.toml b/rslib/Cargo.toml index 230bd40be..74b4999d6 100644 --- a/rslib/Cargo.toml +++ b/rslib/Cargo.toml @@ -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" diff --git a/rslib/linkchecker/Cargo.toml b/rslib/linkchecker/Cargo.toml index bc852eb44..20bd5ea52 100644 --- a/rslib/linkchecker/Cargo.toml +++ b/rslib/linkchecker/Cargo.toml @@ -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] diff --git a/rslib/src/backend/ankidroid/db.rs b/rslib/src/backend/ankidroid/db.rs new file mode 100644 index 000000000..be5668eb6 --- /dev/null +++ b/rslib/src/backend/ankidroid/db.rs @@ -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, +} + +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::(), + Data::DoubleValue(_) => size_of::(), + 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) -> Vec { + select_slice_of_size(rows, get_max_page_size()) + .into_inner() + .1 +} + +fn select_slice_of_size<'a>( + mut rows: impl Iterator, + max_size: usize, +) -> FoldWhile<(usize, Vec)> { + let init: Vec = 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>> = + 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 { + 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.
+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 { + 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 { + 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 = 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 = 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 { + let ret: DBArgs = serde_json::from_slice(in_bytes)?; + Ok(ret) +} + +pub(crate) fn insert_for_id(col: &Collection, json: &[u8]) -> Result { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/rslib/src/backend/ankidroid/error.rs b/rslib/src/backend/ankidroid/error.rs new file mode 100644 index 000000000..c1c24615f --- /dev/null +++ b/rslib/src/backend/ankidroid/error.rs @@ -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, + }, + }, + } +} diff --git a/rslib/src/backend/ankidroid/mod.rs b/rslib/src/backend/ankidroid/mod.rs new file mode 100644 index 000000000..da190a564 --- /dev/null +++ b/rslib/src/backend/ankidroid/mod.rs @@ -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 { + 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 { + Ok(pb::generic::Int32 { + val: timing::local_minutes_west_for_stamp(input.val.into())?, + }) + } + + fn run_db_command(&self, input: Json) -> Result { + self.with_col(|col| db_command_bytes(col, &input.json)) + .map(|json| Json { json }) + } + + fn run_db_command_proto(&self, input: Json) -> Result { + self.with_col(|col| db_command_proto(col, &input.json)) + } + + fn run_db_command_for_row_count(&self, input: Json) -> Result { + 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 { + self.with_col(|col| { + db::flush_collection(col); + Ok(Empty {}) + }) + } + + fn flush_query(&self, input: Int32) -> Result { + self.with_col(|col| { + db::flush_single_result(col, input.val); + Ok(Empty {}) + }) + } + + fn get_next_result_page(&self, input: GetNextResultPageRequest) -> Result { + 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 { + self.with_col(|col| insert_for_id(col, &input.json).map(Into::into)) + } + + fn set_page_size(&self, input: pb::generic::Int64) -> Result { + // 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 { + 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 { + self.with_col(|col| { + Ok(GetActiveSequenceNumbersResponse { + numbers: active_sequences(col), + }) + }) + } + + fn debug_produce_error(&self, input: generic::String) -> Result { + Err(debug_produce_error(&input.val)) + } +} diff --git a/rslib/src/backend/collection.rs b/rslib/src/backend/collection.rs index a46aa6d4f..4b6cbddd4 100644 --- a/rslib/src/backend/collection.rs +++ b/rslib/src/backend/collection.rs @@ -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()); diff --git a/rslib/src/backend/dbproxy.rs b/rslib/src/backend/dbproxy.rs index f43a0c517..0738b3d1b 100644 --- a/rslib/src/backend/dbproxy.rs +++ b/rslib/src/backend/dbproxy.rs @@ -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> for pb::ankidroid::Row { + fn from(item: &Vec) -> Self { + Row { + fields: item.iter().map(pb::ankidroid::SqlValue::from).collect(), + } + } +} + +impl From<&Vec>> for pb::ankidroid::DbResult { + fn from(item: &Vec>) -> Self { + ProtoDbResult { + rows: item.iter().map(Row::from).collect(), + } + } +} + impl FromSql for SqlValue { fn column_result(value: ValueRef<'_>) -> std::result::Result { let val = match value { @@ -71,6 +114,10 @@ impl FromSql for SqlValue { } pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result> { + 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 { 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 bool { head.starts_with("select") } +pub(crate) fn db_command_proto(col: &mut Collection, input: &[u8]) -> Result { + 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 { let mut stmt = ctx.db.prepare_cached(sql)?; let columns = stmt.column_count(); diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 75a36571e..1f681f925 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -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), diff --git a/rslib/src/collection/mod.rs b/rslib/src/collection/mod.rs index 51dbd1d30..6cf8f429b 100644 --- a/rslib/src/collection/mod.rs +++ b/rslib/src/collection/mod.rs @@ -33,6 +33,8 @@ pub struct CollectionBuilder { media_db: Option, server: Option, tr: Option, + // temporary option for AnkiDroid + force_schema11: Option, } 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)] diff --git a/rslib/src/import_export/gather.rs b/rslib/src/import_export/gather.rs index 028a142a7..8a3fa4999 100644 --- a/rslib/src/import_export/gather.rs +++ b/rslib/src/import_export/gather.rs @@ -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 { diff --git a/rslib/src/pb.rs b/rslib/src/pb.rs index ae4e01dc8..bcb2f34c4 100644 --- a/rslib/src/pb.rs +++ b/rslib/src/pb.rs @@ -9,6 +9,7 @@ macro_rules! protobuf { }; } +protobuf!(ankidroid, "ankidroid"); protobuf!(backend, "backend"); protobuf!(card_rendering, "card_rendering"); protobuf!(cards, "cards"); diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index 92d4adb29..afd86f915 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -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, ) diff --git a/rslib/src/stats/graphs/today.rs b/rslib/src/stats/graphs/today.rs index d96f2de7b..0b3ba3542 100644 --- a/rslib/src/stats/graphs/today.rs +++ b/rslib/src/stats/graphs/today.rs @@ -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; diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index b569bb070..e06b5d373 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -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; diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index afd078e16..7a829548d 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -204,7 +204,12 @@ fn trace(s: &str) { } impl SqliteStorage { - pub(crate) fn open_or_create(path: &Path, tr: &I18n, server: bool) -> Result { + pub(crate) fn open_or_create( + path: &Path, + tr: &I18n, + server: bool, + force_schema11: bool, + ) -> Result { 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 { + 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) +} diff --git a/rslib/src/timestamp.rs b/rslib/src/timestamp.rs index ba07167a5..31b9b5cbd 100644 --- a/rslib/src/timestamp.rs +++ b/rslib/src/timestamp.rs @@ -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 { diff --git a/sass/_vars.scss b/sass/_vars.scss index fe7a08ac9..ef10bca04 100644 --- a/sass/_vars.scss +++ b/sass/_vars.scss @@ -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: ( diff --git a/sass/base.scss b/sass/base.scss index 0491a59fb..fd8b0e148 100644 --- a/sass/base.scss +++ b/sass/base.scss @@ -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; } diff --git a/sass/buttons.scss b/sass/buttons.scss index 85b93eace..215df2cce 100644 --- a/sass/buttons.scss +++ b/sass/buttons.scss @@ -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; } } diff --git a/sass/core.scss b/sass/core.scss index d6562ed17..46bfb6fc8 100644 --- a/sass/core.scss +++ b/sass/core.scss @@ -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; } diff --git a/tools/rebuild-web b/tools/rebuild-web new file mode 100755 index 000000000..3440f579a --- /dev/null +++ b/tools/rebuild-web @@ -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 diff --git a/tools/reload_webviews.py b/tools/reload_webviews.py new file mode 100755 index 000000000..7afda5616 --- /dev/null +++ b/tools/reload_webviews.py @@ -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() diff --git a/tools/ts-watch b/tools/ts-watch deleted file mode 100755 index ba2c73235..000000000 --- a/tools/ts-watch +++ /dev/null @@ -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" \ No newline at end of file diff --git a/tools/web-watch b/tools/web-watch new file mode 100755 index 000000000..9e6820d7a --- /dev/null +++ b/tools/web-watch @@ -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" diff --git a/ts/components/PaneContent.svelte b/ts/components/PaneContent.svelte index dedb19d1b..516e4a85b 100644 --- a/ts/components/PaneContent.svelte +++ b/ts/components/PaneContent.svelte @@ -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; } } diff --git a/ts/components/TitledContainer.svelte b/ts/components/TitledContainer.svelte index 185f2cc0d..e653c1caf 100644 --- a/ts/components/TitledContainer.svelte +++ b/ts/components/TitledContainer.svelte @@ -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); diff --git a/ts/deck-options/deck-options-base.scss b/ts/deck-options/deck-options-base.scss index b88d78d93..9710faa22 100644 --- a/ts/deck-options/deck-options-base.scss +++ b/ts/deck-options/deck-options-base.scss @@ -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"; diff --git a/ts/editable/frame-handle.ts b/ts/editable/frame-handle.ts index 64ae17155..ce42a03cb 100644 --- a/ts/editable/frame-handle.ts +++ b/ts/editable/frame-handle.ts @@ -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 = new Set(); +type Placement = Extract; + 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(); diff --git a/ts/editor/ClozeButtons.svelte b/ts/editor/ClozeButtons.svelte index 489597b46..07586c0ba 100644 --- a/ts/editor/ClozeButtons.svelte +++ b/ts/editor/ClozeButtons.svelte @@ -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"; diff --git a/ts/editor/CollapseBadge.svelte b/ts/editor/CollapseBadge.svelte index 608e1a1cc..3ca97677c 100644 --- a/ts/editor/CollapseBadge.svelte +++ b/ts/editor/CollapseBadge.svelte @@ -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; } diff --git a/ts/editor/mathjax-overlay/MathjaxButtons.svelte b/ts/editor/mathjax-overlay/MathjaxButtons.svelte index d16737e9c..2857cd04b 100644 --- a/ts/editor/mathjax-overlay/MathjaxButtons.svelte +++ b/ts/editor/mathjax-overlay/MathjaxButtons.svelte @@ -38,7 +38,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - + (); allowPromise = promise; diff --git a/ts/graphs/CardCounts.svelte b/ts/graphs/CardCounts.svelte index 0ffcfe02e..67876129d 100644 --- a/ts/graphs/CardCounts.svelte +++ b/ts/graphs/CardCounts.svelte @@ -87,7 +87,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html