diff --git a/proto/anki/collection.proto b/proto/anki/collection.proto
index 1a760035e..6d78cd35b 100644
--- a/proto/anki/collection.proto
+++ b/proto/anki/collection.proto
@@ -8,6 +8,7 @@ option java_multiple_files = true;
package anki.collection;
import "anki/generic.proto";
+import "anki/sync.proto";
service CollectionService {
rpc CheckDatabase(generic.Empty) returns (CheckDatabaseResponse);
@@ -100,12 +101,6 @@ message OpChangesAfterUndo {
}
message Progress {
- message MediaSync {
- string checked = 1;
- string added = 2;
- string removed = 3;
- }
-
message FullSync {
uint32 transferred = 1;
uint32 total = 2;
@@ -136,7 +131,7 @@ message Progress {
oneof value {
generic.Empty none = 1;
- MediaSync media_sync = 2;
+ sync.MediaSyncProgress media_sync = 2;
string media_check = 3;
FullSync full_sync = 4;
NormalSync normal_sync = 5;
diff --git a/proto/anki/sync.proto b/proto/anki/sync.proto
index 368287195..bc65359c8 100644
--- a/proto/anki/sync.proto
+++ b/proto/anki/sync.proto
@@ -9,17 +9,19 @@ package anki.sync;
import "anki/generic.proto";
-/// Syncing methods are only available with a Backend handle.
+// Syncing methods are only available with a Backend handle.
service SyncService {}
service BackendSyncService {
rpc SyncMedia(SyncAuth) returns (generic.Empty);
rpc AbortMediaSync(generic.Empty) returns (generic.Empty);
+ // Can be used by the frontend to detect an active sync. If the sync aborted
+ // with an error, the next call to this method will return the error.
+ rpc MediaSyncStatus(generic.Empty) returns (MediaSyncStatusResponse);
rpc SyncLogin(SyncLoginRequest) returns (SyncAuth);
rpc SyncStatus(SyncAuth) returns (SyncStatusResponse);
- rpc SyncCollection(SyncAuth) returns (SyncCollectionResponse);
- rpc FullUpload(SyncAuth) returns (generic.Empty);
- rpc FullDownload(SyncAuth) returns (generic.Empty);
+ rpc SyncCollection(SyncCollectionRequest) returns (SyncCollectionResponse);
+ rpc FullUploadOrDownload(FullUploadOrDownloadRequest) returns (generic.Empty);
rpc AbortSync(generic.Empty) returns (generic.Empty);
}
@@ -45,6 +47,11 @@ message SyncStatusResponse {
optional string new_endpoint = 4;
}
+message SyncCollectionRequest {
+ SyncAuth auth = 1;
+ bool sync_media = 2;
+}
+
message SyncCollectionResponse {
enum ChangesRequired {
NO_CHANGES = 0;
@@ -60,4 +67,23 @@ message SyncCollectionResponse {
string server_message = 2;
ChangesRequired required = 3;
optional string new_endpoint = 4;
+ int32 server_media_usn = 5;
+}
+
+message MediaSyncStatusResponse {
+ bool active = 1;
+ MediaSyncProgress progress = 2;
+}
+
+message MediaSyncProgress {
+ string checked = 1;
+ string added = 2;
+ string removed = 3;
+}
+
+message FullUploadOrDownloadRequest {
+ SyncAuth auth = 1;
+ bool upload = 2;
+ // if not provided, media syncing will be skipped
+ optional int32 server_usn = 3;
}
diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index 8073334c6..a17664e6a 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -16,6 +16,7 @@ from anki import (
links_pb2,
search_pb2,
stats_pb2,
+ sync_pb2,
)
from anki._legacy import DeprecatedNamesMixin, deprecated
from anki.sync_pb2 import SyncLoginRequest
@@ -49,6 +50,7 @@ AddImageOcclusionNoteRequest = image_occlusion_pb2.AddImageOcclusionNoteRequest
GetImageOcclusionNoteResponse = image_occlusion_pb2.GetImageOcclusionNoteResponse
AddonInfo = ankiweb_pb2.AddonInfo
CheckForUpdateResponse = ankiweb_pb2.CheckForUpdateResponse
+MediaSyncStatus = sync_pb2.MediaSyncStatusResponse
import copy
import os
@@ -1246,11 +1248,14 @@ class Collection(DeprecatedNamesMixin):
def abort_sync(self) -> None:
self._backend.abort_sync()
- def full_upload(self, auth: SyncAuth) -> None:
- self._backend.full_upload(auth)
-
- def full_download(self, auth: SyncAuth) -> None:
- self._backend.full_download(auth)
+ def full_upload_or_download(
+ self, *, auth: SyncAuth, server_usn: int | None, upload: bool
+ ) -> None:
+ self._backend.full_upload_or_download(
+ sync_pb2.FullUploadOrDownloadRequest(
+ auth=auth, server_usn=server_usn, upload=upload
+ )
+ )
def sync_login(
self, username: str, password: str, endpoint: str | None
@@ -1259,8 +1264,8 @@ class Collection(DeprecatedNamesMixin):
SyncLoginRequest(username=username, password=password, endpoint=endpoint)
)
- def sync_collection(self, auth: SyncAuth) -> SyncOutput:
- return self._backend.sync_collection(auth)
+ def sync_collection(self, auth: SyncAuth, sync_media: bool) -> SyncOutput:
+ return self._backend.sync_collection(auth=auth, sync_media=sync_media)
def sync_media(self, auth: SyncAuth) -> None:
self._backend.sync_media(auth)
@@ -1268,6 +1273,10 @@ class Collection(DeprecatedNamesMixin):
def sync_status(self, auth: SyncAuth) -> SyncStatus:
return self._backend.sync_status(auth)
+ def media_sync_status(self) -> MediaSyncStatus:
+ "This will throw if the sync failed with an error."
+ return self._backend.media_sync_status()
+
def get_preferences(self) -> Preferences:
return self._backend.get_preferences()
diff --git a/qt/aqt/forms/synclog.ui b/qt/aqt/forms/synclog.ui
index e0d93b35a..c48416bc9 100644
--- a/qt/aqt/forms/synclog.ui
+++ b/qt/aqt/forms/synclog.ui
@@ -6,8 +6,8 @@
0
0
- 557
- 295
+ 482
+ 90
@@ -15,12 +15,15 @@
-
-
-
- true
+
+
+ TextLabel
-
-
+
+ Qt::PlainText
+
+
+ Qt::AlignCenter
diff --git a/qt/aqt/main.py b/qt/aqt/main.py
index f707ffc02..df25ac058 100644
--- a/qt/aqt/main.py
+++ b/qt/aqt/main.py
@@ -1019,9 +1019,6 @@ title="{}" {}>{}""".format(
def _sync_collection_and_media(self, after_sync: Callable[[], None]) -> None:
"Caller should ensure auth available."
- # start media sync if not already running
- if not self.media_syncer.is_syncing():
- self.media_syncer.start()
def on_collection_sync_finished() -> None:
self.col.clear_python_undo()
diff --git a/qt/aqt/mediasync.py b/qt/aqt/mediasync.py
index a5617c0b6..f0312102e 100644
--- a/qt/aqt/mediasync.py
+++ b/qt/aqt/mediasync.py
@@ -5,110 +5,93 @@ from __future__ import annotations
import time
from concurrent.futures import Future
-from dataclasses import dataclass
-from typing import Any, Callable, Union
+from datetime import datetime
+from typing import Any, Callable
import aqt
import aqt.forms
import aqt.main
-from anki.collection import Progress
+from anki.collection import Collection
from anki.errors import Interrupted
-from anki.types import assert_exhaustive
from anki.utils import int_time
from aqt import gui_hooks
-from aqt.qt import QDialog, QDialogButtonBox, QPushButton, QTextCursor, QTimer, qconnect
-from aqt.utils import disable_help_button, tr
-
-LogEntry = Union[Progress.MediaSync, str]
-
-
-@dataclass
-class LogEntryWithTime:
- time: int
- entry: LogEntry
+from aqt.operations import QueryOp
+from aqt.qt import QDialog, QDialogButtonBox, QPushButton, Qt, QTimer, qconnect
+from aqt.utils import disable_help_button, show_info, tr
class MediaSyncer:
def __init__(self, mw: aqt.main.AnkiQt) -> None:
self.mw = mw
self._syncing: bool = False
- self._log: list[LogEntryWithTime] = []
- self._progress_timer: QTimer | None = None
+ self.last_progress = ""
+ self._last_progress_at = 0
gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop)
- def _on_progress(self) -> None:
- progress = self.mw.col.latest_progress()
- if not progress.HasField("media_sync"):
- return
- sync_progress = progress.media_sync
- self._log_and_notify(sync_progress)
-
def start(self) -> None:
"Start media syncing in the background, if it's not already running."
+ if not self.mw.pm.media_syncing_enabled() or not (
+ auth := self.mw.pm.sync_auth()
+ ):
+ return
+
+ def run(col: Collection) -> None:
+ col.sync_media(auth)
+
+ # this will exit after the thread is spawned, but may block if there's an existing
+ # backend lock
+ QueryOp(parent=aqt.mw, op=run, success=lambda _: 1).run_in_background()
+
+ self.start_monitoring()
+
+ def start_monitoring(self) -> None:
if self._syncing:
return
-
- if not self.mw.pm.media_syncing_enabled():
- self._log_and_notify(tr.sync_media_disabled())
- return
-
- auth = self.mw.pm.sync_auth()
- if auth is None:
- return
-
- self._log_and_notify(tr.sync_media_starting())
self._syncing = True
- self._progress_timer = self.mw.progress.timer(
- 1000, self._on_progress, True, True, parent=self.mw
- )
gui_hooks.media_sync_did_start_or_stop(True)
+ self._update_progress(tr.sync_media_starting())
- def run() -> None:
- self.mw.col.sync_media(auth)
+ def monitor() -> None:
+ while True:
+ resp = self.mw.col.media_sync_status()
+ if not resp.active:
+ return
+ if p := resp.progress:
+ self._update_progress(f"{p.added}, {p.removed}, {p.checked}")
- self.mw.taskman.run_in_background(run, self._on_finished)
+ time.sleep(0.25)
- def _log_and_notify(self, entry: LogEntry) -> None:
- entry_with_time = LogEntryWithTime(time=int_time(), entry=entry)
- self._log.append(entry_with_time)
- self.mw.taskman.run_on_main(
- lambda: gui_hooks.media_sync_did_progress(entry_with_time)
- )
+ self.mw.taskman.run_in_background(monitor, self._on_finished)
+
+ def _update_progress(self, progress: str) -> None:
+ self.last_progress = progress
+ self.mw.taskman.run_on_main(lambda: gui_hooks.media_sync_did_progress(progress))
def _on_finished(self, future: Future) -> None:
self._syncing = False
- if self._progress_timer:
- self._progress_timer.stop()
- self._progress_timer.deleteLater()
- self._progress_timer = None
+ self._last_progress_at = int_time()
gui_hooks.media_sync_did_start_or_stop(False)
exc = future.exception()
if exc is not None:
self._handle_sync_error(exc)
else:
- self._log_and_notify(tr.sync_media_complete())
+ self._update_progress(tr.sync_media_complete())
def _handle_sync_error(self, exc: BaseException) -> None:
if isinstance(exc, Interrupted):
- self._log_and_notify(tr.sync_media_aborted())
+ self._update_progress(tr.sync_media_aborted())
return
else:
- # Avoid popups for errors; they can cause a deadlock if
- # a modal window happens to be active, or a duplicate auth
- # failed message if the password is changed.
- self._log_and_notify(str(exc))
+ show_info(str(exc), modality=Qt.WindowModality.NonModal)
return
- def entries(self) -> list[LogEntryWithTime]:
- return self._log
-
def abort(self) -> None:
if not self.is_syncing():
return
- self._log_and_notify(tr.sync_media_aborting())
self.mw.col.set_wants_abort()
self.mw.col.abort_media_sync()
+ self._update_progress(tr.sync_media_aborting())
def is_syncing(self) -> bool:
return self._syncing
@@ -140,11 +123,7 @@ class MediaSyncer:
if self.is_syncing():
return 0
- if self._log:
- last = self._log[-1].time
- else:
- last = 0
- return int_time() - last
+ return int_time() - self._last_progress_at
class MediaSyncDialog(QDialog):
@@ -172,10 +151,7 @@ class MediaSyncDialog(QDialog):
gui_hooks.media_sync_did_progress.append(self._on_log_entry)
gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop)
- self.form.plainTextEdit.setPlainText(
- "\n".join(self._entry_to_text(x) for x in syncer.entries())
- )
- self.form.plainTextEdit.moveCursor(QTextCursor.MoveOperation.End)
+ self._on_log_entry(syncer.last_progress)
self.show()
def reject(self) -> None:
@@ -197,24 +173,11 @@ class MediaSyncDialog(QDialog):
self._syncer.abort()
self.abort_button.setHidden(True)
- def _time_and_text(self, stamp: int, text: str) -> str:
- asctime = time.asctime(time.localtime(stamp))
- return f"{asctime}: {text}"
-
- def _entry_to_text(self, entry: LogEntryWithTime) -> str:
- if isinstance(entry.entry, str):
- txt = entry.entry
- elif isinstance(entry.entry, Progress.MediaSync):
- txt = self._logentry_to_text(entry.entry)
- else:
- assert_exhaustive(entry.entry)
- return self._time_and_text(entry.time, txt)
-
- def _logentry_to_text(self, e: Progress.MediaSync) -> str:
- return f"{e.added}, {e.removed}, {e.checked}"
-
- def _on_log_entry(self, entry: LogEntryWithTime) -> None:
- self.form.plainTextEdit.appendPlainText(self._entry_to_text(entry))
+ def _on_log_entry(self, entry: str) -> None:
+ dt = datetime.fromtimestamp(int_time())
+ time = dt.strftime("%H:%M:%S")
+ text = f"{time}: {entry}"
+ self.form.log_label.setText(text)
if not self._syncer.is_syncing():
self.abort_button.setHidden(True)
diff --git a/qt/aqt/sync.py b/qt/aqt/sync.py
index 34f7a2dc1..f44a49b1f 100644
--- a/qt/aqt/sync.py
+++ b/qt/aqt/sync.py
@@ -113,14 +113,15 @@ def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
if out.server_message:
showText(out.server_message)
if out.required == out.NO_CHANGES:
- # all done
+ # all done; track media progress
+ mw.media_syncer.start_monitoring()
return on_done()
else:
full_sync(mw, out, on_done)
mw.col.save(trx=False)
mw.taskman.with_progress(
- lambda: mw.col.sync_collection(auth),
+ lambda: mw.col.sync_collection(auth, mw.pm.media_syncing_enabled()),
on_future_done,
label=tr.sync_checking(),
immediate=True,
@@ -130,10 +131,11 @@ def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
def full_sync(
mw: aqt.main.AnkiQt, out: SyncOutput, on_done: Callable[[], None]
) -> None:
+ server_usn = out.server_media_usn if mw.pm.media_syncing_enabled() else None
if out.required == out.FULL_DOWNLOAD:
- confirm_full_download(mw, on_done)
+ confirm_full_download(mw, server_usn, on_done)
elif out.required == out.FULL_UPLOAD:
- full_upload(mw, on_done)
+ full_upload(mw, server_usn, on_done)
else:
button_labels: list[str] = [
tr.sync_upload_to_ankiweb(),
@@ -143,9 +145,9 @@ def full_sync(
def callback(choice: int) -> None:
if choice == 0:
- full_upload(mw, on_done)
+ full_upload(mw, server_usn, on_done)
elif choice == 1:
- full_download(mw, on_done)
+ full_download(mw, server_usn, on_done)
else:
on_done()
@@ -157,13 +159,15 @@ def full_sync(
)
-def confirm_full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
+def confirm_full_download(
+ mw: aqt.main.AnkiQt, server_usn: int, on_done: Callable[[], None]
+) -> None:
# confirmation step required, as some users customize their notetypes
# in an empty collection, then want to upload them
if not askUser(tr.sync_confirm_empty_download()):
return on_done()
else:
- mw.closeAllWindows(lambda: full_download(mw, on_done))
+ mw.closeAllWindows(lambda: full_download(mw, server_usn, on_done))
def on_full_sync_timer(mw: aqt.main.AnkiQt, label: str) -> None:
@@ -185,7 +189,9 @@ def on_full_sync_timer(mw: aqt.main.AnkiQt, label: str) -> None:
mw.col.abort_sync()
-def full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
+def full_download(
+ mw: aqt.main.AnkiQt, server_usn: int, on_done: Callable[[], None]
+) -> None:
label = tr.sync_downloading_from_ankiweb()
def on_timer() -> None:
@@ -201,7 +207,9 @@ def full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
def download() -> None:
mw.create_backup_now()
mw.col.close_for_full_sync()
- mw.col.full_download(mw.pm.sync_auth())
+ mw.col.full_upload_or_download(
+ auth=mw.pm.sync_auth(), server_usn=server_usn, upload=False
+ )
def on_future_done(fut: Future) -> None:
timer.stop()
@@ -211,7 +219,7 @@ def full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
fut.result()
except Exception as err:
handle_sync_error(mw, err)
- mw.media_syncer.start()
+ mw.media_syncer.start_monitoring()
return on_done()
mw.taskman.with_progress(
@@ -220,7 +228,9 @@ def full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
)
-def full_upload(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
+def full_upload(
+ mw: aqt.main.AnkiQt, server_usn: int | None, on_done: Callable[[], None]
+) -> None:
gui_hooks.collection_will_temporarily_close(mw.col)
mw.col.close_for_full_sync()
@@ -242,11 +252,13 @@ def full_upload(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
except Exception as err:
handle_sync_error(mw, err)
return on_done()
- mw.media_syncer.start()
+ mw.media_syncer.start_monitoring()
return on_done()
mw.taskman.with_progress(
- lambda: mw.col.full_upload(mw.pm.sync_auth()),
+ lambda: mw.col.full_upload_or_download(
+ auth=mw.pm.sync_auth(), server_usn=server_usn, upload=True
+ ),
on_future_done,
)
diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py
index 32b9215b1..8b0f833d9 100644
--- a/qt/aqt/utils.py
+++ b/qt/aqt/utils.py
@@ -141,12 +141,13 @@ class MessageBox(QMessageBox):
buttons: Sequence[str | QMessageBox.StandardButton] | None = None,
default_button: int = 0,
textFormat: Qt.TextFormat = Qt.TextFormat.PlainText,
+ modality: Qt.WindowModality = Qt.WindowModality.WindowModal,
) -> None:
parent = parent or aqt.mw.app.activeWindow() or aqt.mw
super().__init__(parent)
self.setText(text)
self.setWindowTitle(title)
- self.setWindowModality(Qt.WindowModality.WindowModal)
+ self.setWindowModality(modality)
self.setIcon(icon)
if icon == QMessageBox.Icon.Question and theme_manager.night_mode:
img = self.iconPixmap().toImage()
diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py
index 5145925f5..6a4cb0567 100644
--- a/qt/tools/genhooks_gui.py
+++ b/qt/tools/genhooks_gui.py
@@ -881,7 +881,7 @@ gui_hooks.webview_did_inject_style_into_page.append(mytest)
),
Hook(
name="media_sync_did_progress",
- args=["entry: aqt.mediasync.LogEntryWithTime"],
+ args=["entry: str"],
),
Hook(name="media_sync_did_start_or_stop", args=["running: bool"]),
Hook(
diff --git a/rslib/process/src/lib.rs b/rslib/process/src/lib.rs
index aebcf9fb3..fcf91ae2c 100644
--- a/rslib/process/src/lib.rs
+++ b/rslib/process/src/lib.rs
@@ -17,7 +17,7 @@ pub enum Error {
cmdline: String,
source: std::io::Error,
},
- #[snafu(display("Fail with code {code:?}: {cmdline}"))]
+ #[snafu(display("Failed with code {code:?}: {cmdline}"))]
ReturnedError { cmdline: String, code: Option },
#[snafu(display("Couldn't decode stdout/stderr as utf8"))]
InvalidUtf8 {
diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs
index eb611dafd..4d4bd95eb 100644
--- a/rslib/src/backend/mod.rs
+++ b/rslib/src/backend/mod.rs
@@ -55,6 +55,7 @@ pub struct BackendInner {
runtime: OnceCell,
state: Mutex,
backup_task: Mutex