Merge remote-tracking branch 'danielelmes/master' into fix_windows_build

# Conflicts:
#	Makefile
This commit is contained in:
evandrocoan 2020-02-27 00:20:34 -03:00
commit dc049ce26a
46 changed files with 371 additions and 236 deletions

View file

@ -9,7 +9,7 @@ git log --pretty=format:' - %ae' CONTRIBUTORS |sort |uniq |sort -f | sed "s/@/$a
headAuthor=$(git log -1 --pretty=format:'%ae') headAuthor=$(git log -1 --pretty=format:'%ae')
authorAt=$(echo "$headAuthor" | sed "s/@/$antispam/") 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" echo "Author $authorAt found in CONTRIBUTORS"
else else
echo "Author $authorAt NOT found in list" echo "Author $authorAt NOT found in list"

View file

@ -44,7 +44,7 @@ buildhash:
fi fi
.PHONY: develop .PHONY: develop
develop: pyenv buildhash develop: pyenv buildhash prepare
set -eo pipefail && \ set -eo pipefail && \
. "${ACTIVATE_SCRIPT}" && \ . "${ACTIVATE_SCRIPT}" && \
for dir in $(DEVEL); do \ for dir in $(DEVEL); do \
@ -58,6 +58,16 @@ run: develop
echo "Starting Anki..."; \ echo "Starting Anki..."; \
python qt/runanki $(RUNFLAGS) 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 .PHONY: build
build: clean-dist build-rspy build-pylib build-qt add-buildhash build: clean-dist build-rspy build-pylib build-qt add-buildhash
@echo @echo
@ -123,10 +133,10 @@ add-buildhash:
pull-i18n: pull-i18n:
(cd rslib/ftl && scripts/fetch-latest-translations) (cd rslib/ftl && scripts/fetch-latest-translations)
(cd qt/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 .PHONY: push-i18n
push-i18n: pull-i18n push-i18n: pull-i18n
(cd rslib/ftl && scripts/upload-latest-templates) (cd rslib/ftl && scripts/upload-latest-templates)
(cd qt/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)

View file

@ -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 in one of those files and running 'make develop' will update pylib/anki/hooks
.py or qt/aqt/gui_hooks.py. .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 Tests Must Pass
---------------- ----------------

View file

@ -19,10 +19,9 @@ message I18nBackendInit {
string locale_folder_path = 5; string locale_folder_path = 5;
} }
// 1-15 reserved for future use; 2047 for errors // 1-15 reserved for future use
message BackendInput { message BackendInput {
reserved 2047;
oneof value { oneof value {
TemplateRequirementsIn template_requirements = 16; TemplateRequirementsIn template_requirements = 16;
SchedTimingTodayIn sched_timing_today = 17; SchedTimingTodayIn sched_timing_today = 17;
@ -47,37 +46,43 @@ message BackendInput {
message BackendOutput { message BackendOutput {
oneof value { oneof value {
TemplateRequirementsOut template_requirements = 16; // infallible commands
SchedTimingTodayOut sched_timing_today = 17; 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; sint32 local_minutes_west = 22;
string strip_av_tags = 23; string strip_av_tags = 23;
ExtractAVTagsOut extract_av_tags = 24; ExtractAVTagsOut extract_av_tags = 24;
ExtractLatexOut extract_latex = 25; 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 translate_string = 30;
string format_time_span = 31; string format_time_span = 31;
string studied_today = 32; string studied_today = 32;
string congrats_learn_msg = 33; 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; BackendError error = 2047;
} }
} }
message BackendError { message BackendError {
// localized error description suitable for displaying to the user
string localized = 1;
// error specifics
oneof value { oneof value {
StringError invalid_input = 1; Empty invalid_input = 2;
TemplateParseError template_parse = 2; Empty template_parse = 3;
StringError io_error = 3; Empty io_error = 4;
StringError db_error = 4; Empty db_error = 5;
NetworkError network_error = 5; NetworkError network_error = 6;
SyncError sync_error = 6; SyncError sync_error = 7;
// user interrupted operation // user interrupted operation
Empty interrupted = 8; Empty interrupted = 8;
} }
@ -90,28 +95,17 @@ message Progress {
} }
} }
message StringError {
string info = 1;
}
message TemplateParseError {
string info = 1;
}
message NetworkError { message NetworkError {
string info = 1;
enum NetworkErrorKind { enum NetworkErrorKind {
OTHER = 0; OTHER = 0;
OFFLINE = 1; OFFLINE = 1;
TIMEOUT = 2; TIMEOUT = 2;
PROXY_AUTH = 3; PROXY_AUTH = 3;
} }
NetworkErrorKind kind = 2; NetworkErrorKind kind = 1;
string localized = 3;
} }
message SyncError { message SyncError {
string info = 1;
enum SyncErrorKind { enum SyncErrorKind {
OTHER = 0; OTHER = 0;
CONFLICT = 1; CONFLICT = 1;
@ -122,8 +116,7 @@ message SyncError {
MEDIA_CHECK_REQUIRED = 6; MEDIA_CHECK_REQUIRED = 6;
RESYNC_REQUIRED = 7; RESYNC_REQUIRED = 7;
} }
SyncErrorKind kind = 2; SyncErrorKind kind = 1;
string localized = 3;
} }
message MediaSyncProgress { message MediaSyncProgress {
@ -307,8 +300,9 @@ message TranslateArgValue {
message FormatTimeSpanIn { message FormatTimeSpanIn {
enum Context { enum Context {
NORMAL = 0; PRECISE = 0;
ANSWER_BUTTONS = 1; ANSWER_BUTTONS = 1;
INTERVALS = 2;
} }
float seconds = 1; float seconds = 1;

View file

@ -73,7 +73,6 @@ class _Collection:
ls: int ls: int
conf: Dict[str, Any] conf: Dict[str, Any]
_undo: List[Any] _undo: List[Any]
backend: RustBackend
def __init__( def __init__(
self, self,
@ -83,6 +82,7 @@ class _Collection:
log: bool = False, log: bool = False,
) -> None: ) -> None:
self.backend = backend self.backend = backend
self.tr = backend.translate
self._debugLog = log self._debugLog = log
self.db = db self.db = db
self.path = db._path self.path = db._path

View file

@ -12,7 +12,7 @@ import ankirspy # pytype: disable=import-error
import anki.backend_pb2 as pb import anki.backend_pb2 as pb
import anki.buildinfo import anki.buildinfo
from anki import hooks 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.models import AllTemplateReqs
from anki.sound import AVTag, SoundOrVideoTag, TTSTag from anki.sound import AVTag, SoundOrVideoTag, TTSTag
from anki.types import assert_impossible_literal from anki.types import assert_impossible_literal
@ -32,14 +32,17 @@ class StringError(Exception):
NetworkErrorKind = pb.NetworkError.NetworkErrorKind NetworkErrorKind = pb.NetworkError.NetworkErrorKind
SyncErrorKind = pb.SyncError.SyncErrorKind
class NetworkError(StringError): class NetworkError(StringError):
def kind(self) -> NetworkErrorKind: def kind(self) -> NetworkErrorKind:
return self.args[1] 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): class IOError(StringError):
@ -54,35 +57,22 @@ class TemplateError(StringError):
pass 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: def proto_exception_to_native(err: pb.BackendError) -> Exception:
val = err.WhichOneof("value") val = err.WhichOneof("value")
if val == "interrupted": if val == "interrupted":
return Interrupted() return Interrupted()
elif val == "network_error": elif val == "network_error":
e = err.network_error return NetworkError(err.localized, err.network_error.kind)
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)
elif val == "sync_error": elif val == "sync_error":
e2 = err.sync_error return SyncError(err.localized, err.sync_error.kind)
return SyncError(e2.info, e2.kind, e2.localized) 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: else:
assert_impossible_literal(val) assert_impossible_literal(val)
@ -334,7 +324,7 @@ class RustBackend:
pb.BackendInput(trash_media_files=pb.TrashMediaFilesIn(fnames=fnames)) 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( return self._run_command(
pb.BackendInput(translate_string=translate_string_in(key, **kwargs)) pb.BackendInput(translate_string=translate_string_in(key, **kwargs))
).translate_string ).translate_string
@ -342,7 +332,7 @@ class RustBackend:
def format_time_span( def format_time_span(
self, self,
seconds: float, seconds: float,
context: FormatTimeSpanContext = FormatTimeSpanContext.NORMAL, context: FormatTimeSpanContext = FormatTimeSpanContext.INTERVALS,
) -> str: ) -> str:
return self._run_command( return self._run_command(
pb.BackendInput( pb.BackendInput(
@ -368,7 +358,7 @@ class RustBackend:
def translate_string_in( def translate_string_in(
key: FString, **kwargs: Union[str, int, float] key: TR, **kwargs: Union[str, int, float]
) -> pb.TranslateStringIn: ) -> pb.TranslateStringIn:
args = {} args = {}
for (k, v) in kwargs.items(): for (k, v) in kwargs.items():
@ -386,7 +376,7 @@ class I18nBackend:
) )
self._backend = ankirspy.open_i18n(init_msg.SerializeToString()) 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( return self._backend.translate(
translate_string_in(key, **kwargs).SerializeToString() translate_string_in(key, **kwargs).SerializeToString()
) )

View file

@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple
import anki import anki
from anki.consts import * from anki.consts import *
from anki.lang import _, ngettext from anki.lang import _, ngettext
from anki.rsbackend import FString from anki.rsbackend import TR, FormatTimeSpanContext
from anki.utils import ids2str from anki.utils import ids2str
# Card stats # Card stats
@ -47,9 +47,7 @@ class CardStats:
next = c.due next = c.due
next = self.date(next) next = self.date(next)
if next: if next:
self.addLine( self.addLine(self.col.tr(TR.STATISTICS_DUE_DATE), next)
self.col.backend.translate(FString.STATISTICS_DUE_DATE), next,
)
if c.queue == QUEUE_TYPE_REV: if c.queue == QUEUE_TYPE_REV:
self.addLine( self.addLine(
_("Interval"), self.col.backend.format_time_span(c.ivl * 86400) _("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)) return time.strftime("%Y-%m-%d", time.localtime(tm))
def time(self, tm: float) -> str: 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 # Collection stats
@ -276,9 +276,7 @@ from revlog where id > ? """
def _dueInfo(self, tot, num) -> str: def _dueInfo(self, tot, num) -> str:
i: List[str] = [] i: List[str] = []
self._line( self._line(
i, i, _("Total"), self.col.tr(TR.STATISTICS_REVIEWS, reviews=tot),
_("Total"),
self.col.backend.translate(FString.STATISTICS_REVIEWS, reviews=tot),
) )
self._line(i, _("Average"), self._avgDay(tot, num, _("reviews"))) self._line(i, _("Average"), self._avgDay(tot, num, _("reviews")))
tomorrow = self.col.db.scalar( tomorrow = self.col.db.scalar(
@ -455,8 +453,8 @@ group by day order by day"""
self._line( self._line(
i, i,
_("Average answer time"), _("Average answer time"),
self.col.backend.translate( self.col.tr(
FString.STATISTICS_AVERAGE_ANSWER_TIME, TR.STATISTICS_AVERAGE_ANSWER_TIME,
**{"cards-per-minute": perMin, "average-seconds": average_secs}, **{"cards-per-minute": perMin, "average-seconds": average_secs},
), ),
) )

View file

@ -5,7 +5,7 @@ import tempfile
from anki import Collection as aopen from anki import Collection as aopen
from anki.lang import without_unicode_isolation 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.stdmodels import addBasicModel, models
from anki.utils import isWin from anki.utils import isWin
from tests.shared import assertException, getEmptyCol from tests.shared import assertException, getEmptyCol
@ -153,12 +153,11 @@ def test_furigana():
def test_translate(): def test_translate():
d = getEmptyCol() d = getEmptyCol()
tr = d.backend.translate
no_uni = without_unicode_isolation no_uni = without_unicode_isolation
assert ( assert (
tr(FString.CARD_TEMPLATE_RENDERING_FRONT_SIDE_PROBLEM) d.tr(TR.CARD_TEMPLATE_RENDERING_FRONT_SIDE_PROBLEM)
== "Front template has a problem:" == "Front template has a problem:"
) )
assert no_uni(tr(FString.STATISTICS_REVIEWS, reviews=1)) == "1 review" assert no_uni(d.tr(TR.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=2)) == "2 reviews"

View file

@ -27,8 +27,10 @@ all: check
./tools/build_ui.sh ./tools/build_ui.sh
@touch $@ @touch $@
.build/i18n: $(wildcard i18n/po/desktop/*/anki.po) .build/i18n: $(wildcard po/repo/desktop/*/anki.po)
(cd i18n && ./pull-git && ./build-mo-files && ./copy-qt-files) (cd po && ./scripts/fetch-latest-translations && \
./scripts/build-mo-files && \
./scripts/copy-qt-files)
@touch $@ @touch $@
TSDEPS := $(wildcard ts/src/*.ts) $(wildcard ts/scss/*.scss) TSDEPS := $(wildcard ts/src/*.ts) $(wildcard ts/scss/*.scss)

View file

@ -24,7 +24,7 @@ from anki.consts import *
from anki.lang import _, ngettext from anki.lang import _, ngettext
from anki.models import NoteType from anki.models import NoteType
from anki.notes import Note 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 anki.utils import htmlToTextLine, ids2str, intTime, isMac, isWin
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.editor import Editor from aqt.editor import Editor
@ -356,7 +356,7 @@ class DataModel(QAbstractTableModel):
elif c.queue == QUEUE_TYPE_LRN: elif c.queue == QUEUE_TYPE_LRN:
date = c.due date = c.due
elif c.queue == QUEUE_TYPE_NEW or c.type == CARD_TYPE_NEW: 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 ( elif c.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or (
c.type == CARD_TYPE_REV and c.queue < 0 c.type == CARD_TYPE_REV and c.queue < 0
): ):
@ -730,7 +730,7 @@ class Browser(QMainWindow):
("noteCrt", _("Created")), ("noteCrt", _("Created")),
("noteMod", _("Edited")), ("noteMod", _("Edited")),
("cardMod", _("Changed")), ("cardMod", _("Changed")),
("cardDue", tr(FString.STATISTICS_DUE_DATE)), ("cardDue", tr(TR.STATISTICS_DUE_DATE)),
("cardIvl", _("Interval")), ("cardIvl", _("Interval")),
("cardEase", _("Ease")), ("cardEase", _("Ease")),
("cardReps", _("Reviews")), ("cardReps", _("Reviews")),
@ -1281,7 +1281,7 @@ by clicking on one on the left."""
(_("New"), "is:new"), (_("New"), "is:new"),
(_("Learning"), "is:learn"), (_("Learning"), "is:learn"),
(_("Review"), "is:review"), (_("Review"), "is:review"),
(tr(FString.FILTERING_IS_DUE), "is:due"), (tr(TR.FILTERING_IS_DUE), "is:due"),
None, None,
(_("Suspended"), "is:suspended"), (_("Suspended"), "is:suspended"),
(_("Buried"), "is:buried"), (_("Buried"), "is:buried"),
@ -1494,6 +1494,8 @@ border: 1px solid #000; padding: 3px; '>%s</div>"""
if ivl == 0: if ivl == 0:
ivl = "" ivl = ""
else: else:
if ivl > 0:
ivl *= 86_400
ivl = cs.time(abs(ivl)) ivl = cs.time(abs(ivl))
s += "<td align=right>%s</td>" % tstr s += "<td align=right>%s</td>" % tstr
s += "<td align=center>%s</td>" % ease s += "<td align=center>%s</td>" % ease
@ -1501,7 +1503,7 @@ border: 1px solid #000; padding: 3px; '>%s</div>"""
s += ("<td align=right>%s</td>" * 2) % ( s += ("<td align=right>%s</td>" * 2) % (
"%d%%" % (factor / 10) if factor else "", "%d%%" % (factor / 10) if factor else "",
cs.time(taken), self.col.backend.format_time_span(taken),
) + "</tr>" ) + "</tr>"
s += "</table>" s += "</table>"
if cnt < self.card.reps: if cnt < self.card.reps:
@ -1770,8 +1772,10 @@ where id in %s"""
else: else:
audio = c.answer_av_tags() audio = c.answer_av_tags()
av_player.play_tags(audio) 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 = gui_hooks.card_will_show(
txt, c, "preview" + self._previewState.capitalize() txt, c, "preview" + self._previewState.capitalize()
) )

View file

@ -11,7 +11,7 @@ from typing import Any
import aqt import aqt
from anki.errors import DeckRenameError from anki.errors import DeckRenameError
from anki.lang import _, ngettext from anki.lang import _, ngettext
from anki.rsbackend import FString from anki.rsbackend import TR
from anki.utils import ids2str from anki.utils import ids2str
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.qt import * from aqt.qt import *
@ -185,7 +185,7 @@ where id > ?""",
<tr><th colspan=5 align=left>%s</th><th class=count>%s</th> <tr><th colspan=5 align=left>%s</th><th class=count>%s</th>
<th class=count>%s</th><th class=optscol></th></tr>""" % ( <th class=count>%s</th><th class=optscol></th></tr>""" % (
_("Deck"), _("Deck"),
tr(FString.STATISTICS_DUE_COUNT), tr(TR.STATISTICS_DUE_COUNT),
_("New"), _("New"),
) )
buf += self._topLevelDragRow() buf += self._topLevelDragRow()

View file

@ -11,7 +11,7 @@ from markdown import markdown
from anki.lang import _ from anki.lang import _
from aqt import mw from aqt import mw
from aqt.qt import * 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"): 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: if "disk I/O error" in error:
showWarning(markdown(tr(FString.ERRORS_ACCESSING_DB))) showWarning(markdown(tr(TR.ERRORS_ACCESSING_DB)))
return return
if self.mw.addonManager.dirty: 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 error = supportText() + self._addonText(error) + "\n" + error
else: else:
txt = markdown(tr(FString.ERRORS_STANDARD_POPUP)) txt = markdown(tr(TR.ERRORS_STANDARD_POPUP))
error = supportText() + "\n" + error error = supportText() + "\n" + error
# show dialog # show dialog

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import os import os
import re import re
import time import time
@ -15,7 +17,12 @@ from aqt.utils import checkInvalidFilename, getSaveFile, showWarning, tooltip
class ExportDialog(QDialog): 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) QDialog.__init__(self, mw, Qt.Window)
self.mw = mw self.mw = mw
self.col = mw.col self.col = mw.col
@ -118,6 +125,9 @@ class ExportDialog(QDialog):
return return
if checkInvalidFilename(os.path.basename(file), dirsep=False): if checkInvalidFilename(os.path.basename(file), dirsep=False):
continue continue
if os.path.commonprefix([self.mw.pm.base, file]) == self.mw.pm.base:
showWarning("Please choose a different export location.")
continue
break break
self.hide() self.hide()
if file: if file:

View file

@ -862,6 +862,51 @@ class _EditorWillUseFontForFieldFilter:
editor_will_use_font_for_field = _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: class _MediaSyncDidProgressHook:
_hooks: List[Callable[["aqt.mediasync.LogEntryWithTime"], None]] = [] _hooks: List[Callable[["aqt.mediasync.LogEntryWithTime"], None]] = []

View file

@ -33,7 +33,7 @@ def stripSounds(text) -> str:
return aqt.mw.col.backend.strip_av_tags(text) 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()") print("fmtTimeSpan() has become col.backend.format_time_span()")
return aqt.mw.col.backend.format_time_span(time) return aqt.mw.col.backend.format_time_span(time)

View file

@ -1303,6 +1303,7 @@ will be lost. Continue?"""
def onEmptyCards(self): def onEmptyCards(self):
self.progress.start(immediate=True) self.progress.start(immediate=True)
cids = self.col.emptyCids() cids = self.col.emptyCids()
cids = gui_hooks.empty_cards_will_be_deleted(cids)
if not cids: if not cids:
self.progress.finish() self.progress.finish()
tooltip(_("No empty cards.")) tooltip(_("No empty cards."))

View file

@ -10,13 +10,7 @@ from typing import Iterable, List, Optional, TypeVar
import aqt import aqt
from anki import hooks from anki import hooks
from anki.rsbackend import ( from anki.rsbackend import TR, Interrupted, MediaCheckOutput, Progress, ProgressKind
FString,
Interrupted,
MediaCheckOutput,
Progress,
ProgressKind,
)
from aqt.qt import * from aqt.qt import *
from aqt.utils import askUser, restoreGeom, saveGeom, showText, tooltip, tr from aqt.utils import askUser, restoreGeom, saveGeom, showText, tooltip, tr
@ -89,14 +83,14 @@ class MediaChecker:
layout.addWidget(box) layout.addWidget(box)
if output.unused: if output.unused:
b = QPushButton(tr(FString.MEDIA_CHECK_DELETE_UNUSED)) b = QPushButton(tr(TR.MEDIA_CHECK_DELETE_UNUSED))
b.setAutoDefault(False) b.setAutoDefault(False)
box.addButton(b, QDialogButtonBox.RejectRole) box.addButton(b, QDialogButtonBox.RejectRole)
b.clicked.connect(lambda c: self._on_trash_files(output.unused)) # type: ignore b.clicked.connect(lambda c: self._on_trash_files(output.unused)) # type: ignore
if output.missing: if output.missing:
if any(map(lambda x: x.startswith("latex-"), 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) b.setAutoDefault(False)
box.addButton(b, QDialogButtonBox.RejectRole) box.addButton(b, QDialogButtonBox.RejectRole)
b.clicked.connect(self._on_render_latex) # type: ignore b.clicked.connect(self._on_render_latex) # type: ignore
@ -125,17 +119,17 @@ class MediaChecker:
browser.onSearchActivated() browser.onSearchActivated()
showText(err, type="html") showText(err, type="html")
else: 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: def _on_render_latex_progress(self, count: int) -> bool:
if self.progress_dialog.wantCancel: if self.progress_dialog.wantCancel:
return False 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 return True
def _on_trash_files(self, fnames: List[str]): 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 return
self.progress_dialog = self.mw.progress.start() self.progress_dialog = self.mw.progress.start()
@ -149,10 +143,10 @@ class MediaChecker:
remaining -= len(chunk) remaining -= len(chunk)
if time.time() - last_progress >= 0.3: if time.time() - last_progress >= 0.3:
self.mw.progress.update( self.mw.progress.update(
tr(FString.MEDIA_CHECK_FILES_REMAINING, count=remaining) tr(TR.MEDIA_CHECK_FILES_REMAINING, count=remaining)
) )
finally: finally:
self.mw.progress.finish() self.mw.progress.finish()
self.progress_dialog = None self.progress_dialog = None
tooltip(tr(FString.MEDIA_CHECK_DELETE_UNUSED_COMPLETE, count=total)) tooltip(tr(TR.MEDIA_CHECK_DELETE_UNUSED_COMPLETE, count=total))

View file

@ -10,15 +10,7 @@ from typing import List, Union
import aqt import aqt
from anki import hooks from anki import hooks
from anki.rsbackend import ( from anki.rsbackend import TR, Interrupted, MediaSyncProgress, Progress, ProgressKind
FString,
Interrupted,
MediaSyncProgress,
NetworkError,
Progress,
ProgressKind,
SyncError,
)
from anki.types import assert_impossible from anki.types import assert_impossible
from anki.utils import intTime from anki.utils import intTime
from aqt import gui_hooks from aqt import gui_hooks
@ -65,10 +57,10 @@ class MediaSyncer:
return return
if not self.mw.pm.media_syncing_enabled(): 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 return
self._log_and_notify(tr(FString.SYNC_MEDIA_STARTING)) self._log_and_notify(tr(TR.SYNC_MEDIA_STARTING))
self._syncing = True self._syncing = True
self._want_stop = False self._want_stop = False
gui_hooks.media_sync_did_start_or_stop(True) gui_hooks.media_sync_did_start_or_stop(True)
@ -101,21 +93,15 @@ class MediaSyncer:
if exc is not None: if exc is not None:
self._handle_sync_error(exc) self._handle_sync_error(exc)
else: 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): def _handle_sync_error(self, exc: BaseException):
if isinstance(exc, Interrupted): if isinstance(exc, Interrupted):
self._log_and_notify(tr(FString.SYNC_MEDIA_ABORTED)) self._log_and_notify(tr(TR.SYNC_MEDIA_ABORTED))
return return
self._log_and_notify(tr(FString.SYNC_MEDIA_FAILED)) self._log_and_notify(tr(TR.SYNC_MEDIA_FAILED))
if isinstance(exc, SyncError): showWarning(str(exc))
showWarning(exc.localized())
elif isinstance(exc, NetworkError):
msg = exc.localized()
msg += "\n\n" + tr(FString.NETWORK_DETAILS, details=str(exc))
else:
raise exc
def entries(self) -> List[LogEntryWithTime]: def entries(self) -> List[LogEntryWithTime]:
return self._log return self._log
@ -123,7 +109,7 @@ class MediaSyncer:
def abort(self) -> None: def abort(self) -> None:
if not self.is_syncing(): if not self.is_syncing():
return return
self._log_and_notify(tr(FString.SYNC_MEDIA_ABORTING)) self._log_and_notify(tr(TR.SYNC_MEDIA_ABORTING))
self._want_stop = True self._want_stop = True
def is_syncing(self) -> bool: def is_syncing(self) -> bool:
@ -166,10 +152,12 @@ class MediaSyncDialog(QDialog):
self._close_when_done = close_when_done self._close_when_done = close_when_done
self.form = aqt.forms.synclog.Ui_Dialog() self.form = aqt.forms.synclog.Ui_Dialog()
self.form.setupUi(self) 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.clicked.connect(self._on_abort) # type: ignore
self.abort_button.setAutoDefault(False) self.abort_button.setAutoDefault(False)
self.form.buttonBox.addButton(self.abort_button, QDialogButtonBox.ActionRole) 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_progress.append(self._on_log_entry)
gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop) gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop)

View file

@ -10,7 +10,7 @@ import aqt
from anki.lang import _ from anki.lang import _
from aqt import AnkiQt from aqt import AnkiQt
from aqt.qt import * 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): class Preferences(QDialog):
@ -177,6 +177,8 @@ class Preferences(QDialog):
###################################################################### ######################################################################
def setupNetwork(self): 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.syncOnProgramOpen.setChecked(self.prof["autoSync"])
self.form.syncMedia.setChecked(self.prof["syncMedia"]) self.form.syncMedia.setChecked(self.prof["syncMedia"])
if not self.prof["syncKey"]: if not self.prof["syncKey"]:
@ -185,6 +187,9 @@ class Preferences(QDialog):
self.form.syncUser.setText(self.prof.get("syncUser", "")) self.form.syncUser.setText(self.prof.get("syncUser", ""))
self.form.syncDeauth.clicked.connect(self.onSyncDeauth) self.form.syncDeauth.clicked.connect(self.onSyncDeauth)
def on_media_log(self):
self.mw.media_syncer.show_sync_log()
def _hideAuth(self): def _hideAuth(self):
self.form.syncDeauth.setVisible(False) self.form.syncDeauth.setVisible(False)
self.form.syncUser.setText("") self.form.syncUser.setText("")

View file

@ -485,7 +485,8 @@ please see:
###################################################################### ######################################################################
def uiScale(self) -> float: 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: def setUiScale(self, scale: float) -> None:
self.meta["uiScale"] = scale self.meta["uiScale"] = scale

View file

@ -187,6 +187,8 @@ The front of this card is empty. Please run Tools>Empty Cards."""
# play audio? # play audio?
if self.autoplay(c): if self.autoplay(c):
av_player.play_tags(c.question_av_tags()) av_player.play_tags(c.question_av_tags())
else:
av_player.maybe_interrupt()
# render & update bottom # render & update bottom
q = self._mungeQA(q) q = self._mungeQA(q)
@ -230,6 +232,8 @@ The front of this card is empty. Please run Tools>Empty Cards."""
# play audio? # play audio?
if self.autoplay(c): if self.autoplay(c):
av_player.play_tags(c.answer_av_tags()) av_player.play_tags(c.answer_av_tags())
else:
av_player.maybe_interrupt()
a = self._mungeQA(a) a = self._mungeQA(a)
a = gui_hooks.card_will_show(a, c, "reviewAnswer") a = gui_hooks.card_will_show(a, c, "reviewAnswer")

View file

@ -95,14 +95,17 @@ class AVPlayer:
def play_tags(self, tags: List[AVTag]) -> None: def play_tags(self, tags: List[AVTag]) -> None:
"""Clear the existing queue, then start playing provided tags.""" """Clear the existing queue, then start playing provided tags."""
self._enqueued = tags[:] self._enqueued = tags[:]
if self.interrupt_current_audio: self.maybe_interrupt()
self._stop_if_playing()
self._play_next_if_idle() self._play_next_if_idle()
def stop_and_clear_queue(self) -> None: def stop_and_clear_queue(self) -> None:
self._enqueued = [] self._enqueued = []
self._stop_if_playing() 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: def play_file(self, filename: str) -> None:
self.play_tags([SoundOrVideoTag(filename=filename)]) self.play_tags([SoundOrVideoTag(filename=filename)])
@ -639,10 +642,6 @@ def setup_audio(taskman: TaskManager, base_folder: str) -> None:
mplayer = SimpleMplayerSlaveModePlayer(taskman) mplayer = SimpleMplayerSlaveModePlayer(taskman)
av_player.players.append(mplayer) av_player.players.append(mplayer)
# currently unused
# mpv = SimpleMpvPlayer(base_folder)
# av_player.players.append(mpv)
# tts support # tts support
if isMac: if isMac:
from aqt.tts import MacTTSPlayer from aqt.tts import MacTTSPlayer

View file

@ -9,7 +9,7 @@ from typing import Dict
from anki.utils import isMac from anki.utils import isMac
from aqt import QApplication, gui_hooks, isWin from aqt import QApplication, gui_hooks, isWin
from aqt.colors import colors 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: class ThemeManager:
@ -23,6 +23,8 @@ class ThemeManager:
return False return False
if not isMac: if not isMac:
return False return False
if qtminor < 13:
return False
import darkdetect # pylint: disable=import-error import darkdetect # pylint: disable=import-error
return darkdetect.isDark() is True return darkdetect.isDark() is True

View file

@ -472,9 +472,14 @@ if isWin:
return LCIDS.get(dec_str, "unknown") return LCIDS.get(dec_str, "unknown")
class WindowsTTSPlayer(TTSProcessPlayer): 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]: def get_available_voices(self) -> List[TTSVoice]:
if self.speaker is None:
return []
return list(map(self._voice_to_object, self.speaker.GetVoices())) return list(map(self._voice_to_object, self.speaker.GetVoices()))
def _voice_to_object(self, voice: Any): def _voice_to_object(self, voice: Any):

View file

@ -13,7 +13,7 @@ from typing import Any, Optional, Union
import anki import anki
import aqt import aqt
from anki.lang import _ from anki.lang import _
from anki.rsbackend import FString from anki.rsbackend import TR
from anki.utils import invalidFilename, isMac, isWin, noBundledLibs, versionWithBuild from anki.utils import invalidFilename, isMac, isWin, noBundledLibs, versionWithBuild
from aqt.qt import * from aqt.qt import *
from aqt.theme import theme_manager from aqt.theme import theme_manager
@ -32,7 +32,7 @@ def locale_dir() -> str:
return os.path.join(aqt_data_folder(), "locale") 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." "Shortcut to access Fluent translations."
return anki.lang.current_i18n.translate(key, **kwargs) return anki.lang.current_i18n.translate(key, **kwargs)

View file

@ -246,7 +246,7 @@
<string>%</string> <string>%</string>
</property> </property>
<property name="minimum"> <property name="minimum">
<number>50</number> <number>100</number>
</property> </property>
<property name="maximum"> <property name="maximum">
<number>200</number> <number>200</number>
@ -371,6 +371,30 @@
</item> </item>
</layout> </layout>
</item> </item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QPushButton" name="media_log">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout> </layout>
</item> </item>
</layout> </layout>
@ -542,10 +566,10 @@
<tabstop>lrnCutoff</tabstop> <tabstop>lrnCutoff</tabstop>
<tabstop>timeLimit</tabstop> <tabstop>timeLimit</tabstop>
<tabstop>numBackups</tabstop> <tabstop>numBackups</tabstop>
<tabstop>syncOnProgramOpen</tabstop>
<tabstop>tabWidget</tabstop>
<tabstop>fullSync</tabstop>
<tabstop>syncMedia</tabstop> <tabstop>syncMedia</tabstop>
<tabstop>tabWidget</tabstop>
<tabstop>syncOnProgramOpen</tabstop>
<tabstop>fullSync</tabstop>
<tabstop>syncDeauth</tabstop> <tabstop>syncDeauth</tabstop>
</tabstops> </tabstops>
<resources/> <resources/>

View file

@ -11,7 +11,7 @@
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
<string>Sync</string> <string/>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout"> <layout class="QVBoxLayout" name="verticalLayout">
<item> <item>

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -1,3 +1,3 @@
.build .build
po repo
ftl ftl

20
qt/po/scripts/build-mo-files Executable file
View file

@ -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

View file

@ -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)

View file

@ -4,7 +4,7 @@
# #
set -eo pipefail set -eo pipefail
topDir=$(dirname $0)/../.. topDir=$(dirname $0)/../../../
cd $topDir cd $topDir
all=all.files all=all.files
@ -16,5 +16,5 @@ for i in qt/aqt/{*.py,forms/*.py}; do
echo $i >> $all echo $i >> $all
done 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 rm $all

View file

@ -0,0 +1,6 @@
#!/bin/bash
set -eo pipefail
scripts/update-po-template
(cd repo && git add desktop; git commit -m update; git push)

View file

@ -1,13 +1,13 @@
import anki.lang import anki.lang
from anki.rsbackend import FString from anki.rsbackend import TR
def test_no_collection_i18n(): def test_no_collection_i18n():
anki.lang.set_lang("zz", "") anki.lang.set_lang("zz", "")
tr2 = anki.lang.current_i18n.translate tr2 = anki.lang.current_i18n.translate
no_uni = anki.lang.without_unicode_isolation 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", "") anki.lang.set_lang("ja", "")
tr2 = anki.lang.current_i18n.translate 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 枚の復習カード"

View file

@ -346,6 +346,30 @@ hooks = [
name="media_sync_did_progress", args=["entry: aqt.mediasync.LogEntryWithTime"], name="media_sync_did_progress", args=["entry: aqt.mediasync.LogEntryWithTime"],
), ),
Hook(name="media_sync_did_start_or_stop", args=["running: bool"]), 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 # Adding cards
################### ###################
Hook( Hook(

View file

@ -1,6 +1,8 @@
/* Copyright: Ankitects Pty Ltd and contributors /* Copyright: Ankitects Pty Ltd and contributors
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
@use 'vars';
hr { hr {
background-color: #ccc; background-color: #ccc;
} }
@ -11,8 +13,8 @@ body {
} }
body.nightMode { body.nightMode {
background-color: black; background-color: vars.$night-window-bg;
color: white; color: vars.$night-text-fg;
} }
img { img {

View file

@ -11,9 +11,16 @@ sync-media-complete = Media sync complete.
sync-media-failed = Media sync failed. sync-media-failed = Media sync failed.
sync-media-aborting = Media sync aborting... sync-media-aborting = Media sync aborting...
sync-media-aborted = Media sync aborted. 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-media-disabled = Media sync disabled.
sync-abort-button = Abort 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 ## Error messages

View file

@ -44,24 +44,21 @@ fn anki_error_to_proto_error(err: AnkiError, i18n: &I18n) -> pb::BackendError {
use pb::backend_error::Value as V; use pb::backend_error::Value as V;
let localized = err.localized_description(i18n); let localized = err.localized_description(i18n);
let value = match err { let value = match err {
AnkiError::InvalidInput { info } => V::InvalidInput(pb::StringError { info }), AnkiError::InvalidInput { .. } => V::InvalidInput(pb::Empty {}),
AnkiError::TemplateError { info } => V::TemplateParse(pb::TemplateParseError { info }), AnkiError::TemplateError { .. } => V::TemplateParse(pb::Empty {}),
AnkiError::IOError { info } => V::IoError(pb::StringError { info }), AnkiError::IOError { .. } => V::IoError(pb::Empty {}),
AnkiError::DBError { info } => V::DbError(pb::StringError { info }), AnkiError::DBError { .. } => V::DbError(pb::Empty {}),
AnkiError::NetworkError { info, kind } => V::NetworkError(pb::NetworkError { AnkiError::NetworkError { kind, .. } => {
info, V::NetworkError(pb::NetworkError { kind: kind.into() })
kind: kind.into(), }
localized, AnkiError::SyncError { kind, .. } => V::SyncError(pb::SyncError { kind: kind.into() }),
}),
AnkiError::SyncError { info, kind } => V::SyncError(pb::SyncError {
info,
kind: kind.into(),
localized,
}),
AnkiError::Interrupted => V::Interrupted(Empty {}), AnkiError::Interrupted => V::Interrupted(Empty {}),
}; };
pb::BackendError { value: Some(value) } pb::BackendError {
value: Some(value),
localized,
}
} }
// Convert an Anki error to a protobuf output. // Convert an Anki error to a protobuf output.
@ -422,7 +419,10 @@ impl Backend {
None => return "".to_string(), None => return "".to_string(),
}; };
match context { 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 => { pb::format_time_span_in::Context::AnswerButtons => {
answer_button_time(input.seconds, &self.i18n) answer_button_time(input.seconds, &self.i18n)
} }

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // 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}; pub use failure::{Error, Fail};
use reqwest::StatusCode; use reqwest::StatusCode;
use std::io; use std::io;
@ -67,14 +67,21 @@ impl AnkiError {
SyncErrorKind::ResyncRequired => i18n.tr(FString::SyncResyncRequired), SyncErrorKind::ResyncRequired => i18n.tr(FString::SyncResyncRequired),
} }
.into(), .into(),
AnkiError::NetworkError { kind, .. } => match kind { AnkiError::NetworkError { kind, info } => {
NetworkErrorKind::Offline => i18n.tr(FString::NetworkOffline), let summary = match kind {
NetworkErrorKind::Timeout => i18n.tr(FString::NetworkTimeout), NetworkErrorKind::Offline => i18n.tr(FString::NetworkOffline),
NetworkErrorKind::ProxyAuth => i18n.tr(FString::NetworkProxyAuth), NetworkErrorKind::Timeout => i18n.tr(FString::NetworkTimeout),
NetworkErrorKind::Other => i18n.tr(FString::NetworkOther), 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(), AnkiError::TemplateError { info } => {
_ => "".into(), // already localized
info.into()
}
_ => format!("{:?}", self),
} }
} }
} }

View file

@ -6,13 +6,7 @@ use crate::i18n::{tr_args, FString, I18n};
/// Short string like '4d' to place above answer buttons. /// Short string like '4d' to place above answer buttons.
pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String { pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String {
let span = Timespan::from_secs(seconds).natural_span(); let span = Timespan::from_secs(seconds).natural_span();
let amount = match span.unit() { let args = tr_args!["amount" => span.as_rounded_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 key = match span.unit() { let key = match span.unit() {
TimespanUnit::Seconds => FString::SchedulingAnswerButtonTimeSeconds, TimespanUnit::Seconds => FString::SchedulingAnswerButtonTimeSeconds,
TimespanUnit::Minutes => FString::SchedulingAnswerButtonTimeMinutes, TimespanUnit::Minutes => FString::SchedulingAnswerButtonTimeMinutes,
@ -24,11 +18,17 @@ pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String {
i18n.trn(key, args) 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" /// 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 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 args = tr_args!["amount" => amount];
let key = match span.unit() { let key = match span.unit() {
TimespanUnit::Seconds => FString::SchedulingTimeSpanSeconds, 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 { fn unit(self) -> TimespanUnit {
self.unit self.unit
} }
@ -173,16 +184,18 @@ mod test {
fn answer_buttons() { fn answer_buttons() {
let i18n = I18n::new(&["zz"], ""); let i18n = I18n::new(&["zz"], "");
assert_eq!(answer_button_time(30.0, &i18n), "30s"); 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"); assert_eq!(answer_button_time(1.1 * MONTH, &i18n), "1.1mo");
} }
#[test] #[test]
fn time_spans() { fn time_spans() {
let i18n = I18n::new(&["zz"], ""); let i18n = I18n::new(&["zz"], "");
assert_eq!(time_span(1.0, &i18n), "1 second"); assert_eq!(time_span(1.0, &i18n, false), "1 second");
assert_eq!(time_span(30.0, &i18n), "30 seconds"); assert_eq!(time_span(30.3, &i18n, false), "30 seconds");
assert_eq!(time_span(90.0, &i18n), "1.5 minutes"); 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] #[test]