2019-02-05 04:59:03 +01:00
|
|
|
# Copyright: Ankitects Pty Ltd and contributors
|
2012-12-21 08:51:59 +01:00
|
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
2021-03-10 09:20:37 +01:00
|
|
|
|
2021-03-26 07:06:02 +01:00
|
|
|
from typing import Any, cast
|
|
|
|
|
2016-04-05 03:02:01 +02:00
|
|
|
import anki.lang
|
2012-12-21 08:51:59 +01:00
|
|
|
import aqt
|
2022-02-13 04:40:47 +01:00
|
|
|
import aqt.forms
|
|
|
|
import aqt.operations
|
2021-08-19 02:47:55 +02:00
|
|
|
from anki.collection import OpChanges
|
2021-10-22 12:39:49 +02:00
|
|
|
from anki.consts import new_card_scheduling_labels
|
2019-12-20 10:19:03 +01:00
|
|
|
from aqt import AnkiQt
|
2021-08-19 02:47:55 +02:00
|
|
|
from aqt.operations.collection import set_preferences
|
2021-10-11 10:23:38 +02:00
|
|
|
from aqt.profiles import VideoDriver
|
2019-12-20 10:19:03 +01:00
|
|
|
from aqt.qt import *
|
2021-11-24 22:17:41 +01:00
|
|
|
from aqt.theme import Theme
|
2021-03-26 05:21:04 +01:00
|
|
|
from aqt.utils import HelpPage, disable_help_button, openHelp, showInfo, showWarning, tr
|
2019-12-20 10:19:03 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
class Preferences(QDialog):
|
2021-02-01 14:28:21 +01:00
|
|
|
def __init__(self, mw: AnkiQt) -> None:
|
2021-10-05 05:53:01 +02:00
|
|
|
QDialog.__init__(self, mw, Qt.WindowType.Window)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.mw = mw
|
|
|
|
self.prof = self.mw.pm.profile
|
|
|
|
self.form = aqt.forms.preferences.Ui_Preferences()
|
|
|
|
self.form.setupUi(self)
|
2021-01-07 05:24:49 +01:00
|
|
|
disable_help_button(self)
|
2021-10-05 05:53:01 +02:00
|
|
|
self.form.buttonBox.button(QDialogButtonBox.StandardButton.Help).setAutoDefault(
|
|
|
|
False
|
|
|
|
)
|
|
|
|
self.form.buttonBox.button(
|
|
|
|
QDialogButtonBox.StandardButton.Close
|
|
|
|
).setAutoDefault(False)
|
2021-01-25 14:45:47 +01:00
|
|
|
qconnect(
|
|
|
|
self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.PREFERENCES)
|
|
|
|
)
|
2017-09-10 07:15:12 +02:00
|
|
|
self.silentlyClose = True
|
2021-03-10 09:20:37 +01:00
|
|
|
self.setup_collection()
|
|
|
|
self.setup_profile()
|
|
|
|
self.setup_global()
|
2012-12-21 08:51:59 +01:00
|
|
|
self.show()
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def accept(self) -> None:
|
2015-04-26 12:28:32 +02:00
|
|
|
# avoid exception if main window is already closed
|
|
|
|
if not self.mw.col:
|
|
|
|
return
|
2021-08-19 02:47:55 +02:00
|
|
|
|
|
|
|
def after_collection_update() -> None:
|
|
|
|
self.update_profile()
|
|
|
|
self.update_global()
|
|
|
|
self.mw.pm.save()
|
|
|
|
self.done(0)
|
|
|
|
aqt.dialogs.markClosed("Preferences")
|
|
|
|
|
|
|
|
self.update_collection(after_collection_update)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def reject(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
self.accept()
|
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
# Preferences stored in the collection
|
2016-04-05 03:02:01 +02:00
|
|
|
######################################################################
|
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
def setup_collection(self) -> None:
|
|
|
|
self.prefs = self.mw.col.get_preferences()
|
2016-04-05 03:02:01 +02:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
form = self.form
|
|
|
|
|
|
|
|
scheduling = self.prefs.scheduling
|
2021-05-13 07:23:16 +02:00
|
|
|
|
|
|
|
version = scheduling.scheduler_version
|
|
|
|
form.dayLearnFirst.setVisible(version == 2)
|
|
|
|
form.legacy_timezone.setVisible(version >= 2)
|
|
|
|
form.newSpread.setVisible(version < 3)
|
2021-05-27 15:09:49 +02:00
|
|
|
form.sched2021.setVisible(version >= 2)
|
2021-05-13 07:23:16 +02:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
form.lrnCutoff.setValue(int(scheduling.learn_ahead_secs / 60.0))
|
2021-10-22 12:39:49 +02:00
|
|
|
form.newSpread.addItems(list(new_card_scheduling_labels(self.mw.col).values()))
|
2021-03-10 09:20:37 +01:00
|
|
|
form.newSpread.setCurrentIndex(scheduling.new_review_mix)
|
|
|
|
form.dayLearnFirst.setChecked(scheduling.day_learn_first)
|
|
|
|
form.dayOffset.setValue(scheduling.rollover)
|
2021-05-13 07:23:16 +02:00
|
|
|
form.legacy_timezone.setChecked(not scheduling.new_timezone)
|
2021-05-27 15:09:49 +02:00
|
|
|
form.sched2021.setChecked(version == 3)
|
2021-03-10 09:20:37 +01:00
|
|
|
|
|
|
|
reviewing = self.prefs.reviewing
|
|
|
|
form.timeLimit.setValue(int(reviewing.time_limit_secs / 60.0))
|
|
|
|
form.showEstimates.setChecked(reviewing.show_intervals_on_buttons)
|
|
|
|
form.showProgress.setChecked(reviewing.show_remaining_due_counts)
|
|
|
|
form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons)
|
|
|
|
form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering)
|
|
|
|
|
|
|
|
editing = self.prefs.editing
|
|
|
|
form.useCurrent.setCurrentIndex(
|
|
|
|
0 if editing.adding_defaults_to_current_deck else 1
|
2020-11-17 08:42:43 +01:00
|
|
|
)
|
2021-03-10 09:20:37 +01:00
|
|
|
form.paste_strips_formatting.setChecked(editing.paste_strips_formatting)
|
2022-02-17 07:30:52 +01:00
|
|
|
form.ignore_accents_in_search.setChecked(editing.ignore_accents_in_search)
|
2021-03-10 09:20:37 +01:00
|
|
|
form.pastePNG.setChecked(editing.paste_images_as_png)
|
2021-06-24 03:23:25 +02:00
|
|
|
form.default_search_text.setText(editing.default_search_text)
|
2021-03-10 09:20:37 +01:00
|
|
|
|
Backups (#1685)
* Add zstd dep
* Implement backend backup with zstd
* Implement backup thinning
* Write backup meta
* Use new file ending anki21b
* Asynchronously backup on collection close in Rust
* Revert "Add zstd dep"
This reverts commit 3fcb2141d2be15f907269d13275c41971431385c.
* Add zstd again
* Take backup col path from col struct
* Fix formatting
* Implement backup restoring on backend
* Normalize restored media file names
* Refactor `extract_legacy_data()`
A bit cumbersome due to borrowing rules.
* Refactor
* Make thinning calendar-based and gradual
* Consider last kept backups of previous stages
* Import full apkgs and colpkgs with backend
* Expose new backup settings
* Test `BackupThinner` and make it deterministic
* Mark backup_path when closing optional
* Delete leaky timer
* Add progress updates for restoring media
* Write restored collection to tempfile first
* Do collection compression in the background thread
This has us currently storing an uncompressed and compressed copy of
the collection in memory (not ideal), but means the collection can be
closed without waiting for compression to complete. On a large collection,
this takes a close and reopen from about 0.55s to about 0.07s. The old
backup code for comparison: about 0.35s for compression off, about
8.5s for zip compression.
* Use multithreading in zstd compression
On my system, this reduces the compression time of a large collection
from about 0.55s to 0.08s.
* Stream compressed collection data into zip file
* Tweak backup explanation
+ Fix incorrect tab order for ignore accents option
* Decouple restoring backup and full import
In the first case, no profile is opened, unless the new collection
succeeds to load.
In the second case, either the old collection is reloaded or the new one
is loaded.
* Fix number gap in Progress message
* Don't revert backup when media fails but report it
* Tweak error flow
* Remove native BackupLimits enum
* Fix type annotation
* Add thinning test for whole year
* Satisfy linter
* Await async backup to finish
* Move restart disclaimer out of backup tab
Should be visible regardless of the current tab.
* Write restored collection in chunks
* Refactor
* Write media in chunks and refactor
* Log error if removing file fails
* join_backup_task -> await_backup_completion
* Refactor backup.rs
* Refactor backup meta and collection extraction
* Fix wrong error being returned
* Call sync_all() on new collection
* Add ImportError
* Store logger in Backend, instead of creating one on demand
init_backend() accepts a Logger rather than a log file, to allow other
callers to customize the logger if they wish.
In the future we may want to explore using the tracing crate as an
alternative; it's a bit more ergonomic, as a logger doesn't need to be
passed around, and it plays more nicely with async code.
* Sync file contents prior to rename; sync folder after rename.
* Limit backup creation to once per 30 min
* Use zstd::stream::copy_decode
* Make importing abortable
* Don't revert if backup media is aborted
* Set throttle implicitly
* Change force flag to minimum_backup_interval
* Don't attempt to open folders on Windows
* Join last backup thread before starting new one
Also refactor.
* Disable auto sync and backup when restoring again
* Force backup on full download
* Include the reason why a media file import failed, and the file path
- Introduce a FileIoError that contains a string representation of
the underlying I/O error, and an associated path. There are a few
places in the code where we're currently manually including the filename
in a custom error message, and this is a step towards a more consistent
approach (but we may be better served with a more general approach in
the future similar to Anyhow's .context())
- Move the error message into importing.ftl, as it's a bit neater
when error messages live in the same file as the rest of the messages
associated with some functionality.
* Fix importing of media files
* Minor wording tweaks
* Save an allocation
I18n strings with replacements are already strings, so we can skip the
extra allocation. Not that it matters here at all.
* Terminate import if file missing from archive
If a third-party tool is creating invalid archives, the user should know
about it. This should be rare, so I did not attempt to make it
translatable.
* Skip multithreaded compression on small collections
Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2022-03-07 06:11:31 +01:00
|
|
|
form.backup_explanation.setText(
|
|
|
|
anki.lang.with_collapsed_whitespace(tr.preferences_backup_explanation())
|
|
|
|
)
|
|
|
|
form.daily_backups.setValue(self.prefs.backups.daily)
|
|
|
|
form.weekly_backups.setValue(self.prefs.backups.weekly)
|
|
|
|
form.monthly_backups.setValue(self.prefs.backups.monthly)
|
Backup improvements (#1728)
* Collection needs to be closed prior to backup even when not downgrading
* Backups -> BackupLimits
* Some improvements to backup_task
- backup_inner now returns the error instead of logging it, so that
the frontend can discover the issue when they await a backup (or create
another one)
- start_backup() was acquiring backup_task twice, and if another thread
started a backup between the two locks, the task could have been accidentally
overwritten without awaiting it
* Backups no longer require a collection close
- Instead of closing the collection, we ensure there is no active
transaction, and flush the WAL to disk. This means the undo history
is no longer lost on backup, which will be particularly useful if we
add a periodic backup in the future.
- Because a close is no longer required, backups are now achieved with
a separate command, instead of being included in CloseCollection().
- Full sync no longer requires an extra close+reopen step, and we now
wait for the backup to complete before proceeding.
- Create a backup before 'check db'
* Add File>Create Backup
https://forums.ankiweb.net/t/anki-mac-os-no-backup-on-sync/6157
* Defer checkpoint until we know we need it
When running periodic backups on a timer, we don't want to be fsync()ing
unnecessarily.
* Skip backup if modification time has not changed
We don't want the user leaving Anki open overnight, and coming back
to lots of identical backups.
* Periodic backups
Creates an automatic backup every 30 minutes if the collection has been
modified.
If there's a legacy checkpoint active, tries again 5 minutes later.
* Switch to a user-configurable backup duration
CreateBackup() now uses a simple force argument to determine whether
the user's limits should be respected or not, and only potentially
destructive ops (full download, check DB) override the user's configured
limit.
I considered having a separate limit for collection close and automatic
backups (eg keeping the previous 5 minute limit for collection close),
but that had two downsides:
- When the user closes their collection at the end of the day, they'd
get a recent backup. When they open the collection the next day, it
would get backed up again within 5 minutes, even though not much had
changed.
- Multiple limits are harder to communicate to users in the UI
Some remaining decisions I wasn't 100% sure about:
- If force is true but the collection has not been modified, the backup
will be skipped. If the user manually deleted their backups without
closing Anki, they wouldn't get a new one if the mtime hadn't changed.
- Force takes preference over the configured backup interval - should
we be ignored the user here, or take no backups at all?
Did a sneaky edit of the existing ftl string, as it hasn't been live
long.
* Move maybe_backup() into Collection
* Use a single method for manual and periodic backups
When manually creating a backup via the File menu, we no longer make
the user wait until the backup completes. As we continue waiting for
the backup in the background, if any errors occur, the user will get
notified about it fairly quickly.
* Show message to user if backup was skipped due to no changes
+ Don't incorrectly assert a backup will be created on force
* Add "automatic" to description
* Ensure we backup prior to importing colpkg if collection open
The backup doesn't happen when invoked from 'open backup' in the profile
screen, which matches Anki's previous behaviour. The user could
potentially clobber up to 30 minutes of their work if they exited to
the profile screen and restored a backup, but the alternative is we
create backups every time a backup is restored, which may happen a number
of times if the user is trying various ones. Or we could go back to a
separate throttle amount for this case, at the cost of more complexity.
* Remove the 0 special case on backup interval; minimum of 5 minutes
https://github.com/ankitects/anki/pull/1728#discussion_r830876833
2022-03-21 10:40:42 +01:00
|
|
|
form.minutes_between_backups.setValue(self.prefs.backups.minimum_interval_mins)
|
Backups (#1685)
* Add zstd dep
* Implement backend backup with zstd
* Implement backup thinning
* Write backup meta
* Use new file ending anki21b
* Asynchronously backup on collection close in Rust
* Revert "Add zstd dep"
This reverts commit 3fcb2141d2be15f907269d13275c41971431385c.
* Add zstd again
* Take backup col path from col struct
* Fix formatting
* Implement backup restoring on backend
* Normalize restored media file names
* Refactor `extract_legacy_data()`
A bit cumbersome due to borrowing rules.
* Refactor
* Make thinning calendar-based and gradual
* Consider last kept backups of previous stages
* Import full apkgs and colpkgs with backend
* Expose new backup settings
* Test `BackupThinner` and make it deterministic
* Mark backup_path when closing optional
* Delete leaky timer
* Add progress updates for restoring media
* Write restored collection to tempfile first
* Do collection compression in the background thread
This has us currently storing an uncompressed and compressed copy of
the collection in memory (not ideal), but means the collection can be
closed without waiting for compression to complete. On a large collection,
this takes a close and reopen from about 0.55s to about 0.07s. The old
backup code for comparison: about 0.35s for compression off, about
8.5s for zip compression.
* Use multithreading in zstd compression
On my system, this reduces the compression time of a large collection
from about 0.55s to 0.08s.
* Stream compressed collection data into zip file
* Tweak backup explanation
+ Fix incorrect tab order for ignore accents option
* Decouple restoring backup and full import
In the first case, no profile is opened, unless the new collection
succeeds to load.
In the second case, either the old collection is reloaded or the new one
is loaded.
* Fix number gap in Progress message
* Don't revert backup when media fails but report it
* Tweak error flow
* Remove native BackupLimits enum
* Fix type annotation
* Add thinning test for whole year
* Satisfy linter
* Await async backup to finish
* Move restart disclaimer out of backup tab
Should be visible regardless of the current tab.
* Write restored collection in chunks
* Refactor
* Write media in chunks and refactor
* Log error if removing file fails
* join_backup_task -> await_backup_completion
* Refactor backup.rs
* Refactor backup meta and collection extraction
* Fix wrong error being returned
* Call sync_all() on new collection
* Add ImportError
* Store logger in Backend, instead of creating one on demand
init_backend() accepts a Logger rather than a log file, to allow other
callers to customize the logger if they wish.
In the future we may want to explore using the tracing crate as an
alternative; it's a bit more ergonomic, as a logger doesn't need to be
passed around, and it plays more nicely with async code.
* Sync file contents prior to rename; sync folder after rename.
* Limit backup creation to once per 30 min
* Use zstd::stream::copy_decode
* Make importing abortable
* Don't revert if backup media is aborted
* Set throttle implicitly
* Change force flag to minimum_backup_interval
* Don't attempt to open folders on Windows
* Join last backup thread before starting new one
Also refactor.
* Disable auto sync and backup when restoring again
* Force backup on full download
* Include the reason why a media file import failed, and the file path
- Introduce a FileIoError that contains a string representation of
the underlying I/O error, and an associated path. There are a few
places in the code where we're currently manually including the filename
in a custom error message, and this is a step towards a more consistent
approach (but we may be better served with a more general approach in
the future similar to Anyhow's .context())
- Move the error message into importing.ftl, as it's a bit neater
when error messages live in the same file as the rest of the messages
associated with some functionality.
* Fix importing of media files
* Minor wording tweaks
* Save an allocation
I18n strings with replacements are already strings, so we can skip the
extra allocation. Not that it matters here at all.
* Terminate import if file missing from archive
If a third-party tool is creating invalid archives, the user should know
about it. This should be rare, so I did not attempt to make it
translatable.
* Skip multithreaded compression on small collections
Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2022-03-07 06:11:31 +01:00
|
|
|
|
2021-08-19 02:47:55 +02:00
|
|
|
def update_collection(self, on_done: Callable[[], None]) -> None:
|
2021-03-10 09:20:37 +01:00
|
|
|
form = self.form
|
|
|
|
|
|
|
|
scheduling = self.prefs.scheduling
|
2021-03-26 07:06:02 +01:00
|
|
|
scheduling.new_review_mix = cast(Any, form.newSpread.currentIndex())
|
2021-03-10 09:20:37 +01:00
|
|
|
scheduling.learn_ahead_secs = form.lrnCutoff.value() * 60
|
|
|
|
scheduling.day_learn_first = form.dayLearnFirst.isChecked()
|
|
|
|
scheduling.rollover = form.dayOffset.value()
|
|
|
|
scheduling.new_timezone = not form.legacy_timezone.isChecked()
|
|
|
|
|
|
|
|
reviewing = self.prefs.reviewing
|
|
|
|
reviewing.show_remaining_due_counts = form.showProgress.isChecked()
|
|
|
|
reviewing.show_intervals_on_buttons = form.showEstimates.isChecked()
|
|
|
|
reviewing.time_limit_secs = form.timeLimit.value() * 60
|
|
|
|
reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked()
|
|
|
|
reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked()
|
|
|
|
|
|
|
|
editing = self.prefs.editing
|
|
|
|
editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex()
|
|
|
|
editing.paste_images_as_png = self.form.pastePNG.isChecked()
|
|
|
|
editing.paste_strips_formatting = self.form.paste_strips_formatting.isChecked()
|
2021-06-24 03:23:25 +02:00
|
|
|
editing.default_search_text = self.form.default_search_text.text()
|
2022-02-17 07:30:52 +01:00
|
|
|
editing.ignore_accents_in_search = (
|
|
|
|
self.form.ignore_accents_in_search.isChecked()
|
|
|
|
)
|
2016-04-05 03:02:01 +02:00
|
|
|
|
Backups (#1685)
* Add zstd dep
* Implement backend backup with zstd
* Implement backup thinning
* Write backup meta
* Use new file ending anki21b
* Asynchronously backup on collection close in Rust
* Revert "Add zstd dep"
This reverts commit 3fcb2141d2be15f907269d13275c41971431385c.
* Add zstd again
* Take backup col path from col struct
* Fix formatting
* Implement backup restoring on backend
* Normalize restored media file names
* Refactor `extract_legacy_data()`
A bit cumbersome due to borrowing rules.
* Refactor
* Make thinning calendar-based and gradual
* Consider last kept backups of previous stages
* Import full apkgs and colpkgs with backend
* Expose new backup settings
* Test `BackupThinner` and make it deterministic
* Mark backup_path when closing optional
* Delete leaky timer
* Add progress updates for restoring media
* Write restored collection to tempfile first
* Do collection compression in the background thread
This has us currently storing an uncompressed and compressed copy of
the collection in memory (not ideal), but means the collection can be
closed without waiting for compression to complete. On a large collection,
this takes a close and reopen from about 0.55s to about 0.07s. The old
backup code for comparison: about 0.35s for compression off, about
8.5s for zip compression.
* Use multithreading in zstd compression
On my system, this reduces the compression time of a large collection
from about 0.55s to 0.08s.
* Stream compressed collection data into zip file
* Tweak backup explanation
+ Fix incorrect tab order for ignore accents option
* Decouple restoring backup and full import
In the first case, no profile is opened, unless the new collection
succeeds to load.
In the second case, either the old collection is reloaded or the new one
is loaded.
* Fix number gap in Progress message
* Don't revert backup when media fails but report it
* Tweak error flow
* Remove native BackupLimits enum
* Fix type annotation
* Add thinning test for whole year
* Satisfy linter
* Await async backup to finish
* Move restart disclaimer out of backup tab
Should be visible regardless of the current tab.
* Write restored collection in chunks
* Refactor
* Write media in chunks and refactor
* Log error if removing file fails
* join_backup_task -> await_backup_completion
* Refactor backup.rs
* Refactor backup meta and collection extraction
* Fix wrong error being returned
* Call sync_all() on new collection
* Add ImportError
* Store logger in Backend, instead of creating one on demand
init_backend() accepts a Logger rather than a log file, to allow other
callers to customize the logger if they wish.
In the future we may want to explore using the tracing crate as an
alternative; it's a bit more ergonomic, as a logger doesn't need to be
passed around, and it plays more nicely with async code.
* Sync file contents prior to rename; sync folder after rename.
* Limit backup creation to once per 30 min
* Use zstd::stream::copy_decode
* Make importing abortable
* Don't revert if backup media is aborted
* Set throttle implicitly
* Change force flag to minimum_backup_interval
* Don't attempt to open folders on Windows
* Join last backup thread before starting new one
Also refactor.
* Disable auto sync and backup when restoring again
* Force backup on full download
* Include the reason why a media file import failed, and the file path
- Introduce a FileIoError that contains a string representation of
the underlying I/O error, and an associated path. There are a few
places in the code where we're currently manually including the filename
in a custom error message, and this is a step towards a more consistent
approach (but we may be better served with a more general approach in
the future similar to Anyhow's .context())
- Move the error message into importing.ftl, as it's a bit neater
when error messages live in the same file as the rest of the messages
associated with some functionality.
* Fix importing of media files
* Minor wording tweaks
* Save an allocation
I18n strings with replacements are already strings, so we can skip the
extra allocation. Not that it matters here at all.
* Terminate import if file missing from archive
If a third-party tool is creating invalid archives, the user should know
about it. This should be rare, so I did not attempt to make it
translatable.
* Skip multithreaded compression on small collections
Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2022-03-07 06:11:31 +01:00
|
|
|
self.prefs.backups.daily = form.daily_backups.value()
|
|
|
|
self.prefs.backups.weekly = form.weekly_backups.value()
|
|
|
|
self.prefs.backups.monthly = form.monthly_backups.value()
|
Backup improvements (#1728)
* Collection needs to be closed prior to backup even when not downgrading
* Backups -> BackupLimits
* Some improvements to backup_task
- backup_inner now returns the error instead of logging it, so that
the frontend can discover the issue when they await a backup (or create
another one)
- start_backup() was acquiring backup_task twice, and if another thread
started a backup between the two locks, the task could have been accidentally
overwritten without awaiting it
* Backups no longer require a collection close
- Instead of closing the collection, we ensure there is no active
transaction, and flush the WAL to disk. This means the undo history
is no longer lost on backup, which will be particularly useful if we
add a periodic backup in the future.
- Because a close is no longer required, backups are now achieved with
a separate command, instead of being included in CloseCollection().
- Full sync no longer requires an extra close+reopen step, and we now
wait for the backup to complete before proceeding.
- Create a backup before 'check db'
* Add File>Create Backup
https://forums.ankiweb.net/t/anki-mac-os-no-backup-on-sync/6157
* Defer checkpoint until we know we need it
When running periodic backups on a timer, we don't want to be fsync()ing
unnecessarily.
* Skip backup if modification time has not changed
We don't want the user leaving Anki open overnight, and coming back
to lots of identical backups.
* Periodic backups
Creates an automatic backup every 30 minutes if the collection has been
modified.
If there's a legacy checkpoint active, tries again 5 minutes later.
* Switch to a user-configurable backup duration
CreateBackup() now uses a simple force argument to determine whether
the user's limits should be respected or not, and only potentially
destructive ops (full download, check DB) override the user's configured
limit.
I considered having a separate limit for collection close and automatic
backups (eg keeping the previous 5 minute limit for collection close),
but that had two downsides:
- When the user closes their collection at the end of the day, they'd
get a recent backup. When they open the collection the next day, it
would get backed up again within 5 minutes, even though not much had
changed.
- Multiple limits are harder to communicate to users in the UI
Some remaining decisions I wasn't 100% sure about:
- If force is true but the collection has not been modified, the backup
will be skipped. If the user manually deleted their backups without
closing Anki, they wouldn't get a new one if the mtime hadn't changed.
- Force takes preference over the configured backup interval - should
we be ignored the user here, or take no backups at all?
Did a sneaky edit of the existing ftl string, as it hasn't been live
long.
* Move maybe_backup() into Collection
* Use a single method for manual and periodic backups
When manually creating a backup via the File menu, we no longer make
the user wait until the backup completes. As we continue waiting for
the backup in the background, if any errors occur, the user will get
notified about it fairly quickly.
* Show message to user if backup was skipped due to no changes
+ Don't incorrectly assert a backup will be created on force
* Add "automatic" to description
* Ensure we backup prior to importing colpkg if collection open
The backup doesn't happen when invoked from 'open backup' in the profile
screen, which matches Anki's previous behaviour. The user could
potentially clobber up to 30 minutes of their work if they exited to
the profile screen and restored a backup, but the alternative is we
create backups every time a backup is restored, which may happen a number
of times if the user is trying various ones. Or we could go back to a
separate throttle amount for this case, at the cost of more complexity.
* Remove the 0 special case on backup interval; minimum of 5 minutes
https://github.com/ankitects/anki/pull/1728#discussion_r830876833
2022-03-21 10:40:42 +01:00
|
|
|
self.prefs.backups.minimum_interval_mins = form.minutes_between_backups.value()
|
Backups (#1685)
* Add zstd dep
* Implement backend backup with zstd
* Implement backup thinning
* Write backup meta
* Use new file ending anki21b
* Asynchronously backup on collection close in Rust
* Revert "Add zstd dep"
This reverts commit 3fcb2141d2be15f907269d13275c41971431385c.
* Add zstd again
* Take backup col path from col struct
* Fix formatting
* Implement backup restoring on backend
* Normalize restored media file names
* Refactor `extract_legacy_data()`
A bit cumbersome due to borrowing rules.
* Refactor
* Make thinning calendar-based and gradual
* Consider last kept backups of previous stages
* Import full apkgs and colpkgs with backend
* Expose new backup settings
* Test `BackupThinner` and make it deterministic
* Mark backup_path when closing optional
* Delete leaky timer
* Add progress updates for restoring media
* Write restored collection to tempfile first
* Do collection compression in the background thread
This has us currently storing an uncompressed and compressed copy of
the collection in memory (not ideal), but means the collection can be
closed without waiting for compression to complete. On a large collection,
this takes a close and reopen from about 0.55s to about 0.07s. The old
backup code for comparison: about 0.35s for compression off, about
8.5s for zip compression.
* Use multithreading in zstd compression
On my system, this reduces the compression time of a large collection
from about 0.55s to 0.08s.
* Stream compressed collection data into zip file
* Tweak backup explanation
+ Fix incorrect tab order for ignore accents option
* Decouple restoring backup and full import
In the first case, no profile is opened, unless the new collection
succeeds to load.
In the second case, either the old collection is reloaded or the new one
is loaded.
* Fix number gap in Progress message
* Don't revert backup when media fails but report it
* Tweak error flow
* Remove native BackupLimits enum
* Fix type annotation
* Add thinning test for whole year
* Satisfy linter
* Await async backup to finish
* Move restart disclaimer out of backup tab
Should be visible regardless of the current tab.
* Write restored collection in chunks
* Refactor
* Write media in chunks and refactor
* Log error if removing file fails
* join_backup_task -> await_backup_completion
* Refactor backup.rs
* Refactor backup meta and collection extraction
* Fix wrong error being returned
* Call sync_all() on new collection
* Add ImportError
* Store logger in Backend, instead of creating one on demand
init_backend() accepts a Logger rather than a log file, to allow other
callers to customize the logger if they wish.
In the future we may want to explore using the tracing crate as an
alternative; it's a bit more ergonomic, as a logger doesn't need to be
passed around, and it plays more nicely with async code.
* Sync file contents prior to rename; sync folder after rename.
* Limit backup creation to once per 30 min
* Use zstd::stream::copy_decode
* Make importing abortable
* Don't revert if backup media is aborted
* Set throttle implicitly
* Change force flag to minimum_backup_interval
* Don't attempt to open folders on Windows
* Join last backup thread before starting new one
Also refactor.
* Disable auto sync and backup when restoring again
* Force backup on full download
* Include the reason why a media file import failed, and the file path
- Introduce a FileIoError that contains a string representation of
the underlying I/O error, and an associated path. There are a few
places in the code where we're currently manually including the filename
in a custom error message, and this is a step towards a more consistent
approach (but we may be better served with a more general approach in
the future similar to Anyhow's .context())
- Move the error message into importing.ftl, as it's a bit neater
when error messages live in the same file as the rest of the messages
associated with some functionality.
* Fix importing of media files
* Minor wording tweaks
* Save an allocation
I18n strings with replacements are already strings, so we can skip the
extra allocation. Not that it matters here at all.
* Terminate import if file missing from archive
If a third-party tool is creating invalid archives, the user should know
about it. This should be rare, so I did not attempt to make it
translatable.
* Skip multithreaded compression on small collections
Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2022-03-07 06:11:31 +01:00
|
|
|
|
2021-08-19 02:47:55 +02:00
|
|
|
def after_prefs_update(changes: OpChanges) -> None:
|
|
|
|
self.mw.apply_collection_options()
|
|
|
|
if scheduling.scheduler_version > 1:
|
|
|
|
want_v3 = form.sched2021.isChecked()
|
|
|
|
if self.mw.col.v3_scheduler() != want_v3:
|
|
|
|
self.mw.col.set_v3_scheduler(want_v3)
|
|
|
|
|
|
|
|
on_done()
|
2020-05-11 10:35:15 +02:00
|
|
|
|
2021-08-19 02:47:55 +02:00
|
|
|
set_preferences(parent=self, preferences=self.prefs).success(
|
|
|
|
after_prefs_update
|
|
|
|
).run_in_background()
|
2021-05-27 15:09:49 +02:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
# Preferences stored in the profile
|
|
|
|
######################################################################
|
2020-05-11 10:35:15 +02:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
def setup_profile(self) -> None:
|
|
|
|
"Setup options stored in the user profile."
|
|
|
|
self.setup_network()
|
2020-05-11 10:35:15 +02:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
def update_profile(self) -> None:
|
|
|
|
self.update_network()
|
2020-05-11 10:35:15 +02:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
# Profile: network
|
2012-12-21 08:51:59 +01:00
|
|
|
######################################################################
|
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
def setup_network(self) -> None:
|
2021-03-26 04:48:26 +01:00
|
|
|
self.form.media_log.setText(tr.sync_media_log_button())
|
2020-05-04 05:23:08 +02:00
|
|
|
qconnect(self.form.media_log.clicked, self.on_media_log)
|
2019-12-23 01:34:10 +01:00
|
|
|
self.form.syncOnProgramOpen.setChecked(self.prof["autoSync"])
|
|
|
|
self.form.syncMedia.setChecked(self.prof["syncMedia"])
|
2020-07-01 03:35:24 +02:00
|
|
|
self.form.autoSyncMedia.setChecked(self.mw.pm.auto_sync_media_minutes() != 0)
|
2019-12-23 01:34:10 +01:00
|
|
|
if not self.prof["syncKey"]:
|
2021-03-10 09:20:37 +01:00
|
|
|
self._hide_sync_auth_settings()
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
2019-12-23 01:34:10 +01:00
|
|
|
self.form.syncUser.setText(self.prof.get("syncUser", ""))
|
2021-03-10 09:20:37 +01:00
|
|
|
qconnect(self.form.syncDeauth.clicked, self.sync_logout)
|
2021-03-26 04:48:26 +01:00
|
|
|
self.form.syncDeauth.setText(tr.sync_log_out_button())
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def on_media_log(self) -> None:
|
2020-02-27 03:22:24 +01:00
|
|
|
self.mw.media_syncer.show_sync_log()
|
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
def _hide_sync_auth_settings(self) -> None:
|
2013-04-15 06:46:07 +02:00
|
|
|
self.form.syncDeauth.setVisible(False)
|
2013-10-03 23:09:36 +02:00
|
|
|
self.form.syncUser.setText("")
|
2019-12-23 01:34:10 +01:00
|
|
|
self.form.syncLabel.setText(
|
2021-03-26 04:48:26 +01:00
|
|
|
tr.preferences_synchronizationnot_currently_enabled_click_the_sync()
|
2019-12-23 01:34:10 +01:00
|
|
|
)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
def sync_logout(self) -> None:
|
2020-02-15 08:48:35 +01:00
|
|
|
if self.mw.media_syncer.is_syncing():
|
|
|
|
showWarning("Can't log out while sync in progress.")
|
|
|
|
return
|
2019-12-23 01:34:10 +01:00
|
|
|
self.prof["syncKey"] = None
|
2020-02-15 08:48:35 +01:00
|
|
|
self.mw.col.media.force_resync()
|
2021-03-10 09:20:37 +01:00
|
|
|
self._hide_sync_auth_settings()
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
def update_network(self) -> None:
|
2019-12-23 01:34:10 +01:00
|
|
|
self.prof["autoSync"] = self.form.syncOnProgramOpen.isChecked()
|
|
|
|
self.prof["syncMedia"] = self.form.syncMedia.isChecked()
|
2020-07-01 06:01:24 +02:00
|
|
|
self.mw.pm.set_auto_sync_media_minutes(
|
|
|
|
self.form.autoSyncMedia.isChecked() and 15 or 0
|
|
|
|
)
|
2013-05-14 08:23:50 +02:00
|
|
|
if self.form.fullSync.isChecked():
|
2021-06-27 07:12:22 +02:00
|
|
|
self.mw.col.mod_schema(check=False)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
# Global preferences
|
2012-12-21 08:51:59 +01:00
|
|
|
######################################################################
|
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
def setup_global(self) -> None:
|
|
|
|
"Setup options global to all profiles."
|
2022-09-03 04:14:47 +02:00
|
|
|
self.form.reduce_motion.setChecked(self.mw.pm.reduced_motion())
|
2020-12-15 14:09:19 +01:00
|
|
|
self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100))
|
2021-11-24 22:17:41 +01:00
|
|
|
themes = [
|
|
|
|
tr.preferences_theme_label(theme=theme)
|
|
|
|
for theme in (
|
|
|
|
tr.preferences_theme_follow_system(),
|
|
|
|
tr.preferences_theme_light(),
|
|
|
|
tr.preferences_theme_dark(),
|
|
|
|
)
|
|
|
|
]
|
|
|
|
self.form.theme.addItems(themes)
|
|
|
|
self.form.theme.setCurrentIndex(self.mw.pm.theme().value)
|
|
|
|
qconnect(self.form.theme.currentIndexChanged, self.on_theme_changed)
|
2022-07-18 05:31:24 +02:00
|
|
|
self.form.legacy_import_export.setChecked(self.mw.pm.legacy_import_export())
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
self.setup_language()
|
|
|
|
self.setup_video_driver()
|
|
|
|
|
|
|
|
self.setupOptions()
|
|
|
|
|
|
|
|
def update_global(self) -> None:
|
2020-01-23 06:08:10 +01:00
|
|
|
restart_required = False
|
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
self.update_video_driver()
|
|
|
|
|
2019-12-23 01:34:10 +01:00
|
|
|
newScale = self.form.uiScale.value() / 100
|
2019-12-19 00:58:16 +01:00
|
|
|
if newScale != self.mw.pm.uiScale():
|
|
|
|
self.mw.pm.setUiScale(newScale)
|
2020-01-23 06:08:10 +01:00
|
|
|
restart_required = True
|
|
|
|
|
2022-09-03 04:14:47 +02:00
|
|
|
self.mw.pm.set_reduced_motion(self.form.reduce_motion.isChecked())
|
|
|
|
|
2022-07-18 05:31:24 +02:00
|
|
|
self.mw.pm.set_legacy_import_export(self.form.legacy_import_export.isChecked())
|
2022-06-02 08:50:32 +02:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
if restart_required:
|
2021-03-26 04:48:26 +01:00
|
|
|
showInfo(tr.preferences_changes_will_take_effect_when_you())
|
2020-12-21 06:04:22 +01:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
self.updateOptions()
|
|
|
|
|
2021-11-24 22:17:41 +01:00
|
|
|
def on_theme_changed(self, index: int) -> None:
|
|
|
|
self.mw.set_theme(Theme(index))
|
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
# legacy - one of Henrik's add-ons is currently wrapping them
|
|
|
|
|
|
|
|
def setupOptions(self) -> None:
|
|
|
|
pass
|
|
|
|
|
|
|
|
def updateOptions(self) -> None:
|
|
|
|
pass
|
|
|
|
|
|
|
|
# Global: language
|
|
|
|
######################################################################
|
|
|
|
|
|
|
|
def setup_language(self) -> None:
|
|
|
|
f = self.form
|
|
|
|
f.lang.addItems([x[0] for x in anki.lang.langs])
|
|
|
|
f.lang.setCurrentIndex(self.current_lang_index())
|
|
|
|
qconnect(f.lang.currentIndexChanged, self.on_language_index_changed)
|
|
|
|
|
|
|
|
def current_lang_index(self) -> int:
|
|
|
|
codes = [x[1] for x in anki.lang.langs]
|
2021-10-25 06:50:13 +02:00
|
|
|
lang = anki.lang.current_lang
|
2021-03-10 09:20:37 +01:00
|
|
|
if lang in anki.lang.compatMap:
|
|
|
|
lang = anki.lang.compatMap[lang]
|
|
|
|
else:
|
|
|
|
lang = lang.replace("-", "_")
|
|
|
|
try:
|
|
|
|
return codes.index(lang)
|
|
|
|
except:
|
|
|
|
return codes.index("en_US")
|
|
|
|
|
|
|
|
def on_language_index_changed(self, idx: int) -> None:
|
|
|
|
code = anki.lang.langs[idx][1]
|
|
|
|
self.mw.pm.setLang(code)
|
2021-03-26 04:48:26 +01:00
|
|
|
showInfo(tr.preferences_please_restart_anki_to_complete_language(), parent=self)
|
2021-03-10 09:20:37 +01:00
|
|
|
|
|
|
|
# Global: video driver
|
|
|
|
######################################################################
|
|
|
|
|
|
|
|
def setup_video_driver(self) -> None:
|
|
|
|
self.video_drivers = VideoDriver.all_for_platform()
|
|
|
|
names = [
|
2021-03-26 05:21:04 +01:00
|
|
|
tr.preferences_video_driver(driver=video_driver_name_for_platform(d))
|
2021-03-10 09:20:37 +01:00
|
|
|
for d in self.video_drivers
|
2020-12-21 06:04:22 +01:00
|
|
|
]
|
2021-03-10 09:20:37 +01:00
|
|
|
self.form.video_driver.addItems(names)
|
|
|
|
self.form.video_driver.setCurrentIndex(
|
|
|
|
self.video_drivers.index(self.mw.pm.video_driver())
|
|
|
|
)
|
2022-04-06 03:34:57 +02:00
|
|
|
self.form.video_driver.setVisible(qtmajor == 5)
|
2020-02-02 23:32:07 +01:00
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
def update_video_driver(self) -> None:
|
|
|
|
new_driver = self.video_drivers[self.form.video_driver.currentIndex()]
|
|
|
|
if new_driver != self.mw.pm.video_driver():
|
|
|
|
self.mw.pm.set_video_driver(new_driver)
|
2021-03-26 04:48:26 +01:00
|
|
|
showInfo(tr.preferences_changes_will_take_effect_when_you())
|
2021-03-10 09:20:37 +01:00
|
|
|
|
|
|
|
|
|
|
|
def video_driver_name_for_platform(driver: VideoDriver) -> str:
|
|
|
|
if driver == VideoDriver.ANGLE:
|
2021-03-26 04:48:26 +01:00
|
|
|
return tr.preferences_video_driver_angle()
|
2021-03-10 09:20:37 +01:00
|
|
|
elif driver == VideoDriver.Software:
|
2021-11-25 00:06:16 +01:00
|
|
|
if is_mac:
|
2021-03-26 04:48:26 +01:00
|
|
|
return tr.preferences_video_driver_software_mac()
|
2021-03-10 09:20:37 +01:00
|
|
|
else:
|
2021-03-26 04:48:26 +01:00
|
|
|
return tr.preferences_video_driver_software_other()
|
2021-03-10 09:20:37 +01:00
|
|
|
else:
|
2021-11-25 00:06:16 +01:00
|
|
|
if is_mac:
|
2021-03-26 04:48:26 +01:00
|
|
|
return tr.preferences_video_driver_opengl_mac()
|
2021-03-10 09:20:37 +01:00
|
|
|
else:
|
2021-03-26 04:48:26 +01:00
|
|
|
return tr.preferences_video_driver_opengl_other()
|