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]