diff --git a/.github/scripts/contrib.sh b/.github/scripts/contrib.sh
index 055e917ae..0bcf6153e 100755
--- a/.github/scripts/contrib.sh
+++ b/.github/scripts/contrib.sh
@@ -9,7 +9,7 @@ git log --pretty=format:' - %ae' CONTRIBUTORS |sort |uniq |sort -f | sed "s/@/$a
headAuthor=$(git log -1 --pretty=format:'%ae')
authorAt=$(echo "$headAuthor" | sed "s/@/$antispam/")
-if git log --pretty=format:'%ae' CONTRIBUTORS | grep -qi "$headAuthor"; then
+if git log --pretty=format:'%ae' CONTRIBUTORS | grep -i "$headAuthor" > /dev/null; then
echo "Author $authorAt found in CONTRIBUTORS"
else
echo "Author $authorAt NOT found in list"
diff --git a/Makefile b/Makefile
index 2815e9ad7..be2132fea 100644
--- a/Makefile
+++ b/Makefile
@@ -44,7 +44,7 @@ buildhash:
fi
.PHONY: develop
-develop: pyenv buildhash
+develop: pyenv buildhash prepare
set -eo pipefail && \
. "${ACTIVATE_SCRIPT}" && \
for dir in $(DEVEL); do \
@@ -58,6 +58,16 @@ run: develop
echo "Starting Anki..."; \
python qt/runanki $(RUNFLAGS)
+.PHONY: prepare
+prepare: rslib/ftl/repo qt/ftl/repo qt/po/repo
+
+rslib/ftl/repo:
+ $(MAKE) pull-i18n
+qt/ftl/repo:
+ $(MAKE) pull-i18n
+qt/po/repo:
+ $(MAKE) pull-i18n
+
.PHONY: build
build: clean-dist build-rspy build-pylib build-qt add-buildhash
@echo
@@ -123,10 +133,10 @@ add-buildhash:
pull-i18n:
(cd rslib/ftl && scripts/fetch-latest-translations)
(cd qt/ftl && scripts/fetch-latest-translations)
- (cd qt/i18n && ./pull-git)
+ (cd qt/po && scripts/fetch-latest-translations)
.PHONY: push-i18n
push-i18n: pull-i18n
(cd rslib/ftl && scripts/upload-latest-templates)
(cd qt/ftl && scripts/upload-latest-templates)
- (cd qt/i18n && ./sync-po-git)
+ (cd qt/po && scripts/upload-latest-template)
diff --git a/README.contributing b/README.contributing
index 80a2bd88c..b04c808e0 100644
--- a/README.contributing
+++ b/README.contributing
@@ -96,6 +96,16 @@ pylib/tools/genhooks.py and qt/tools/genhooks_gui.py. Adding a new definition
in one of those files and running 'make develop' will update pylib/anki/hooks
.py or qt/aqt/gui_hooks.py.
+Translations
+--------------
+
+The translations into other languages will be fetched on the first build.
+If you'd like to keep them up to date, you need to run 'make pull-i18n'
+periodically.
+
+For information on adding new translatable strings to Anki, please see
+https://ankitects.github.io/translating/#/anki/developers
+
Tests Must Pass
----------------
diff --git a/proto/backend.proto b/proto/backend.proto
index 2fcf913a4..c9435c7d4 100644
--- a/proto/backend.proto
+++ b/proto/backend.proto
@@ -19,10 +19,9 @@ message I18nBackendInit {
string locale_folder_path = 5;
}
-// 1-15 reserved for future use; 2047 for errors
+// 1-15 reserved for future use
message BackendInput {
- reserved 2047;
oneof value {
TemplateRequirementsIn template_requirements = 16;
SchedTimingTodayIn sched_timing_today = 17;
@@ -47,37 +46,43 @@ message BackendInput {
message BackendOutput {
oneof value {
- TemplateRequirementsOut template_requirements = 16;
+ // infallible commands
SchedTimingTodayOut sched_timing_today = 17;
- DeckTreeOut deck_tree = 18;
- FindCardsOut find_cards = 19;
- BrowserRowsOut browser_rows = 20;
- RenderCardOut render_card = 21;
sint32 local_minutes_west = 22;
string strip_av_tags = 23;
ExtractAVTagsOut extract_av_tags = 24;
ExtractLatexOut extract_latex = 25;
- string add_media_file = 26;
- Empty sync_media = 27;
- MediaCheckOut check_media = 28;
- Empty trash_media_files = 29;
string translate_string = 30;
string format_time_span = 31;
string studied_today = 32;
string congrats_learn_msg = 33;
+ // fallible commands
+ TemplateRequirementsOut template_requirements = 16;
+ DeckTreeOut deck_tree = 18;
+ FindCardsOut find_cards = 19;
+ BrowserRowsOut browser_rows = 20;
+ RenderCardOut render_card = 21;
+ string add_media_file = 26;
+ Empty sync_media = 27;
+ MediaCheckOut check_media = 28;
+ Empty trash_media_files = 29;
+
BackendError error = 2047;
}
}
message BackendError {
+ // localized error description suitable for displaying to the user
+ string localized = 1;
+ // error specifics
oneof value {
- StringError invalid_input = 1;
- TemplateParseError template_parse = 2;
- StringError io_error = 3;
- StringError db_error = 4;
- NetworkError network_error = 5;
- SyncError sync_error = 6;
+ Empty invalid_input = 2;
+ Empty template_parse = 3;
+ Empty io_error = 4;
+ Empty db_error = 5;
+ NetworkError network_error = 6;
+ SyncError sync_error = 7;
// user interrupted operation
Empty interrupted = 8;
}
@@ -90,28 +95,17 @@ message Progress {
}
}
-message StringError {
- string info = 1;
-}
-
-message TemplateParseError {
- string info = 1;
-}
-
message NetworkError {
- string info = 1;
enum NetworkErrorKind {
OTHER = 0;
OFFLINE = 1;
TIMEOUT = 2;
PROXY_AUTH = 3;
}
- NetworkErrorKind kind = 2;
- string localized = 3;
+ NetworkErrorKind kind = 1;
}
message SyncError {
- string info = 1;
enum SyncErrorKind {
OTHER = 0;
CONFLICT = 1;
@@ -122,8 +116,7 @@ message SyncError {
MEDIA_CHECK_REQUIRED = 6;
RESYNC_REQUIRED = 7;
}
- SyncErrorKind kind = 2;
- string localized = 3;
+ SyncErrorKind kind = 1;
}
message MediaSyncProgress {
@@ -307,8 +300,9 @@ message TranslateArgValue {
message FormatTimeSpanIn {
enum Context {
- NORMAL = 0;
+ PRECISE = 0;
ANSWER_BUTTONS = 1;
+ INTERVALS = 2;
}
float seconds = 1;
diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index 2a865f93e..cfa2c3cdb 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -73,7 +73,6 @@ class _Collection:
ls: int
conf: Dict[str, Any]
_undo: List[Any]
- backend: RustBackend
def __init__(
self,
@@ -83,6 +82,7 @@ class _Collection:
log: bool = False,
) -> None:
self.backend = backend
+ self.tr = backend.translate
self._debugLog = log
self.db = db
self.path = db._path
diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py
index 62ee71bff..def276f30 100644
--- a/pylib/anki/rsbackend.py
+++ b/pylib/anki/rsbackend.py
@@ -12,7 +12,7 @@ import ankirspy # pytype: disable=import-error
import anki.backend_pb2 as pb
import anki.buildinfo
from anki import hooks
-from anki.fluent_pb2 import FluentString as FString
+from anki.fluent_pb2 import FluentString as TR
from anki.models import AllTemplateReqs
from anki.sound import AVTag, SoundOrVideoTag, TTSTag
from anki.types import assert_impossible_literal
@@ -32,14 +32,17 @@ class StringError(Exception):
NetworkErrorKind = pb.NetworkError.NetworkErrorKind
+SyncErrorKind = pb.SyncError.SyncErrorKind
class NetworkError(StringError):
def kind(self) -> NetworkErrorKind:
return self.args[1]
- def localized(self) -> str:
- return self.args[2]
+
+class SyncError(StringError):
+ def kind(self) -> SyncErrorKind:
+ return self.args[1]
class IOError(StringError):
@@ -54,35 +57,22 @@ class TemplateError(StringError):
pass
-SyncErrorKind = pb.SyncError.SyncErrorKind
-
-
-class SyncError(StringError):
- def kind(self) -> SyncErrorKind:
- return self.args[1]
-
- def localized(self) -> str:
- return self.args[2]
-
-
def proto_exception_to_native(err: pb.BackendError) -> Exception:
val = err.WhichOneof("value")
if val == "interrupted":
return Interrupted()
elif val == "network_error":
- e = err.network_error
- return NetworkError(e.info, e.kind, e.localized)
- elif val == "io_error":
- return IOError(err.io_error.info)
- elif val == "db_error":
- return DBError(err.db_error.info)
- elif val == "template_parse":
- return TemplateError(err.template_parse.info)
- elif val == "invalid_input":
- return StringError(err.invalid_input.info)
+ return NetworkError(err.localized, err.network_error.kind)
elif val == "sync_error":
- e2 = err.sync_error
- return SyncError(e2.info, e2.kind, e2.localized)
+ return SyncError(err.localized, err.sync_error.kind)
+ elif val == "io_error":
+ return IOError(err.localized)
+ elif val == "db_error":
+ return DBError(err.localized)
+ elif val == "template_parse":
+ return TemplateError(err.localized)
+ elif val == "invalid_input":
+ return StringError(err.localized)
else:
assert_impossible_literal(val)
@@ -334,7 +324,7 @@ class RustBackend:
pb.BackendInput(trash_media_files=pb.TrashMediaFilesIn(fnames=fnames))
)
- def translate(self, key: FString, **kwargs: Union[str, int, float]):
+ def translate(self, key: TR, **kwargs: Union[str, int, float]) -> str:
return self._run_command(
pb.BackendInput(translate_string=translate_string_in(key, **kwargs))
).translate_string
@@ -342,7 +332,7 @@ class RustBackend:
def format_time_span(
self,
seconds: float,
- context: FormatTimeSpanContext = FormatTimeSpanContext.NORMAL,
+ context: FormatTimeSpanContext = FormatTimeSpanContext.INTERVALS,
) -> str:
return self._run_command(
pb.BackendInput(
@@ -368,7 +358,7 @@ class RustBackend:
def translate_string_in(
- key: FString, **kwargs: Union[str, int, float]
+ key: TR, **kwargs: Union[str, int, float]
) -> pb.TranslateStringIn:
args = {}
for (k, v) in kwargs.items():
@@ -386,7 +376,7 @@ class I18nBackend:
)
self._backend = ankirspy.open_i18n(init_msg.SerializeToString())
- def translate(self, key: FString, **kwargs: Union[str, int, float]):
+ def translate(self, key: TR, **kwargs: Union[str, int, float]):
return self._backend.translate(
translate_string_in(key, **kwargs).SerializeToString()
)
diff --git a/pylib/anki/stats.py b/pylib/anki/stats.py
index 133b7cf72..2f24babe6 100644
--- a/pylib/anki/stats.py
+++ b/pylib/anki/stats.py
@@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple
import anki
from anki.consts import *
from anki.lang import _, ngettext
-from anki.rsbackend import FString
+from anki.rsbackend import TR, FormatTimeSpanContext
from anki.utils import ids2str
# Card stats
@@ -47,9 +47,7 @@ class CardStats:
next = c.due
next = self.date(next)
if next:
- self.addLine(
- self.col.backend.translate(FString.STATISTICS_DUE_DATE), next,
- )
+ self.addLine(self.col.tr(TR.STATISTICS_DUE_DATE), next)
if c.queue == QUEUE_TYPE_REV:
self.addLine(
_("Interval"), self.col.backend.format_time_span(c.ivl * 86400)
@@ -85,7 +83,9 @@ class CardStats:
return time.strftime("%Y-%m-%d", time.localtime(tm))
def time(self, tm: float) -> str:
- return self.col.backend.format_time_span(tm)
+ return self.col.backend.format_time_span(
+ tm, context=FormatTimeSpanContext.PRECISE
+ )
# Collection stats
@@ -276,9 +276,7 @@ from revlog where id > ? """
def _dueInfo(self, tot, num) -> str:
i: List[str] = []
self._line(
- i,
- _("Total"),
- self.col.backend.translate(FString.STATISTICS_REVIEWS, reviews=tot),
+ i, _("Total"), self.col.tr(TR.STATISTICS_REVIEWS, reviews=tot),
)
self._line(i, _("Average"), self._avgDay(tot, num, _("reviews")))
tomorrow = self.col.db.scalar(
@@ -455,8 +453,8 @@ group by day order by day"""
self._line(
i,
_("Average answer time"),
- self.col.backend.translate(
- FString.STATISTICS_AVERAGE_ANSWER_TIME,
+ self.col.tr(
+ TR.STATISTICS_AVERAGE_ANSWER_TIME,
**{"cards-per-minute": perMin, "average-seconds": average_secs},
),
)
diff --git a/pylib/tests/test_collection.py b/pylib/tests/test_collection.py
index acba9f139..8c0985426 100644
--- a/pylib/tests/test_collection.py
+++ b/pylib/tests/test_collection.py
@@ -5,7 +5,7 @@ import tempfile
from anki import Collection as aopen
from anki.lang import without_unicode_isolation
-from anki.rsbackend import FString
+from anki.rsbackend import TR
from anki.stdmodels import addBasicModel, models
from anki.utils import isWin
from tests.shared import assertException, getEmptyCol
@@ -153,12 +153,11 @@ def test_furigana():
def test_translate():
d = getEmptyCol()
- tr = d.backend.translate
no_uni = without_unicode_isolation
assert (
- tr(FString.CARD_TEMPLATE_RENDERING_FRONT_SIDE_PROBLEM)
+ d.tr(TR.CARD_TEMPLATE_RENDERING_FRONT_SIDE_PROBLEM)
== "Front template has a problem:"
)
- assert no_uni(tr(FString.STATISTICS_REVIEWS, reviews=1)) == "1 review"
- assert no_uni(tr(FString.STATISTICS_REVIEWS, reviews=2)) == "2 reviews"
+ assert no_uni(d.tr(TR.STATISTICS_REVIEWS, reviews=1)) == "1 review"
+ assert no_uni(d.tr(TR.STATISTICS_REVIEWS, reviews=2)) == "2 reviews"
diff --git a/qt/Makefile b/qt/Makefile
index 4fb91701d..0f95ceed8 100644
--- a/qt/Makefile
+++ b/qt/Makefile
@@ -27,8 +27,10 @@ all: check
./tools/build_ui.sh
@touch $@
-.build/i18n: $(wildcard i18n/po/desktop/*/anki.po)
- (cd i18n && ./pull-git && ./build-mo-files && ./copy-qt-files)
+.build/i18n: $(wildcard po/repo/desktop/*/anki.po)
+ (cd po && ./scripts/fetch-latest-translations && \
+ ./scripts/build-mo-files && \
+ ./scripts/copy-qt-files)
@touch $@
TSDEPS := $(wildcard ts/src/*.ts) $(wildcard ts/scss/*.scss)
diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py
index a46388eed..4a1f7b317 100644
--- a/qt/aqt/browser.py
+++ b/qt/aqt/browser.py
@@ -24,7 +24,7 @@ from anki.consts import *
from anki.lang import _, ngettext
from anki.models import NoteType
from anki.notes import Note
-from anki.rsbackend import FString
+from anki.rsbackend import TR
from anki.utils import htmlToTextLine, ids2str, intTime, isMac, isWin
from aqt import AnkiQt, gui_hooks
from aqt.editor import Editor
@@ -356,7 +356,7 @@ class DataModel(QAbstractTableModel):
elif c.queue == QUEUE_TYPE_LRN:
date = c.due
elif c.queue == QUEUE_TYPE_NEW or c.type == CARD_TYPE_NEW:
- return tr(FString.STATISTICS_DUE_FOR_NEW_CARD, number=c.due)
+ return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=c.due)
elif c.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or (
c.type == CARD_TYPE_REV and c.queue < 0
):
@@ -730,7 +730,7 @@ class Browser(QMainWindow):
("noteCrt", _("Created")),
("noteMod", _("Edited")),
("cardMod", _("Changed")),
- ("cardDue", tr(FString.STATISTICS_DUE_DATE)),
+ ("cardDue", tr(TR.STATISTICS_DUE_DATE)),
("cardIvl", _("Interval")),
("cardEase", _("Ease")),
("cardReps", _("Reviews")),
@@ -1281,7 +1281,7 @@ by clicking on one on the left."""
(_("New"), "is:new"),
(_("Learning"), "is:learn"),
(_("Review"), "is:review"),
- (tr(FString.FILTERING_IS_DUE), "is:due"),
+ (tr(TR.FILTERING_IS_DUE), "is:due"),
None,
(_("Suspended"), "is:suspended"),
(_("Buried"), "is:buried"),
@@ -1494,6 +1494,8 @@ border: 1px solid #000; padding: 3px; '>%s"""
if ivl == 0:
ivl = ""
else:
+ if ivl > 0:
+ ivl *= 86_400
ivl = cs.time(abs(ivl))
s += "
%s | " % tstr
s += "%s | " % ease
@@ -1501,7 +1503,7 @@ border: 1px solid #000; padding: 3px; '>%s"""
s += ("%s | " * 2) % (
"%d%%" % (factor / 10) if factor else "",
- cs.time(taken),
+ self.col.backend.format_time_span(taken),
) + ""
s += ""
if cnt < self.card.reps:
@@ -1770,8 +1772,10 @@ where id in %s"""
else:
audio = c.answer_av_tags()
av_player.play_tags(audio)
+ else:
+ av_player.maybe_interrupt()
- txt = self.mw.prepare_card_text_for_display(txt)
+ txt = self.mw.prepare_card_text_for_display(txt)
txt = gui_hooks.card_will_show(
txt, c, "preview" + self._previewState.capitalize()
)
diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py
index 184ccd59e..e1b3cd748 100644
--- a/qt/aqt/deckbrowser.py
+++ b/qt/aqt/deckbrowser.py
@@ -11,7 +11,7 @@ from typing import Any
import aqt
from anki.errors import DeckRenameError
from anki.lang import _, ngettext
-from anki.rsbackend import FString
+from anki.rsbackend import TR
from anki.utils import ids2str
from aqt import AnkiQt, gui_hooks
from aqt.qt import *
@@ -185,7 +185,7 @@ where id > ?""",
%s | %s |
%s | |
""" % (
_("Deck"),
- tr(FString.STATISTICS_DUE_COUNT),
+ tr(TR.STATISTICS_DUE_COUNT),
_("New"),
)
buf += self._topLevelDragRow()
diff --git a/qt/aqt/errors.py b/qt/aqt/errors.py
index b40dd18ff..1bbc3b1da 100644
--- a/qt/aqt/errors.py
+++ b/qt/aqt/errors.py
@@ -11,7 +11,7 @@ from markdown import markdown
from anki.lang import _
from aqt import mw
from aqt.qt import *
-from aqt.utils import FString, showText, showWarning, supportText, tr
+from aqt.utils import TR, showText, showWarning, supportText, tr
if not os.environ.get("DEBUG"):
@@ -106,14 +106,14 @@ your system's temporary folder may be incorrect."""
)
)
if "disk I/O error" in error:
- showWarning(markdown(tr(FString.ERRORS_ACCESSING_DB)))
+ showWarning(markdown(tr(TR.ERRORS_ACCESSING_DB)))
return
if self.mw.addonManager.dirty:
- txt = markdown(tr(FString.ERRORS_ADDONS_ACTIVE_POPUP))
+ txt = markdown(tr(TR.ERRORS_ADDONS_ACTIVE_POPUP))
error = supportText() + self._addonText(error) + "\n" + error
else:
- txt = markdown(tr(FString.ERRORS_STANDARD_POPUP))
+ txt = markdown(tr(TR.ERRORS_STANDARD_POPUP))
error = supportText() + "\n" + error
# show dialog
diff --git a/qt/aqt/exporting.py b/qt/aqt/exporting.py
index 11f53808b..8b6657f8d 100644
--- a/qt/aqt/exporting.py
+++ b/qt/aqt/exporting.py
@@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+from __future__ import annotations
+
import os
import re
import time
@@ -15,7 +17,12 @@ from aqt.utils import checkInvalidFilename, getSaveFile, showWarning, tooltip
class ExportDialog(QDialog):
- def __init__(self, mw, did: Optional[int] = None, cids: Optional[List[int]] = None):
+ def __init__(
+ self,
+ mw: aqt.main.AnkiQt,
+ did: Optional[int] = None,
+ cids: Optional[List[int]] = None,
+ ):
QDialog.__init__(self, mw, Qt.Window)
self.mw = mw
self.col = mw.col
@@ -118,6 +125,9 @@ class ExportDialog(QDialog):
return
if checkInvalidFilename(os.path.basename(file), dirsep=False):
continue
+ if os.path.commonprefix([self.mw.pm.base, file]) == self.mw.pm.base:
+ showWarning("Please choose a different export location.")
+ continue
break
self.hide()
if file:
diff --git a/qt/aqt/gui_hooks.py b/qt/aqt/gui_hooks.py
index de0ec671d..5a001251e 100644
--- a/qt/aqt/gui_hooks.py
+++ b/qt/aqt/gui_hooks.py
@@ -862,6 +862,51 @@ class _EditorWillUseFontForFieldFilter:
editor_will_use_font_for_field = _EditorWillUseFontForFieldFilter()
+class _EmptyCardsWillBeDeletedFilter:
+ """Allow to change the list of cards to delete.
+
+ For example, an add-on creating a method to delete only empty
+ new cards would be done as follow:
+```
+from anki.consts import CARD_TYPE_NEW
+from anki.utils import ids2str
+from aqt import mw
+from aqt import gui_hooks
+
+def filter(cids, col):
+ return col.db.list(
+ f"select id from cards where (type={CARD_TYPE_NEW} and (id in {ids2str(cids)))")
+
+def emptyNewCard():
+ gui_hooks.append(filter)
+ mw.onEmptyCards()
+ gui_hooks.remove(filter)
+```"""
+
+ _hooks: List[Callable[[List[int]], List[int]]] = []
+
+ def append(self, cb: Callable[[List[int]], List[int]]) -> None:
+ """(cids: List[int])"""
+ self._hooks.append(cb)
+
+ def remove(self, cb: Callable[[List[int]], List[int]]) -> None:
+ if cb in self._hooks:
+ self._hooks.remove(cb)
+
+ def __call__(self, cids: List[int]) -> List[int]:
+ for filter in self._hooks:
+ try:
+ cids = filter(cids)
+ except:
+ # if the hook fails, remove it
+ self._hooks.remove(filter)
+ raise
+ return cids
+
+
+empty_cards_will_be_deleted = _EmptyCardsWillBeDeletedFilter()
+
+
class _MediaSyncDidProgressHook:
_hooks: List[Callable[["aqt.mediasync.LogEntryWithTime"], None]] = []
diff --git a/qt/aqt/legacy.py b/qt/aqt/legacy.py
index 0318a2e44..79fd1f942 100644
--- a/qt/aqt/legacy.py
+++ b/qt/aqt/legacy.py
@@ -33,7 +33,7 @@ def stripSounds(text) -> str:
return aqt.mw.col.backend.strip_av_tags(text)
-def fmtTimeSpan(time, pad=0, point=0, inTime=False, unit=99):
+def fmtTimeSpan(time, pad=0, point=0, short=False, inTime=False, unit=99):
print("fmtTimeSpan() has become col.backend.format_time_span()")
return aqt.mw.col.backend.format_time_span(time)
diff --git a/qt/aqt/main.py b/qt/aqt/main.py
index 9ca7e2acb..182c5de8a 100644
--- a/qt/aqt/main.py
+++ b/qt/aqt/main.py
@@ -1303,6 +1303,7 @@ will be lost. Continue?"""
def onEmptyCards(self):
self.progress.start(immediate=True)
cids = self.col.emptyCids()
+ cids = gui_hooks.empty_cards_will_be_deleted(cids)
if not cids:
self.progress.finish()
tooltip(_("No empty cards."))
diff --git a/qt/aqt/mediacheck.py b/qt/aqt/mediacheck.py
index 20e36a178..7500ceafd 100644
--- a/qt/aqt/mediacheck.py
+++ b/qt/aqt/mediacheck.py
@@ -10,13 +10,7 @@ from typing import Iterable, List, Optional, TypeVar
import aqt
from anki import hooks
-from anki.rsbackend import (
- FString,
- Interrupted,
- MediaCheckOutput,
- Progress,
- ProgressKind,
-)
+from anki.rsbackend import TR, Interrupted, MediaCheckOutput, Progress, ProgressKind
from aqt.qt import *
from aqt.utils import askUser, restoreGeom, saveGeom, showText, tooltip, tr
@@ -89,14 +83,14 @@ class MediaChecker:
layout.addWidget(box)
if output.unused:
- b = QPushButton(tr(FString.MEDIA_CHECK_DELETE_UNUSED))
+ b = QPushButton(tr(TR.MEDIA_CHECK_DELETE_UNUSED))
b.setAutoDefault(False)
box.addButton(b, QDialogButtonBox.RejectRole)
b.clicked.connect(lambda c: self._on_trash_files(output.unused)) # type: ignore
if output.missing:
if any(map(lambda x: x.startswith("latex-"), output.missing)):
- b = QPushButton(tr(FString.MEDIA_CHECK_RENDER_LATEX))
+ b = QPushButton(tr(TR.MEDIA_CHECK_RENDER_LATEX))
b.setAutoDefault(False)
box.addButton(b, QDialogButtonBox.RejectRole)
b.clicked.connect(self._on_render_latex) # type: ignore
@@ -125,17 +119,17 @@ class MediaChecker:
browser.onSearchActivated()
showText(err, type="html")
else:
- tooltip(tr(FString.MEDIA_CHECK_ALL_LATEX_RENDERED))
+ tooltip(tr(TR.MEDIA_CHECK_ALL_LATEX_RENDERED))
def _on_render_latex_progress(self, count: int) -> bool:
if self.progress_dialog.wantCancel:
return False
- self.mw.progress.update(tr(FString.MEDIA_CHECK_CHECKED, count=count))
+ self.mw.progress.update(tr(TR.MEDIA_CHECK_CHECKED, count=count))
return True
def _on_trash_files(self, fnames: List[str]):
- if not askUser(tr(FString.MEDIA_CHECK_DELETE_UNUSED_CONFIRM)):
+ if not askUser(tr(TR.MEDIA_CHECK_DELETE_UNUSED_CONFIRM)):
return
self.progress_dialog = self.mw.progress.start()
@@ -149,10 +143,10 @@ class MediaChecker:
remaining -= len(chunk)
if time.time() - last_progress >= 0.3:
self.mw.progress.update(
- tr(FString.MEDIA_CHECK_FILES_REMAINING, count=remaining)
+ tr(TR.MEDIA_CHECK_FILES_REMAINING, count=remaining)
)
finally:
self.mw.progress.finish()
self.progress_dialog = None
- tooltip(tr(FString.MEDIA_CHECK_DELETE_UNUSED_COMPLETE, count=total))
+ tooltip(tr(TR.MEDIA_CHECK_DELETE_UNUSED_COMPLETE, count=total))
diff --git a/qt/aqt/mediasync.py b/qt/aqt/mediasync.py
index f0ec19bdd..f0b46647a 100644
--- a/qt/aqt/mediasync.py
+++ b/qt/aqt/mediasync.py
@@ -10,15 +10,7 @@ from typing import List, Union
import aqt
from anki import hooks
-from anki.rsbackend import (
- FString,
- Interrupted,
- MediaSyncProgress,
- NetworkError,
- Progress,
- ProgressKind,
- SyncError,
-)
+from anki.rsbackend import TR, Interrupted, MediaSyncProgress, Progress, ProgressKind
from anki.types import assert_impossible
from anki.utils import intTime
from aqt import gui_hooks
@@ -65,10 +57,10 @@ class MediaSyncer:
return
if not self.mw.pm.media_syncing_enabled():
- self._log_and_notify(tr(FString.SYNC_MEDIA_DISABLED))
+ self._log_and_notify(tr(TR.SYNC_MEDIA_DISABLED))
return
- self._log_and_notify(tr(FString.SYNC_MEDIA_STARTING))
+ self._log_and_notify(tr(TR.SYNC_MEDIA_STARTING))
self._syncing = True
self._want_stop = False
gui_hooks.media_sync_did_start_or_stop(True)
@@ -101,21 +93,15 @@ class MediaSyncer:
if exc is not None:
self._handle_sync_error(exc)
else:
- self._log_and_notify(tr(FString.SYNC_MEDIA_COMPLETE))
+ self._log_and_notify(tr(TR.SYNC_MEDIA_COMPLETE))
def _handle_sync_error(self, exc: BaseException):
if isinstance(exc, Interrupted):
- self._log_and_notify(tr(FString.SYNC_MEDIA_ABORTED))
+ self._log_and_notify(tr(TR.SYNC_MEDIA_ABORTED))
return
- self._log_and_notify(tr(FString.SYNC_MEDIA_FAILED))
- if isinstance(exc, SyncError):
- showWarning(exc.localized())
- elif isinstance(exc, NetworkError):
- msg = exc.localized()
- msg += "\n\n" + tr(FString.NETWORK_DETAILS, details=str(exc))
- else:
- raise exc
+ self._log_and_notify(tr(TR.SYNC_MEDIA_FAILED))
+ showWarning(str(exc))
def entries(self) -> List[LogEntryWithTime]:
return self._log
@@ -123,7 +109,7 @@ class MediaSyncer:
def abort(self) -> None:
if not self.is_syncing():
return
- self._log_and_notify(tr(FString.SYNC_MEDIA_ABORTING))
+ self._log_and_notify(tr(TR.SYNC_MEDIA_ABORTING))
self._want_stop = True
def is_syncing(self) -> bool:
@@ -166,10 +152,12 @@ class MediaSyncDialog(QDialog):
self._close_when_done = close_when_done
self.form = aqt.forms.synclog.Ui_Dialog()
self.form.setupUi(self)
- self.abort_button = QPushButton(tr(FString.SYNC_ABORT_BUTTON))
+ self.setWindowTitle(tr(TR.SYNC_MEDIA_LOG_TITLE))
+ self.abort_button = QPushButton(tr(TR.SYNC_ABORT_BUTTON))
self.abort_button.clicked.connect(self._on_abort) # type: ignore
self.abort_button.setAutoDefault(False)
self.form.buttonBox.addButton(self.abort_button, QDialogButtonBox.ActionRole)
+ self.abort_button.setHidden(not self._syncer.is_syncing())
gui_hooks.media_sync_did_progress.append(self._on_log_entry)
gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop)
diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py
index 7c5b924e6..5b997724f 100644
--- a/qt/aqt/preferences.py
+++ b/qt/aqt/preferences.py
@@ -10,7 +10,7 @@ import aqt
from anki.lang import _
from aqt import AnkiQt
from aqt.qt import *
-from aqt.utils import askUser, openHelp, showInfo, showWarning
+from aqt.utils import TR, askUser, openHelp, showInfo, showWarning, tr
class Preferences(QDialog):
@@ -177,6 +177,8 @@ class Preferences(QDialog):
######################################################################
def setupNetwork(self):
+ self.form.media_log.setText(tr(TR.SYNC_MEDIA_LOG_BUTTON))
+ self.form.media_log.clicked.connect(self.on_media_log)
self.form.syncOnProgramOpen.setChecked(self.prof["autoSync"])
self.form.syncMedia.setChecked(self.prof["syncMedia"])
if not self.prof["syncKey"]:
@@ -185,6 +187,9 @@ class Preferences(QDialog):
self.form.syncUser.setText(self.prof.get("syncUser", ""))
self.form.syncDeauth.clicked.connect(self.onSyncDeauth)
+ def on_media_log(self):
+ self.mw.media_syncer.show_sync_log()
+
def _hideAuth(self):
self.form.syncDeauth.setVisible(False)
self.form.syncUser.setText("")
diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py
index 03f4280cb..ba605f401 100644
--- a/qt/aqt/profiles.py
+++ b/qt/aqt/profiles.py
@@ -485,7 +485,8 @@ please see:
######################################################################
def uiScale(self) -> float:
- return self.meta.get("uiScale", 1.0)
+ scale = self.meta.get("uiScale", 1.0)
+ return max(scale, 1)
def setUiScale(self, scale: float) -> None:
self.meta["uiScale"] = scale
diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py
index 9200b15f6..316c33803 100644
--- a/qt/aqt/reviewer.py
+++ b/qt/aqt/reviewer.py
@@ -187,6 +187,8 @@ The front of this card is empty. Please run Tools>Empty Cards."""
# play audio?
if self.autoplay(c):
av_player.play_tags(c.question_av_tags())
+ else:
+ av_player.maybe_interrupt()
# render & update bottom
q = self._mungeQA(q)
@@ -230,6 +232,8 @@ The front of this card is empty. Please run Tools>Empty Cards."""
# play audio?
if self.autoplay(c):
av_player.play_tags(c.answer_av_tags())
+ else:
+ av_player.maybe_interrupt()
a = self._mungeQA(a)
a = gui_hooks.card_will_show(a, c, "reviewAnswer")
diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py
index 0b0c5be8c..508a9611c 100644
--- a/qt/aqt/sound.py
+++ b/qt/aqt/sound.py
@@ -95,14 +95,17 @@ class AVPlayer:
def play_tags(self, tags: List[AVTag]) -> None:
"""Clear the existing queue, then start playing provided tags."""
self._enqueued = tags[:]
- if self.interrupt_current_audio:
- self._stop_if_playing()
+ self.maybe_interrupt()
self._play_next_if_idle()
def stop_and_clear_queue(self) -> None:
self._enqueued = []
self._stop_if_playing()
+ def maybe_interrupt(self) -> None:
+ if self.interrupt_current_audio:
+ self._stop_if_playing()
+
def play_file(self, filename: str) -> None:
self.play_tags([SoundOrVideoTag(filename=filename)])
@@ -639,10 +642,6 @@ def setup_audio(taskman: TaskManager, base_folder: str) -> None:
mplayer = SimpleMplayerSlaveModePlayer(taskman)
av_player.players.append(mplayer)
- # currently unused
- # mpv = SimpleMpvPlayer(base_folder)
- # av_player.players.append(mpv)
-
# tts support
if isMac:
from aqt.tts import MacTTSPlayer
diff --git a/qt/aqt/theme.py b/qt/aqt/theme.py
index 91c9e782a..6a25f9d6d 100644
--- a/qt/aqt/theme.py
+++ b/qt/aqt/theme.py
@@ -9,7 +9,7 @@ from typing import Dict
from anki.utils import isMac
from aqt import QApplication, gui_hooks, isWin
from aqt.colors import colors
-from aqt.qt import QColor, QIcon, QPalette, QPixmap, QStyleFactory, Qt
+from aqt.qt import QColor, QIcon, QPalette, QPixmap, QStyleFactory, Qt, qtminor
class ThemeManager:
@@ -23,6 +23,8 @@ class ThemeManager:
return False
if not isMac:
return False
+ if qtminor < 13:
+ return False
import darkdetect # pylint: disable=import-error
return darkdetect.isDark() is True
diff --git a/qt/aqt/tts.py b/qt/aqt/tts.py
index a78300e31..66dee8cbc 100644
--- a/qt/aqt/tts.py
+++ b/qt/aqt/tts.py
@@ -472,9 +472,14 @@ if isWin:
return LCIDS.get(dec_str, "unknown")
class WindowsTTSPlayer(TTSProcessPlayer):
- speaker = win32com.client.Dispatch("SAPI.SpVoice")
+ try:
+ speaker = win32com.client.Dispatch("SAPI.SpVoice")
+ except:
+ speaker = None
def get_available_voices(self) -> List[TTSVoice]:
+ if self.speaker is None:
+ return []
return list(map(self._voice_to_object, self.speaker.GetVoices()))
def _voice_to_object(self, voice: Any):
diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py
index 26cc4fa5e..a0e123622 100644
--- a/qt/aqt/utils.py
+++ b/qt/aqt/utils.py
@@ -13,7 +13,7 @@ from typing import Any, Optional, Union
import anki
import aqt
from anki.lang import _
-from anki.rsbackend import FString
+from anki.rsbackend import TR
from anki.utils import invalidFilename, isMac, isWin, noBundledLibs, versionWithBuild
from aqt.qt import *
from aqt.theme import theme_manager
@@ -32,7 +32,7 @@ def locale_dir() -> str:
return os.path.join(aqt_data_folder(), "locale")
-def tr(key: FString, **kwargs: Union[str, int, float]) -> str:
+def tr(key: TR, **kwargs: Union[str, int, float]) -> str:
"Shortcut to access Fluent translations."
return anki.lang.current_i18n.translate(key, **kwargs)
diff --git a/qt/designer/preferences.ui b/qt/designer/preferences.ui
index 3e7c9fca4..03ec7d8eb 100644
--- a/qt/designer/preferences.ui
+++ b/qt/designer/preferences.ui
@@ -246,7 +246,7 @@
%
- 50
+ 100
200
@@ -371,6 +371,30 @@
+ -
+
+
-
+
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
@@ -542,10 +566,10 @@
lrnCutoff
timeLimit
numBackups
- syncOnProgramOpen
- tabWidget
- fullSync
syncMedia
+ tabWidget
+ syncOnProgramOpen
+ fullSync
syncDeauth
diff --git a/qt/designer/synclog.ui b/qt/designer/synclog.ui
index adb8120a9..e0d93b35a 100644
--- a/qt/designer/synclog.ui
+++ b/qt/designer/synclog.ui
@@ -11,7 +11,7 @@
- Sync
+
-
diff --git a/qt/i18n/build-mo-files b/qt/i18n/build-mo-files
deleted file mode 100755
index 8feaf7cfe..000000000
--- a/qt/i18n/build-mo-files
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/bin/bash
-#
-# build mo files
-#
-set -eo pipefail
-
-targetDir="../aqt_data/locale/gettext"
-mkdir -p $targetDir
-
-echo "Compiling *.po..."
-for file in po/desktop/*/anki.po
-do
- outdir=$(echo "$file" | \
- perl -pe "s%po/desktop/(.*)/anki.po%$targetDir/\1/LC_MESSAGES%")
- outfile="$outdir/anki.mo"
- mkdir -p $outdir
- msgmerge -q "$file" po/desktop/anki.pot | msgfmt - --output-file="$outfile"
-done
diff --git a/qt/i18n/pull-git b/qt/i18n/pull-git
deleted file mode 100755
index aea1c5c43..000000000
--- a/qt/i18n/pull-git
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/bin/bash
-
-if [ ! -d po ]; then
- git clone https://github.com/ankitects/anki-desktop-i18n po
-fi
-
-echo "Updating translations from git..."
-(cd po && git pull)
-
-# make sure gettext translations haven't broken something
-(cd po && python check-po-files.py)
diff --git a/qt/i18n/sync-po-git b/qt/i18n/sync-po-git
deleted file mode 100755
index 5d5910185..000000000
--- a/qt/i18n/sync-po-git
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/bin/bash
-set -eo pipefail
-
-# pull any pending changes from git repos
-./pull-git
-
-# upload changes to .pot
-./update-po-template
-(cd po && git add desktop; git commit -m update; git push)
diff --git a/qt/i18n/.gitignore b/qt/po/.gitignore
similarity index 68%
rename from qt/i18n/.gitignore
rename to qt/po/.gitignore
index ce405b957..cf4a945f6 100644
--- a/qt/i18n/.gitignore
+++ b/qt/po/.gitignore
@@ -1,3 +1,3 @@
.build
-po
+repo
ftl
diff --git a/qt/i18n/plurals.json b/qt/po/plurals.json
similarity index 100%
rename from qt/i18n/plurals.json
rename to qt/po/plurals.json
diff --git a/qt/i18n/requirements.txt b/qt/po/requirements.txt
similarity index 100%
rename from qt/i18n/requirements.txt
rename to qt/po/requirements.txt
diff --git a/qt/po/scripts/build-mo-files b/qt/po/scripts/build-mo-files
new file mode 100755
index 000000000..d0653351e
--- /dev/null
+++ b/qt/po/scripts/build-mo-files
@@ -0,0 +1,20 @@
+#!/bin/bash
+#
+# build mo files
+#
+set -eo pipefail
+
+targetDir="../aqt_data/locale/gettext"
+mkdir -p $targetDir
+
+echo "Compiling *.repo..."
+for file in repo/desktop/*/anki.po
+do
+ outdir=$(echo "$file" | \
+ perl -pe "s%repo/desktop/(.*)/anki.po%$targetDir/\1/LC_MESSAGES%")
+ outfile="$outdir/anki.mo"
+ mkdir -p $outdir
+ (msgmerge -q "$file" repo/desktop/anki.pot | msgfmt - --output-file="$outfile") || (
+ echo "error building $file"
+ )
+done
diff --git a/qt/i18n/copy-qt-files b/qt/po/scripts/copy-qt-files
similarity index 100%
rename from qt/i18n/copy-qt-files
rename to qt/po/scripts/copy-qt-files
diff --git a/qt/tools/extract-po-string.py b/qt/po/scripts/extract-po-string.py
similarity index 100%
rename from qt/tools/extract-po-string.py
rename to qt/po/scripts/extract-po-string.py
diff --git a/qt/po/scripts/fetch-latest-translations b/qt/po/scripts/fetch-latest-translations
new file mode 100755
index 000000000..286745fe4
--- /dev/null
+++ b/qt/po/scripts/fetch-latest-translations
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+echo "Downloading latest translations..."
+
+if [ ! -d repo ]; then
+ git clone https://github.com/ankitects/anki-desktop-i18n repo
+fi
+
+(cd repo && git pull)
diff --git a/qt/i18n/update-po-template b/qt/po/scripts/update-po-template
similarity index 76%
rename from qt/i18n/update-po-template
rename to qt/po/scripts/update-po-template
index a67197e01..223cada7a 100755
--- a/qt/i18n/update-po-template
+++ b/qt/po/scripts/update-po-template
@@ -4,7 +4,7 @@
#
set -eo pipefail
-topDir=$(dirname $0)/../..
+topDir=$(dirname $0)/../../../
cd $topDir
all=all.files
@@ -16,5 +16,5 @@ for i in qt/aqt/{*.py,forms/*.py}; do
echo $i >> $all
done
-xgettext -cT: -s --no-wrap --files-from=$all --output=qt/i18n/po/desktop/anki.pot
+xgettext -cT: -s --no-wrap --files-from=$all --output=qt/po/repo/desktop/anki.pot
rm $all
diff --git a/qt/po/scripts/upload-latest-template b/qt/po/scripts/upload-latest-template
new file mode 100755
index 000000000..cef056142
--- /dev/null
+++ b/qt/po/scripts/upload-latest-template
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+set -eo pipefail
+
+scripts/update-po-template
+(cd repo && git add desktop; git commit -m update; git push)
diff --git a/qt/tests/test_i18n.py b/qt/tests/test_i18n.py
index edff4dc87..c11180032 100644
--- a/qt/tests/test_i18n.py
+++ b/qt/tests/test_i18n.py
@@ -1,13 +1,13 @@
import anki.lang
-from anki.rsbackend import FString
+from anki.rsbackend import TR
def test_no_collection_i18n():
anki.lang.set_lang("zz", "")
tr2 = anki.lang.current_i18n.translate
no_uni = anki.lang.without_unicode_isolation
- assert no_uni(tr2(FString.STATISTICS_REVIEWS, reviews=2)) == "2 reviews"
+ assert no_uni(tr2(TR.STATISTICS_REVIEWS, reviews=2)) == "2 reviews"
anki.lang.set_lang("ja", "")
tr2 = anki.lang.current_i18n.translate
- assert no_uni(tr2(FString.STATISTICS_REVIEWS, reviews=2)) == "2 枚の復習カード"
+ assert no_uni(tr2(TR.STATISTICS_REVIEWS, reviews=2)) == "2 枚の復習カード"
diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py
index b92d0a969..54b131f51 100644
--- a/qt/tools/genhooks_gui.py
+++ b/qt/tools/genhooks_gui.py
@@ -346,6 +346,30 @@ hooks = [
name="media_sync_did_progress", args=["entry: aqt.mediasync.LogEntryWithTime"],
),
Hook(name="media_sync_did_start_or_stop", args=["running: bool"]),
+ Hook(
+ name="empty_cards_will_be_deleted",
+ args=["cids: List[int]"],
+ return_type="List[int]",
+ doc="""Allow to change the list of cards to delete.
+
+ For example, an add-on creating a method to delete only empty
+ new cards would be done as follow:
+```
+from anki.consts import CARD_TYPE_NEW
+from anki.utils import ids2str
+from aqt import mw
+from aqt import gui_hooks
+
+def filter(cids, col):
+ return col.db.list(
+ f"select id from cards where (type={CARD_TYPE_NEW} and (id in {ids2str(cids)))")
+
+def emptyNewCard():
+ gui_hooks.append(filter)
+ mw.onEmptyCards()
+ gui_hooks.remove(filter)
+```""",
+ ),
# Adding cards
###################
Hook(
diff --git a/qt/ts/scss/reviewer.scss b/qt/ts/scss/reviewer.scss
index c5e05619e..f72859c99 100644
--- a/qt/ts/scss/reviewer.scss
+++ b/qt/ts/scss/reviewer.scss
@@ -1,6 +1,8 @@
/* Copyright: Ankitects Pty Ltd and contributors
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
+@use 'vars';
+
hr {
background-color: #ccc;
}
@@ -11,8 +13,8 @@ body {
}
body.nightMode {
- background-color: black;
- color: white;
+ background-color: vars.$night-window-bg;
+ color: vars.$night-text-fg;
}
img {
diff --git a/rslib/ftl/sync.ftl b/rslib/ftl/sync.ftl
index 39350a03b..97dec7e7e 100644
--- a/rslib/ftl/sync.ftl
+++ b/rslib/ftl/sync.ftl
@@ -11,9 +11,16 @@ sync-media-complete = Media sync complete.
sync-media-failed = Media sync failed.
sync-media-aborting = Media sync aborting...
sync-media-aborted = Media sync aborted.
+
+# Shown in the sync log to indicate media syncing will not be done, because it
+# was previously disabled by the user in the preferences screen.
sync-media-disabled = Media sync disabled.
sync-abort-button = Abort
+sync-media-log-button = Media Log
+
+# Title of the screen that shows syncing progress history
+sync-media-log-title = Media Sync Log
## Error messages
diff --git a/rslib/src/backend.rs b/rslib/src/backend.rs
index 493471b6c..0f76a049f 100644
--- a/rslib/src/backend.rs
+++ b/rslib/src/backend.rs
@@ -44,24 +44,21 @@ fn anki_error_to_proto_error(err: AnkiError, i18n: &I18n) -> pb::BackendError {
use pb::backend_error::Value as V;
let localized = err.localized_description(i18n);
let value = match err {
- AnkiError::InvalidInput { info } => V::InvalidInput(pb::StringError { info }),
- AnkiError::TemplateError { info } => V::TemplateParse(pb::TemplateParseError { info }),
- AnkiError::IOError { info } => V::IoError(pb::StringError { info }),
- AnkiError::DBError { info } => V::DbError(pb::StringError { info }),
- AnkiError::NetworkError { info, kind } => V::NetworkError(pb::NetworkError {
- info,
- kind: kind.into(),
- localized,
- }),
- AnkiError::SyncError { info, kind } => V::SyncError(pb::SyncError {
- info,
- kind: kind.into(),
- localized,
- }),
+ AnkiError::InvalidInput { .. } => V::InvalidInput(pb::Empty {}),
+ AnkiError::TemplateError { .. } => V::TemplateParse(pb::Empty {}),
+ AnkiError::IOError { .. } => V::IoError(pb::Empty {}),
+ AnkiError::DBError { .. } => V::DbError(pb::Empty {}),
+ AnkiError::NetworkError { kind, .. } => {
+ V::NetworkError(pb::NetworkError { kind: kind.into() })
+ }
+ AnkiError::SyncError { kind, .. } => V::SyncError(pb::SyncError { kind: kind.into() }),
AnkiError::Interrupted => V::Interrupted(Empty {}),
};
- pb::BackendError { value: Some(value) }
+ pb::BackendError {
+ value: Some(value),
+ localized,
+ }
}
// Convert an Anki error to a protobuf output.
@@ -422,7 +419,10 @@ impl Backend {
None => return "".to_string(),
};
match context {
- pb::format_time_span_in::Context::Normal => time_span(input.seconds, &self.i18n),
+ pb::format_time_span_in::Context::Precise => time_span(input.seconds, &self.i18n, true),
+ pb::format_time_span_in::Context::Intervals => {
+ time_span(input.seconds, &self.i18n, false)
+ }
pb::format_time_span_in::Context::AnswerButtons => {
answer_button_time(input.seconds, &self.i18n)
}
diff --git a/rslib/src/err.rs b/rslib/src/err.rs
index 9a39babf0..1d2bd2faf 100644
--- a/rslib/src/err.rs
+++ b/rslib/src/err.rs
@@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-use crate::i18n::{FString, I18n};
+use crate::i18n::{tr_strs, FString, I18n};
pub use failure::{Error, Fail};
use reqwest::StatusCode;
use std::io;
@@ -67,14 +67,21 @@ impl AnkiError {
SyncErrorKind::ResyncRequired => i18n.tr(FString::SyncResyncRequired),
}
.into(),
- AnkiError::NetworkError { kind, .. } => match kind {
- NetworkErrorKind::Offline => i18n.tr(FString::NetworkOffline),
- NetworkErrorKind::Timeout => i18n.tr(FString::NetworkTimeout),
- NetworkErrorKind::ProxyAuth => i18n.tr(FString::NetworkProxyAuth),
- NetworkErrorKind::Other => i18n.tr(FString::NetworkOther),
+ AnkiError::NetworkError { kind, info } => {
+ let summary = match kind {
+ NetworkErrorKind::Offline => i18n.tr(FString::NetworkOffline),
+ NetworkErrorKind::Timeout => i18n.tr(FString::NetworkTimeout),
+ NetworkErrorKind::ProxyAuth => i18n.tr(FString::NetworkProxyAuth),
+ NetworkErrorKind::Other => i18n.tr(FString::NetworkOther),
+ };
+ let details = i18n.trn(FString::NetworkDetails, tr_strs!["details"=>info]);
+ format!("{}\n\n{}", summary, details)
}
- .into(),
- _ => "".into(),
+ AnkiError::TemplateError { info } => {
+ // already localized
+ info.into()
+ }
+ _ => format!("{:?}", self),
}
}
}
diff --git a/rslib/src/sched/timespan.rs b/rslib/src/sched/timespan.rs
index 9c496811e..f78156ad9 100644
--- a/rslib/src/sched/timespan.rs
+++ b/rslib/src/sched/timespan.rs
@@ -6,13 +6,7 @@ use crate::i18n::{tr_args, FString, I18n};
/// Short string like '4d' to place above answer buttons.
pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String {
let span = Timespan::from_secs(seconds).natural_span();
- let amount = match span.unit() {
- // months/years shown with 1 decimal place
- TimespanUnit::Months | TimespanUnit::Years => (span.as_unit() * 10.0).round() / 10.0,
- // other values shown without decimals
- _ => span.as_unit().round(),
- };
- let args = tr_args!["amount" => amount];
+ let args = tr_args!["amount" => span.as_rounded_unit()];
let key = match span.unit() {
TimespanUnit::Seconds => FString::SchedulingAnswerButtonTimeSeconds,
TimespanUnit::Minutes => FString::SchedulingAnswerButtonTimeMinutes,
@@ -24,11 +18,17 @@ pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String {
i18n.trn(key, args)
}
-/// Describe the given seconds using the largest appropriate unit
+/// Describe the given seconds using the largest appropriate unit.
+/// If precise is true, show to two decimal places, eg
/// eg 70 seconds -> "1.17 minutes"
-pub fn time_span(seconds: f32, i18n: &I18n) -> String {
+/// If false, seconds and days are shown without decimals.
+pub fn time_span(seconds: f32, i18n: &I18n, precise: bool) -> String {
let span = Timespan::from_secs(seconds).natural_span();
- let amount = span.as_unit();
+ let amount = if precise {
+ span.as_unit()
+ } else {
+ span.as_rounded_unit()
+ };
let args = tr_args!["amount" => amount];
let key = match span.unit() {
TimespanUnit::Seconds => FString::SchedulingTimeSpanSeconds,
@@ -133,6 +133,17 @@ impl Timespan {
}
}
+ /// Round seconds and days to integers, otherwise
+ /// truncates to one decimal place.
+ fn as_rounded_unit(self) -> f32 {
+ match self.unit {
+ // seconds/days as integer
+ TimespanUnit::Seconds | TimespanUnit::Days => self.as_unit().round(),
+ // other values shown to 1 decimal place
+ _ => (self.as_unit() * 10.0).round() / 10.0,
+ }
+ }
+
fn unit(self) -> TimespanUnit {
self.unit
}
@@ -173,16 +184,18 @@ mod test {
fn answer_buttons() {
let i18n = I18n::new(&["zz"], "");
assert_eq!(answer_button_time(30.0, &i18n), "30s");
- assert_eq!(answer_button_time(70.0, &i18n), "1m");
+ assert_eq!(answer_button_time(70.0, &i18n), "1.2m");
assert_eq!(answer_button_time(1.1 * MONTH, &i18n), "1.1mo");
}
#[test]
fn time_spans() {
let i18n = I18n::new(&["zz"], "");
- assert_eq!(time_span(1.0, &i18n), "1 second");
- assert_eq!(time_span(30.0, &i18n), "30 seconds");
- assert_eq!(time_span(90.0, &i18n), "1.5 minutes");
+ assert_eq!(time_span(1.0, &i18n, false), "1 second");
+ assert_eq!(time_span(30.3, &i18n, false), "30 seconds");
+ assert_eq!(time_span(30.3, &i18n, true), "30.3 seconds");
+ assert_eq!(time_span(90.0, &i18n, false), "1.5 minutes");
+ assert_eq!(time_span(45.0 * 86_400.0, &i18n, false), "1.5 months");
}
#[test]