rework Fluent handling
- all .ftl files for a language are concatenated into a single file at build time - all languages are included in the binary - external ftl files placed in the ftl folder can override the built-in definitions - constants are automatically generated for each string key - dropped the separate StringsGroup enum
This commit is contained in:
parent
05d7852910
commit
4430c67069
|
@ -21,6 +21,7 @@ To start, make sure you have the following installed:
|
|||
- gettext
|
||||
- rename
|
||||
- rsync
|
||||
- perl
|
||||
|
||||
The build scripts assume a UNIX-like environment, so on Windows you will
|
||||
need to use WSL or Cygwin to use them.
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
fluent.proto
|
|
@ -1,5 +1,7 @@
|
|||
syntax = "proto3";
|
||||
|
||||
import "fluent.proto";
|
||||
|
||||
package backend_proto;
|
||||
|
||||
message Empty {}
|
||||
|
@ -12,19 +14,6 @@ message BackendInit {
|
|||
string locale_folder_path = 5;
|
||||
}
|
||||
|
||||
enum StringsGroup {
|
||||
OTHER = 0;
|
||||
TEST = 1;
|
||||
MEDIA_CHECK = 2;
|
||||
CARD_TEMPLATES = 3;
|
||||
SYNC = 4;
|
||||
NETWORK = 5;
|
||||
STATISTICS = 6;
|
||||
FILTERING = 7;
|
||||
SCHEDULING = 8;
|
||||
DECK_CONFIG = 9;
|
||||
}
|
||||
|
||||
// 1-15 reserved for future use; 2047 for errors
|
||||
|
||||
message BackendInput {
|
||||
|
@ -299,8 +288,7 @@ message TrashMediaFilesIn {
|
|||
}
|
||||
|
||||
message TranslateStringIn {
|
||||
StringsGroup group = 1;
|
||||
string key = 2;
|
||||
FluentString key = 2;
|
||||
map<string,TranslateArgValue> args = 3;
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
__pycache__
|
||||
anki.egg-info
|
||||
anki/backend_pb2.*
|
||||
anki/fluent_pb2.*
|
||||
anki/buildhash.py
|
||||
build
|
||||
dist
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
[settings]
|
||||
skip=aqt/forms,anki/backend_pb2.py,backend_pb2.pyi
|
||||
skip=aqt/forms,backend_pb2.py,backend_pb2.pyi,fluent_pb2.py,fluent_pb2.pyi
|
||||
multi_line_output=3
|
||||
include_trailing_comma=True
|
||||
force_grid_wrap=0
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
[MASTER]
|
||||
ignore-patterns=.*_pb2.*
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=C,R,
|
||||
fixme,
|
||||
|
|
|
@ -5,7 +5,7 @@ MAKEFLAGS += --warn-undefined-variables
|
|||
MAKEFLAGS += --no-builtin-rules
|
||||
RUNARGS :=
|
||||
.SUFFIXES:
|
||||
BLACKARGS := -t py36 anki tests setup.py tools/*.py --exclude='backend_pb2|buildinfo'
|
||||
BLACKARGS := -t py36 anki tests setup.py tools/*.py --exclude='_pb2|buildinfo'
|
||||
ISORTARGS := anki tests setup.py
|
||||
|
||||
$(shell mkdir -p .build ../dist)
|
||||
|
@ -25,6 +25,9 @@ PROTODEPS := $(wildcard ../proto/*.proto)
|
|||
|
||||
.build/py-proto: .build/dev-deps $(PROTODEPS)
|
||||
protoc --proto_path=../proto --python_out=anki --mypy_out=anki $(PROTODEPS)
|
||||
# fixup import path
|
||||
perl -i'' -pe 's/from fluent_pb2/from anki.fluent_pb2/' anki/backend_pb2.pyi
|
||||
perl -i'' -pe 's/import fluent_pb2/import anki.fluent_pb2/' anki/backend_pb2.py
|
||||
@touch $@
|
||||
|
||||
.build/hooks: tools/genhooks.py tools/hookslib.py
|
||||
|
|
|
@ -12,6 +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.models import AllTemplateReqs
|
||||
from anki.sound import AVTag, SoundOrVideoTag, TTSTag
|
||||
from anki.types import assert_impossible_literal
|
||||
|
@ -132,8 +133,6 @@ MediaSyncProgress = pb.MediaSyncProgress
|
|||
|
||||
MediaCheckOutput = pb.MediaCheckOut
|
||||
|
||||
StringsGroup = pb.StringsGroup
|
||||
|
||||
FormatTimeSpanContext = pb.FormatTimeSpanIn.Context
|
||||
|
||||
|
||||
|
@ -329,9 +328,7 @@ class RustBackend:
|
|||
pb.BackendInput(trash_media_files=pb.TrashMediaFilesIn(fnames=fnames))
|
||||
)
|
||||
|
||||
def translate(
|
||||
self, group: pb.StringsGroup, key: str, **kwargs: Union[str, int, float]
|
||||
):
|
||||
def translate(self, key: FString, **kwargs: Union[str, int, float]):
|
||||
args = {}
|
||||
for (k, v) in kwargs.items():
|
||||
if isinstance(v, str):
|
||||
|
@ -340,9 +337,7 @@ class RustBackend:
|
|||
args[k] = pb.TranslateArgValue(number=v)
|
||||
|
||||
return self._run_command(
|
||||
pb.BackendInput(
|
||||
translate_string=pb.TranslateStringIn(group=group, key=key, args=args)
|
||||
)
|
||||
pb.BackendInput(translate_string=pb.TranslateStringIn(key=key, args=args))
|
||||
).translate_string
|
||||
|
||||
def format_time_span(
|
||||
|
|
|
@ -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 StringsGroup
|
||||
from anki.rsbackend import FString
|
||||
from anki.utils import fmtTimeSpan, ids2str
|
||||
|
||||
# Card stats
|
||||
|
@ -48,8 +48,7 @@ class CardStats:
|
|||
next = self.date(next)
|
||||
if next:
|
||||
self.addLine(
|
||||
self.col.backend.translate(StringsGroup.STATISTICS, "due-date"),
|
||||
next,
|
||||
self.col.backend.translate(FString.STATISTICS_DUE_DATE), next,
|
||||
)
|
||||
if c.queue == QUEUE_TYPE_REV:
|
||||
self.addLine(
|
||||
|
@ -279,7 +278,7 @@ from revlog where id > ? """
|
|||
self._line(
|
||||
i,
|
||||
_("Total"),
|
||||
self.col.backend.translate(StringsGroup.STATISTICS, "reviews", reviews=tot),
|
||||
self.col.backend.translate(FString.STATISTICS_REVIEWS, reviews=tot),
|
||||
)
|
||||
self._line(i, _("Average"), self._avgDay(tot, num, _("reviews")))
|
||||
tomorrow = self.col.db.scalar(
|
||||
|
@ -457,8 +456,7 @@ group by day order by day"""
|
|||
i,
|
||||
_("Average answer time"),
|
||||
self.col.backend.translate(
|
||||
StringsGroup.STATISTICS,
|
||||
"average-answer-time",
|
||||
FString.STATISTICS_AVERAGE_ANSWER_TIME,
|
||||
**{"cards-per-minute": perMin, "average-seconds": average_secs},
|
||||
),
|
||||
)
|
||||
|
|
|
@ -5,7 +5,7 @@ import tempfile
|
|||
|
||||
from anki import Collection as aopen
|
||||
from anki.lang import without_unicode_isolation
|
||||
from anki.rsbackend import StringsGroup
|
||||
from anki.rsbackend import FString
|
||||
from anki.stdmodels import addBasicModel, models
|
||||
from anki.utils import isWin
|
||||
from tests.shared import assertException, getEmptyCol
|
||||
|
@ -156,7 +156,9 @@ def test_translate():
|
|||
tr = d.backend.translate
|
||||
no_uni = without_unicode_isolation
|
||||
|
||||
assert tr(StringsGroup.TEST, "valid-key") == "a valid key"
|
||||
assert "invalid-key" in tr(StringsGroup.TEST, "invalid-key")
|
||||
assert no_uni(tr(StringsGroup.TEST, "plural", hats=1)) == "You have 1 hat."
|
||||
assert no_uni(tr(StringsGroup.TEST, "plural", hats=2)) == "You have 2 hats."
|
||||
assert (
|
||||
tr(FString.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"
|
||||
|
|
|
@ -25,8 +25,8 @@ all: check
|
|||
./tools/build_ui.sh
|
||||
@touch $@
|
||||
|
||||
.build/i18n: $(wildcard i18n/po/desktop/*/anki.po) $(wildcard i18n/ftl/core/*/*.ftl)
|
||||
(cd i18n && ./pull-git && ./build-mo-files && ./copy-qt-files && ./copy-ftl-files)
|
||||
.build/i18n: $(wildcard i18n/po/desktop/*/anki.po)
|
||||
(cd i18n && ./pull-git && ./build-mo-files && ./copy-qt-files)
|
||||
@touch $@
|
||||
|
||||
TSDEPS := $(wildcard ts/src/*.ts) $(wildcard ts/scss/*.scss)
|
||||
|
|
|
@ -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 StringsGroup
|
||||
from anki.rsbackend import FString
|
||||
from anki.utils import fmtTimeSpan, 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(StringsGroup.STATISTICS, "due-for-new-card", number=c.due)
|
||||
return tr(FString.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(StringsGroup.STATISTICS, "due-date")),
|
||||
("cardDue", tr(FString.STATISTICS_DUE_DATE)),
|
||||
("cardIvl", _("Interval")),
|
||||
("cardEase", _("Ease")),
|
||||
("cardReps", _("Reviews")),
|
||||
|
@ -1272,7 +1272,7 @@ by clicking on one on the left."""
|
|||
(_("New"), "is:new"),
|
||||
(_("Learning"), "is:learn"),
|
||||
(_("Review"), "is:review"),
|
||||
(tr(StringsGroup.FILTERING, "is-due"), "is:due"),
|
||||
(tr(FString.FILTERING_IS_DUE), "is:due"),
|
||||
None,
|
||||
(_("Suspended"), "is:suspended"),
|
||||
(_("Buried"), "is:buried"),
|
||||
|
|
|
@ -11,7 +11,7 @@ from typing import Any
|
|||
import aqt
|
||||
from anki.errors import DeckRenameError
|
||||
from anki.lang import _, ngettext
|
||||
from anki.rsbackend import StringsGroup
|
||||
from anki.rsbackend import FString
|
||||
from anki.utils import ids2str
|
||||
from aqt import AnkiQt, gui_hooks
|
||||
from aqt.qt import *
|
||||
|
@ -185,7 +185,7 @@ where id > ?""",
|
|||
<tr><th colspan=5 align=left>%s</th><th class=count>%s</th>
|
||||
<th class=count>%s</th><th class=optscol></th></tr>""" % (
|
||||
_("Deck"),
|
||||
tr(StringsGroup.STATISTICS, "due-count"),
|
||||
tr(FString.STATISTICS_DUE_COUNT),
|
||||
_("New"),
|
||||
)
|
||||
buf += self._topLevelDragRow()
|
||||
|
|
|
@ -11,11 +11,11 @@ from typing import Iterable, List, Optional, TypeVar
|
|||
import aqt
|
||||
from anki import hooks
|
||||
from anki.rsbackend import (
|
||||
FString,
|
||||
Interrupted,
|
||||
MediaCheckOutput,
|
||||
Progress,
|
||||
ProgressKind,
|
||||
StringsGroup,
|
||||
)
|
||||
from aqt.qt import *
|
||||
from aqt.utils import askUser, restoreGeom, saveGeom, showText, tooltip, tr
|
||||
|
@ -89,14 +89,14 @@ class MediaChecker:
|
|||
layout.addWidget(box)
|
||||
|
||||
if output.unused:
|
||||
b = QPushButton(tr(StringsGroup.MEDIA_CHECK, "delete-unused"))
|
||||
b = QPushButton(tr(FString.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(StringsGroup.MEDIA_CHECK, "render-latex"))
|
||||
b = QPushButton(tr(FString.MEDIA_CHECK_RENDER_LATEX))
|
||||
b.setAutoDefault(False)
|
||||
box.addButton(b, QDialogButtonBox.RejectRole)
|
||||
b.clicked.connect(self._on_render_latex) # type: ignore
|
||||
|
@ -125,17 +125,17 @@ class MediaChecker:
|
|||
browser.onSearchActivated()
|
||||
showText(err, type="html")
|
||||
else:
|
||||
tooltip(tr(StringsGroup.MEDIA_CHECK, "all-latex-rendered"))
|
||||
tooltip(tr(FString.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(StringsGroup.MEDIA_CHECK, "checked", count=count))
|
||||
self.mw.progress.update(tr(FString.MEDIA_CHECK_CHECKED, count=count))
|
||||
return True
|
||||
|
||||
def _on_trash_files(self, fnames: List[str]):
|
||||
if not askUser(tr(StringsGroup.MEDIA_CHECK, "delete-unused-confirm")):
|
||||
if not askUser(tr(FString.MEDIA_CHECK_DELETE_UNUSED_CONFIRM)):
|
||||
return
|
||||
|
||||
self.progress_dialog = self.mw.progress.start()
|
||||
|
@ -149,10 +149,10 @@ class MediaChecker:
|
|||
remaining -= len(chunk)
|
||||
if time.time() - last_progress >= 0.3:
|
||||
self.mw.progress.update(
|
||||
tr(StringsGroup.MEDIA_CHECK, "files-remaining", count=remaining)
|
||||
tr(FString.MEDIA_CHECK_FILES_REMAINING, count=remaining)
|
||||
)
|
||||
finally:
|
||||
self.mw.progress.finish()
|
||||
self.progress_dialog = None
|
||||
|
||||
tooltip(tr(StringsGroup.MEDIA_CHECK, "delete-unused-complete", count=total))
|
||||
tooltip(tr(FString.MEDIA_CHECK_DELETE_UNUSED_COMPLETE, count=total))
|
||||
|
|
|
@ -11,12 +11,12 @@ from typing import List, Union
|
|||
import aqt
|
||||
from anki import hooks
|
||||
from anki.rsbackend import (
|
||||
FString,
|
||||
Interrupted,
|
||||
MediaSyncProgress,
|
||||
NetworkError,
|
||||
Progress,
|
||||
ProgressKind,
|
||||
StringsGroup,
|
||||
SyncError,
|
||||
)
|
||||
from anki.types import assert_impossible
|
||||
|
@ -65,10 +65,10 @@ class MediaSyncer:
|
|||
return
|
||||
|
||||
if not self.mw.pm.media_syncing_enabled():
|
||||
self._log_and_notify(tr(StringsGroup.SYNC, "media-disabled"))
|
||||
self._log_and_notify(tr(FString.SYNC_MEDIA_DISABLED))
|
||||
return
|
||||
|
||||
self._log_and_notify(tr(StringsGroup.SYNC, "media-starting"))
|
||||
self._log_and_notify(tr(FString.SYNC_MEDIA_STARTING))
|
||||
self._syncing = True
|
||||
self._want_stop = False
|
||||
gui_hooks.media_sync_did_start_or_stop(True)
|
||||
|
@ -101,19 +101,19 @@ class MediaSyncer:
|
|||
if exc is not None:
|
||||
self._handle_sync_error(exc)
|
||||
else:
|
||||
self._log_and_notify(tr(StringsGroup.SYNC, "media-complete"))
|
||||
self._log_and_notify(tr(FString.SYNC_MEDIA_COMPLETE))
|
||||
|
||||
def _handle_sync_error(self, exc: BaseException):
|
||||
if isinstance(exc, Interrupted):
|
||||
self._log_and_notify(tr(StringsGroup.SYNC, "media-aborted"))
|
||||
self._log_and_notify(tr(FString.SYNC_MEDIA_ABORTED))
|
||||
return
|
||||
|
||||
self._log_and_notify(tr(StringsGroup.SYNC, "media-failed"))
|
||||
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(StringsGroup.NETWORK, "details", details=str(exc))
|
||||
msg += "\n\n" + tr(FString.NETWORK_DETAILS, details=str(exc))
|
||||
else:
|
||||
raise exc
|
||||
|
||||
|
@ -123,7 +123,7 @@ class MediaSyncer:
|
|||
def abort(self) -> None:
|
||||
if not self.is_syncing():
|
||||
return
|
||||
self._log_and_notify(tr(StringsGroup.SYNC, "media-aborting"))
|
||||
self._log_and_notify(tr(FString.SYNC_MEDIA_ABORTING))
|
||||
self._want_stop = True
|
||||
|
||||
def is_syncing(self) -> bool:
|
||||
|
@ -166,7 +166,7 @@ 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(StringsGroup.SYNC, "abort"))
|
||||
self.abort_button = QPushButton(tr(FString.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)
|
||||
|
|
|
@ -12,7 +12,7 @@ from typing import Any, Optional, Union
|
|||
|
||||
import aqt
|
||||
from anki.lang import _
|
||||
from anki.rsbackend import StringsGroup
|
||||
from anki.rsbackend import FString
|
||||
from anki.utils import invalidFilename, isMac, isWin, noBundledLibs, versionWithBuild
|
||||
from aqt.qt import *
|
||||
from aqt.theme import theme_manager
|
||||
|
@ -31,13 +31,13 @@ def locale_dir() -> str:
|
|||
return os.path.join(aqt_data_folder(), "locale")
|
||||
|
||||
|
||||
def tr(group: StringsGroup, key: str, **kwargs: Union[str, int, float]) -> str:
|
||||
def tr(key: FString, **kwargs: Union[str, int, float]) -> str:
|
||||
"""Shortcut to access translations from the backend.
|
||||
(Currently) requires an open collection."""
|
||||
if aqt.mw.col:
|
||||
return aqt.mw.col.backend.translate(group, key, **kwargs)
|
||||
return aqt.mw.col.backend.translate(key, **kwargs)
|
||||
else:
|
||||
return key
|
||||
return repr(key)
|
||||
|
||||
|
||||
def openHelp(section):
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
targetDir=../aqt_data/locale/fluent
|
||||
test -d $targetDir || mkdir -p $targetDir
|
||||
rsync -a --delete --exclude=templates ftl/core/* $targetDir/
|
|
@ -4,13 +4,8 @@ if [ ! -d po ]; then
|
|||
git clone https://github.com/ankitects/anki-desktop-i18n po
|
||||
fi
|
||||
|
||||
if [ ! -d ftl ]; then
|
||||
git clone https://github.com/ankitects/anki-core-i18n ftl
|
||||
fi
|
||||
|
||||
echo "Updating translations from git..."
|
||||
(cd po && git pull)
|
||||
(cd ftl && git pull)
|
||||
|
||||
# make sure gettext translations haven't broken something
|
||||
python check-po-files.py
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# pull any pending changes from git repos
|
||||
./pull-git
|
||||
|
||||
# upload changes to ftl templates
|
||||
./update-ftl-templates
|
||||
(cd ftl && git add core; git commit -m update; git push)
|
|
@ -1,3 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
rsync -a --delete ../../rslib/src/i18n/*.ftl ftl/core/templates/
|
|
@ -49,4 +49,5 @@ reqwest = { version = "0.10.1", features = ["json"] }
|
|||
|
||||
[build-dependencies]
|
||||
prost-build = "0.5.0"
|
||||
fluent-syntax = "0.9.2"
|
||||
|
||||
|
|
|
@ -18,11 +18,12 @@ fix:
|
|||
clean:
|
||||
rm -rf .build target
|
||||
|
||||
develop: .build/vernum
|
||||
develop: .build/vernum ftl/repo
|
||||
|
||||
PROTO_SOURCE := $(wildcard ../proto/*.proto)
|
||||
RS_SOURCE := $(wildcard src/*)
|
||||
ALL_SOURCE := $(RS_SOURCE) $(PROTO_SOURCE)
|
||||
ftl/repo:
|
||||
(cd ftl && ./scripts/fetch-latest-translations)
|
||||
|
||||
ALL_SOURCE := $(find src -type f) $(wildcard ftl/*.ftl)
|
||||
|
||||
# nightly currently required for ignoring files in rustfmt.toml
|
||||
RUST_TOOLCHAIN := $(shell cat rust-toolchain)
|
||||
|
|
148
rslib/build.rs
148
rslib/build.rs
|
@ -1,8 +1,152 @@
|
|||
use prost_build;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
// avoid default OUT_DIR for now, for code completion
|
||||
use fluent_syntax::ast::{Entry::Message, ResourceEntry};
|
||||
use fluent_syntax::parser::parse;
|
||||
|
||||
fn get_identifiers(ftl_text: &str) -> Vec<String> {
|
||||
let res = parse(ftl_text).unwrap();
|
||||
let mut idents = vec![];
|
||||
|
||||
for entry in res.body {
|
||||
if let ResourceEntry::Entry(Message(m)) = entry {
|
||||
idents.push(m.id.name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
idents
|
||||
}
|
||||
|
||||
fn proto_enum(idents: &[String]) -> String {
|
||||
let mut buf = String::from(
|
||||
r#"
|
||||
syntax = "proto3";
|
||||
package backend_proto;
|
||||
enum FluentString {
|
||||
"#,
|
||||
);
|
||||
for (idx, s) in idents.iter().enumerate() {
|
||||
let name = s.replace("-", "_").to_uppercase();
|
||||
buf += &format!(" {} = {};\n", name, idx);
|
||||
}
|
||||
|
||||
buf += "}\n";
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
fn rust_string_vec(idents: &[String]) -> String {
|
||||
let mut buf = String::from(
|
||||
r#"// This file is automatically generated as part of the build process.
|
||||
|
||||
pub(super) const FLUENT_KEYS: &[&str] = &[
|
||||
"#,
|
||||
);
|
||||
|
||||
for s in idents {
|
||||
buf += &format!(" \"{}\",\n", s);
|
||||
}
|
||||
|
||||
buf += "];\n";
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::i18n::extract_idents::{get_identifiers, proto_enum, rust_string_vec};
|
||||
|
||||
#[test]
|
||||
fn all() {
|
||||
let idents = get_identifiers("key-one = foo\nkey-two = bar");
|
||||
assert_eq!(idents, vec!["key-one", "key-two"]);
|
||||
|
||||
assert_eq!(
|
||||
proto_enum(&idents),
|
||||
r#"
|
||||
syntax = "proto3";
|
||||
package backend_strings;
|
||||
enum FluentString {
|
||||
KEY_ONE = 0;
|
||||
KEY_TWO = 1;
|
||||
}
|
||||
"#
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rust_string_vec(&idents),
|
||||
r#"// This file is automatically generated as part of the build process.
|
||||
|
||||
const FLUENT_KEYS: &[&str] = &[
|
||||
"key-one",
|
||||
"key-two",
|
||||
];
|
||||
"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
// write template.ftl
|
||||
let mut buf = String::new();
|
||||
let mut ftl_template_dirs = vec!["./ftl".to_string()];
|
||||
if let Ok(paths) = std::env::var("FTL_TEMPLATE_DIRS") {
|
||||
ftl_template_dirs.extend(paths.split(",").map(|s| s.to_string()));
|
||||
}
|
||||
for ftl_dir in ftl_template_dirs {
|
||||
let ftl_dir = Path::new(&ftl_dir);
|
||||
for entry in fs::read_dir(ftl_dir)? {
|
||||
let entry = entry?;
|
||||
let fname = entry.file_name().into_string().unwrap();
|
||||
if !fname.ends_with(".ftl") {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
println!("cargo:rerun-if-changed=./ftl/{}", fname);
|
||||
buf += &fs::read_to_string(path)?;
|
||||
}
|
||||
}
|
||||
let combined_ftl = Path::new("src/i18n/ftl/template.ftl");
|
||||
fs::write(combined_ftl, &buf)?;
|
||||
|
||||
// generate code completion for ftl strings
|
||||
let idents = get_identifiers(&buf);
|
||||
let string_proto_path = Path::new("../proto/fluent.proto");
|
||||
fs::write(string_proto_path, proto_enum(&idents))?;
|
||||
let rust_string_path = Path::new("src/i18n/autogen.rs");
|
||||
fs::write(rust_string_path, rust_string_vec(&idents))?;
|
||||
|
||||
// output protobuf generated code
|
||||
// we avoid default OUT_DIR for now, as it breaks code completion
|
||||
std::env::set_var("OUT_DIR", "src");
|
||||
println!("cargo:rerun-if-changed=../proto/backend.proto");
|
||||
prost_build::compile_protos(&["../proto/backend.proto"], &["../proto"]).unwrap();
|
||||
|
||||
// write the other language ftl files
|
||||
// fixme: doesn't currently support extra dirs
|
||||
let mut ftl_lang_dirs = vec!["./ftl/repo/core".to_string()];
|
||||
if let Ok(paths) = std::env::var("FTL_LANG_DIRS") {
|
||||
ftl_lang_dirs.extend(paths.split(",").map(|s| s.to_string()));
|
||||
}
|
||||
for ftl_dir in ftl_lang_dirs {
|
||||
for ftl_dir in fs::read_dir(ftl_dir)? {
|
||||
let ftl_dir = ftl_dir?;
|
||||
if ftl_dir.file_name() == "templates" {
|
||||
continue;
|
||||
}
|
||||
let mut buf = String::new();
|
||||
let lang = ftl_dir.file_name().into_string().unwrap();
|
||||
for entry in fs::read_dir(ftl_dir.path())? {
|
||||
let entry = entry?;
|
||||
let fname = entry.file_name().into_string().unwrap();
|
||||
let path = entry.path();
|
||||
println!("cargo:rerun-if-changed=./ftl/{}", fname);
|
||||
buf += &fs::read_to_string(path)?;
|
||||
}
|
||||
fs::write(format!("src/i18n/ftl/{}.ftl", lang), buf)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
repo
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "Downloading latest translations..."
|
||||
|
||||
if [ ! -d repo ]; then
|
||||
git clone https://github.com/ankitects/anki-core-i18n repo
|
||||
fi
|
||||
|
||||
(cd repo && git pull)
|
|
@ -0,0 +1,10 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# expects to be run from the ftl folder
|
||||
#
|
||||
|
||||
test -d repo || exit 1
|
||||
|
||||
rsync -av --delete *.ftl repo/core/templates/
|
||||
(cd repo && git add core; git commit -m update; git push)
|
||||
|
|
@ -5,7 +5,7 @@ use crate::backend_proto as pb;
|
|||
use crate::backend_proto::backend_input::Value;
|
||||
use crate::backend_proto::{Empty, RenderedTemplateReplacement, SyncMediaIn};
|
||||
use crate::err::{AnkiError, NetworkErrorKind, Result, SyncErrorKind};
|
||||
use crate::i18n::{tr_args, I18n, StringsGroup};
|
||||
use crate::i18n::{tr_args, FString, I18n};
|
||||
use crate::latex::{extract_latex, ExtractedLatex};
|
||||
use crate::media::check::MediaChecker;
|
||||
use crate::media::sync::MediaSyncProgress;
|
||||
|
@ -397,17 +397,18 @@ impl Backend {
|
|||
}
|
||||
|
||||
fn translate_string(&self, input: pb::TranslateStringIn) -> String {
|
||||
let group = match pb::StringsGroup::from_i32(input.group) {
|
||||
Some(group) => group,
|
||||
None => return "".to_string(),
|
||||
let key = match pb::FluentString::from_i32(input.key) {
|
||||
Some(key) => key,
|
||||
None => return "invalid key".to_string(),
|
||||
};
|
||||
|
||||
let map = input
|
||||
.args
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), translate_arg_to_fluent_val(&v)))
|
||||
.collect();
|
||||
|
||||
self.i18n.get(group).trn(&input.key, map)
|
||||
self.i18n.trn(key, map)
|
||||
}
|
||||
|
||||
fn format_time_span(&self, input: pb::FormatTimeSpanIn) -> String {
|
||||
|
@ -468,9 +469,7 @@ fn progress_to_proto_bytes(progress: Progress, i18n: &I18n) -> Vec<u8> {
|
|||
value: Some(match progress {
|
||||
Progress::MediaSync(p) => pb::progress::Value::MediaSync(media_sync_progress(p, i18n)),
|
||||
Progress::MediaCheck(n) => {
|
||||
let s = i18n
|
||||
.get(StringsGroup::MediaCheck)
|
||||
.trn("checked", tr_args!["count"=>n]);
|
||||
let s = i18n.trn(FString::MediaCheckChecked, tr_args!["count"=>n]);
|
||||
pb::progress::Value::MediaCheck(s)
|
||||
}
|
||||
}),
|
||||
|
@ -482,15 +481,14 @@ fn progress_to_proto_bytes(progress: Progress, i18n: &I18n) -> Vec<u8> {
|
|||
}
|
||||
|
||||
fn media_sync_progress(p: &MediaSyncProgress, i18n: &I18n) -> pb::MediaSyncProgress {
|
||||
let cat = i18n.get(StringsGroup::Sync);
|
||||
pb::MediaSyncProgress {
|
||||
checked: cat.trn("media-checked-count", tr_args!["count"=>p.checked]),
|
||||
added: cat.trn(
|
||||
"media-added-count",
|
||||
checked: i18n.trn(FString::SyncMediaCheckedCount, tr_args!["count"=>p.checked]),
|
||||
added: i18n.trn(
|
||||
FString::SyncMediaAddedCount,
|
||||
tr_args!["up"=>p.uploaded_files,"down"=>p.downloaded_files],
|
||||
),
|
||||
removed: cat.trn(
|
||||
"media-removed-count",
|
||||
removed: i18n.trn(
|
||||
FString::SyncMediaRemovedCount,
|
||||
tr_args!["up"=>p.uploaded_deletions,"down"=>p.downloaded_deletions],
|
||||
),
|
||||
}
|
||||
|
|
|
@ -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::{I18n, StringsGroup};
|
||||
use crate::i18n::{FString, I18n};
|
||||
pub use failure::{Error, Fail};
|
||||
use reqwest::StatusCode;
|
||||
use std::io;
|
||||
|
@ -57,29 +57,23 @@ impl AnkiError {
|
|||
|
||||
pub fn localized_description(&self, i18n: &I18n) -> String {
|
||||
match self {
|
||||
AnkiError::SyncError { info, kind } => {
|
||||
let cat = i18n.get(StringsGroup::Sync);
|
||||
match kind {
|
||||
SyncErrorKind::ServerMessage => info.into(),
|
||||
SyncErrorKind::Other => info.into(),
|
||||
SyncErrorKind::Conflict => cat.tr("conflict"),
|
||||
SyncErrorKind::ServerError => cat.tr("server-error"),
|
||||
SyncErrorKind::ClientTooOld => cat.tr("client-too-old"),
|
||||
SyncErrorKind::AuthFailed => cat.tr("wrong-pass"),
|
||||
SyncErrorKind::ResyncRequired => cat.tr("resync-required"),
|
||||
}
|
||||
.into()
|
||||
AnkiError::SyncError { info, kind } => match kind {
|
||||
SyncErrorKind::ServerMessage => info.into(),
|
||||
SyncErrorKind::Other => info.into(),
|
||||
SyncErrorKind::Conflict => i18n.tr(FString::SyncConflict),
|
||||
SyncErrorKind::ServerError => i18n.tr(FString::SyncServerError),
|
||||
SyncErrorKind::ClientTooOld => i18n.tr(FString::SyncClientTooOld),
|
||||
SyncErrorKind::AuthFailed => i18n.tr(FString::SyncWrongPass),
|
||||
SyncErrorKind::ResyncRequired => i18n.tr(FString::SyncResyncRequired),
|
||||
}
|
||||
AnkiError::NetworkError { kind, .. } => {
|
||||
let cat = i18n.get(StringsGroup::Network);
|
||||
match kind {
|
||||
NetworkErrorKind::Offline => cat.tr("offline"),
|
||||
NetworkErrorKind::Timeout => cat.tr("timeout"),
|
||||
NetworkErrorKind::ProxyAuth => cat.tr("proxy-auth"),
|
||||
NetworkErrorKind::Other => cat.tr("other"),
|
||||
}
|
||||
.into()
|
||||
.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),
|
||||
}
|
||||
.into(),
|
||||
_ => "".into(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
autogen.rs
|
|
@ -0,0 +1 @@
|
|||
*.ftl
|
|
@ -1,9 +1,10 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use crate::err::Result;
|
||||
use fluent::{FluentArgs, FluentBundle, FluentResource, FluentValue};
|
||||
use intl_memoizer::IntlLangMemoizer;
|
||||
use log::{error, warn};
|
||||
use log::error;
|
||||
use num_format::Locale;
|
||||
use std::borrow::Cow;
|
||||
use std::fs;
|
||||
|
@ -11,9 +12,13 @@ use std::path::{Path, PathBuf};
|
|||
use std::sync::{Arc, Mutex};
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
pub use crate::backend_proto::StringsGroup;
|
||||
mod autogen;
|
||||
use crate::i18n::autogen::FLUENT_KEYS;
|
||||
|
||||
pub use fluent::fluent_args as tr_args;
|
||||
|
||||
pub use crate::backend_proto::FluentString as FString;
|
||||
|
||||
/// Helper for creating args with &strs
|
||||
#[macro_export]
|
||||
macro_rules! tr_strs {
|
||||
|
@ -27,72 +32,140 @@ macro_rules! tr_strs {
|
|||
}
|
||||
};
|
||||
}
|
||||
use std::collections::HashMap;
|
||||
pub use tr_strs;
|
||||
|
||||
/// The folder containing ftl files for the provided language.
|
||||
/// If a fully qualified folder exists (eg, en_GB), return that.
|
||||
/// Otherwise, try the language alone (eg en).
|
||||
/// If neither folder exists, return None.
|
||||
fn lang_folder(lang: LanguageIdentifier, ftl_folder: &Path) -> Option<PathBuf> {
|
||||
if let Some(region) = lang.region() {
|
||||
let path = ftl_folder.join(format!("{}_{}", lang.language(), region));
|
||||
fn lang_folder(lang: Option<&LanguageIdentifier>, ftl_folder: &Path) -> Option<PathBuf> {
|
||||
if let Some(lang) = lang {
|
||||
if let Some(region) = lang.region() {
|
||||
let path = ftl_folder.join(format!("{}_{}", lang.language(), region));
|
||||
if fs::metadata(&path).is_ok() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
let path = ftl_folder.join(lang.language());
|
||||
if fs::metadata(&path).is_ok() {
|
||||
return Some(path);
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
// fallback folder
|
||||
let path = ftl_folder.join("templates");
|
||||
if fs::metadata(&path).is_ok() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
let path = ftl_folder.join(lang.language());
|
||||
if fs::metadata(&path).is_ok() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the fallback/English resource text for the given group.
|
||||
/// Get the template/English resource text for the given group.
|
||||
/// These are embedded in the binary.
|
||||
fn ftl_fallback_for_group(group: StringsGroup) -> String {
|
||||
match group {
|
||||
StringsGroup::Other => "",
|
||||
StringsGroup::Test => include_str!("../../tests/support/test.ftl"),
|
||||
StringsGroup::MediaCheck => include_str!("media-check.ftl"),
|
||||
StringsGroup::CardTemplates => include_str!("card-template-rendering.ftl"),
|
||||
StringsGroup::Sync => include_str!("sync.ftl"),
|
||||
StringsGroup::Network => include_str!("network.ftl"),
|
||||
StringsGroup::Statistics => include_str!("statistics.ftl"),
|
||||
StringsGroup::Filtering => include_str!("filtering.ftl"),
|
||||
StringsGroup::Scheduling => include_str!("scheduling.ftl"),
|
||||
StringsGroup::DeckConfig => include_str!("deck-config.ftl"),
|
||||
}
|
||||
.to_string()
|
||||
fn ftl_template_text() -> String {
|
||||
include_str!("ftl/template.ftl").to_string()
|
||||
}
|
||||
|
||||
/// Get the resource text for the given group in the given language folder.
|
||||
/// If the file can't be read, returns None.
|
||||
fn localized_ftl_for_group(group: StringsGroup, lang_ftl_folder: &Path) -> Option<String> {
|
||||
let path = lang_ftl_folder.join(match group {
|
||||
StringsGroup::Other => "",
|
||||
StringsGroup::Test => "test.ftl",
|
||||
StringsGroup::MediaCheck => "media-check.ftl",
|
||||
StringsGroup::CardTemplates => "card-template-rendering.ftl",
|
||||
StringsGroup::Sync => "sync.ftl",
|
||||
StringsGroup::Network => "network.ftl",
|
||||
StringsGroup::Statistics => "statistics.ftl",
|
||||
StringsGroup::Filtering => "filtering.ftl",
|
||||
StringsGroup::Scheduling => "scheduling.ftl",
|
||||
StringsGroup::DeckConfig => "deck-config.ftl",
|
||||
});
|
||||
fs::read_to_string(&path)
|
||||
.map_err(|e| {
|
||||
warn!("Unable to read translation file: {:?}: {}", path, e);
|
||||
})
|
||||
.ok()
|
||||
fn ftl_localized_text(lang: &LanguageIdentifier) -> Option<String> {
|
||||
Some(
|
||||
match lang.language() {
|
||||
"en" => {
|
||||
match lang.region() {
|
||||
Some("GB") | Some("AU") => include_str!("ftl/en-GB.ftl"),
|
||||
// use fallback language instead
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
"zh" => match lang.region() {
|
||||
Some("TW") | Some("HK") => include_str!("ftl/zh-TW.ftl"),
|
||||
_ => include_str!("ftl/zh-CN.ftl"),
|
||||
},
|
||||
"pt" => {
|
||||
if let Some("PT") = lang.region() {
|
||||
include_str!("ftl/pt-PT.ftl")
|
||||
} else {
|
||||
include_str!("ftl/pt-BR.ftl")
|
||||
}
|
||||
}
|
||||
"ga" => include_str!("ftl/ga-IE.ftl"),
|
||||
"hy" => include_str!("ftl/hy-AM.ftl"),
|
||||
"nb" => include_str!("ftl/nb-NO.ftl"),
|
||||
"sv" => include_str!("ftl/sv-SE.ftl"),
|
||||
"jbo" => include_str!("ftl/jbo.ftl"),
|
||||
"kab" => include_str!("ftl/kab.ftl"),
|
||||
"af" => include_str!("ftl/af.ftl"),
|
||||
"ar" => include_str!("ftl/ar.ftl"),
|
||||
"bg" => include_str!("ftl/bg.ftl"),
|
||||
"ca" => include_str!("ftl/ca.ftl"),
|
||||
"cs" => include_str!("ftl/cs.ftl"),
|
||||
"da" => include_str!("ftl/da.ftl"),
|
||||
"de" => include_str!("ftl/de.ftl"),
|
||||
"el" => include_str!("ftl/el.ftl"),
|
||||
"eo" => include_str!("ftl/eo.ftl"),
|
||||
"es" => include_str!("ftl/es.ftl"),
|
||||
"et" => include_str!("ftl/et.ftl"),
|
||||
"eu" => include_str!("ftl/eu.ftl"),
|
||||
"fa" => include_str!("ftl/fa.ftl"),
|
||||
"fi" => include_str!("ftl/fi.ftl"),
|
||||
"fr" => include_str!("ftl/fr.ftl"),
|
||||
"gl" => include_str!("ftl/gl.ftl"),
|
||||
"he" => include_str!("ftl/he.ftl"),
|
||||
"hr" => include_str!("ftl/hr.ftl"),
|
||||
"hu" => include_str!("ftl/hu.ftl"),
|
||||
"it" => include_str!("ftl/it.ftl"),
|
||||
"ja" => include_str!("ftl/ja.ftl"),
|
||||
"ko" => include_str!("ftl/ko.ftl"),
|
||||
"la" => include_str!("ftl/la.ftl"),
|
||||
"mn" => include_str!("ftl/mn.ftl"),
|
||||
"mr" => include_str!("ftl/mr.ftl"),
|
||||
"ms" => include_str!("ftl/ms.ftl"),
|
||||
"nl" => include_str!("ftl/nl.ftl"),
|
||||
"oc" => include_str!("ftl/oc.ftl"),
|
||||
"pl" => include_str!("ftl/pl.ftl"),
|
||||
"ro" => include_str!("ftl/ro.ftl"),
|
||||
"ru" => include_str!("ftl/ru.ftl"),
|
||||
"sk" => include_str!("ftl/sk.ftl"),
|
||||
"sl" => include_str!("ftl/sl.ftl"),
|
||||
"sr" => include_str!("ftl/sr.ftl"),
|
||||
"th" => include_str!("ftl/th.ftl"),
|
||||
"tr" => include_str!("ftl/tr.ftl"),
|
||||
"uk" => include_str!("ftl/uk.ftl"),
|
||||
"vi" => include_str!("ftl/vi.ftl"),
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the text from any .ftl files in the given folder.
|
||||
fn ftl_external_text(folder: &Path) -> Result<String> {
|
||||
let mut buf = String::new();
|
||||
for entry in fs::read_dir(folder)? {
|
||||
let entry = entry?;
|
||||
let fname = entry
|
||||
.file_name()
|
||||
.into_string()
|
||||
.unwrap_or_else(|_| "".into());
|
||||
if !fname.ends_with(".ftl") {
|
||||
continue;
|
||||
}
|
||||
buf += &fs::read_to_string(entry.path())?
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Parse resource text into an AST for inclusion in a bundle.
|
||||
/// Returns None if the text contains errors.
|
||||
/// Returns None if text contains errors.
|
||||
/// extra_text may contain resources loaded from the filesystem
|
||||
/// at runtime. If it contains errors, they will not prevent a
|
||||
/// bundle from being returned.
|
||||
fn get_bundle(
|
||||
text: String,
|
||||
extra_text: String,
|
||||
locales: &[LanguageIdentifier],
|
||||
) -> Option<FluentBundle<FluentResource>> {
|
||||
let res = FluentResource::try_new(text)
|
||||
|
@ -109,9 +182,46 @@ fn get_bundle(
|
|||
})
|
||||
.ok()?;
|
||||
|
||||
if !extra_text.is_empty() {
|
||||
match FluentResource::try_new(extra_text) {
|
||||
Ok(res) => bundle.add_resource_overriding(res),
|
||||
Err((_res, e)) => error!("Unable to parse translations file: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// disable isolation characters in test mode
|
||||
if cfg!(test) {
|
||||
bundle.set_use_isolating(false);
|
||||
}
|
||||
|
||||
// add numeric formatter
|
||||
set_bundle_formatter_for_langs(&mut bundle, locales);
|
||||
|
||||
Some(bundle)
|
||||
}
|
||||
|
||||
/// Get a bundle that includes any filesystem overrides.
|
||||
fn get_bundle_with_extra(
|
||||
text: String,
|
||||
lang: Option<&LanguageIdentifier>,
|
||||
ftl_folder: &Path,
|
||||
locales: &[LanguageIdentifier],
|
||||
) -> Option<FluentBundle<FluentResource>> {
|
||||
let extra_text = if let Some(path) = lang_folder(lang, &ftl_folder) {
|
||||
match ftl_external_text(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
error!("Error reading external FTL files: {:?}", e);
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"".into()
|
||||
};
|
||||
|
||||
get_bundle(text, extra_text, locales)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct I18n {
|
||||
inner: Arc<Mutex<I18nInner>>,
|
||||
|
@ -119,120 +229,64 @@ pub struct I18n {
|
|||
|
||||
impl I18n {
|
||||
pub fn new<S: AsRef<str>, P: Into<PathBuf>>(locale_codes: &[S], ftl_folder: P) -> Self {
|
||||
let mut langs = vec![];
|
||||
let mut supported = vec![];
|
||||
let ftl_folder = ftl_folder.into();
|
||||
|
||||
let mut langs = vec![];
|
||||
let mut bundles = Vec::with_capacity(locale_codes.len() + 1);
|
||||
|
||||
for code in locale_codes {
|
||||
if let Ok(lang) = code.as_ref().parse::<LanguageIdentifier>() {
|
||||
let code = code.as_ref();
|
||||
if let Ok(lang) = code.parse::<LanguageIdentifier>() {
|
||||
langs.push(lang.clone());
|
||||
if let Some(path) = lang_folder(lang.clone(), &ftl_folder) {
|
||||
supported.push(path);
|
||||
}
|
||||
// if English was listed, any further preferences are skipped,
|
||||
// as the fallback has 100% coverage, and we need to ensure
|
||||
// it is tried prior to any other langs. But we do keep a file
|
||||
// if one was returned, to allow locale English variants to take
|
||||
// priority over the fallback.
|
||||
if lang.language() == "en" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// add fallback date/time
|
||||
langs.push("en_US".parse().unwrap());
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(I18nInner {
|
||||
langs,
|
||||
available_ftl_folders: supported,
|
||||
cache: Default::default(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, group: StringsGroup) -> Arc<I18nCategory> {
|
||||
self.inner.lock().unwrap().get(group)
|
||||
}
|
||||
}
|
||||
|
||||
struct I18nInner {
|
||||
// all preferred languages of the user, used for determine number format
|
||||
langs: Vec<LanguageIdentifier>,
|
||||
// the available ftl folder subset of the user's preferred languages
|
||||
available_ftl_folders: Vec<PathBuf>,
|
||||
cache: HashMap<StringsGroup, Arc<I18nCategory>>,
|
||||
}
|
||||
|
||||
impl I18nInner {
|
||||
pub fn get(&mut self, group: StringsGroup) -> Arc<I18nCategory> {
|
||||
let langs = &self.langs;
|
||||
let avail = &self.available_ftl_folders;
|
||||
|
||||
self.cache
|
||||
.entry(group)
|
||||
.or_insert_with(|| Arc::new(I18nCategory::new(langs, avail, group)))
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct I18nCategory {
|
||||
// bundles in preferred language order, with fallback English as the
|
||||
// last element
|
||||
bundles: Vec<FluentBundle<FluentResource>>,
|
||||
}
|
||||
|
||||
fn set_bundle_formatter_for_langs<T>(bundle: &mut FluentBundle<T>, langs: &[LanguageIdentifier]) {
|
||||
let num_formatter = NumberFormatter::new(langs);
|
||||
let formatter = move |val: &FluentValue, _intls: &Mutex<IntlLangMemoizer>| -> Option<String> {
|
||||
match val {
|
||||
FluentValue::Number(n) => Some(num_formatter.format(n.value)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
bundle.set_formatter(Some(formatter));
|
||||
}
|
||||
|
||||
impl I18nCategory {
|
||||
pub fn new(langs: &[LanguageIdentifier], preferred: &[PathBuf], group: StringsGroup) -> Self {
|
||||
let mut bundles = Vec::with_capacity(preferred.len() + 1);
|
||||
for ftl_folder in preferred {
|
||||
if let Some(text) = localized_ftl_for_group(group, ftl_folder) {
|
||||
if let Some(mut bundle) = get_bundle(text, langs) {
|
||||
if cfg!(test) {
|
||||
bundle.set_use_isolating(false);
|
||||
}
|
||||
set_bundle_formatter_for_langs(&mut bundle, langs);
|
||||
for lang in &langs {
|
||||
// if the language is bundled in the binary
|
||||
if let Some(text) = ftl_localized_text(lang) {
|
||||
if let Some(bundle) = get_bundle_with_extra(text, Some(lang), &ftl_folder, &langs) {
|
||||
bundles.push(bundle);
|
||||
} else {
|
||||
error!("Failed to create bundle for {:?} {:?}", ftl_folder, group);
|
||||
error!("Failed to create bundle for {:?}", lang.language())
|
||||
}
|
||||
|
||||
// if English was listed, any further preferences are skipped,
|
||||
// as the template has 100% coverage, and we need to ensure
|
||||
// it is tried prior to any other langs. But we do keep a file
|
||||
// if one was returned, to allow locale English variants to take
|
||||
// priority over the template.
|
||||
if lang.language() == "en" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut fallback_bundle = get_bundle(ftl_fallback_for_group(group), langs).unwrap();
|
||||
if cfg!(test) {
|
||||
fallback_bundle.set_use_isolating(false);
|
||||
// add English templates
|
||||
let template_bundle =
|
||||
get_bundle_with_extra(ftl_template_text(), None, &ftl_folder, &langs).unwrap();
|
||||
bundles.push(template_bundle);
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(I18nInner { bundles })),
|
||||
}
|
||||
set_bundle_formatter_for_langs(&mut fallback_bundle, langs);
|
||||
|
||||
bundles.push(fallback_bundle);
|
||||
|
||||
Self { bundles }
|
||||
}
|
||||
|
||||
/// Get translation with zero arguments.
|
||||
pub fn tr(&self, key: &str) -> Cow<str> {
|
||||
pub fn tr(&self, key: FString) -> Cow<str> {
|
||||
let key = FLUENT_KEYS[key as usize];
|
||||
self.tr_(key, None)
|
||||
}
|
||||
|
||||
/// Get translation with one or more arguments.
|
||||
pub fn trn(&self, key: &str, args: FluentArgs) -> String {
|
||||
pub fn trn(&self, key: FString, args: FluentArgs) -> String {
|
||||
let key = FLUENT_KEYS[key as usize];
|
||||
self.tr_(key, Some(args)).into()
|
||||
}
|
||||
|
||||
fn tr_<'a>(&'a self, key: &str, args: Option<FluentArgs>) -> Cow<'a, str> {
|
||||
for bundle in &self.bundles {
|
||||
for bundle in &self.inner.lock().unwrap().bundles {
|
||||
let msg = match bundle.get_message(key) {
|
||||
Some(msg) => msg,
|
||||
// not translated in this bundle
|
||||
|
@ -254,10 +308,29 @@ impl I18nCategory {
|
|||
return out.to_string().into();
|
||||
}
|
||||
|
||||
format!("Missing translation key: {}", key).into()
|
||||
// return the key name if it was missing
|
||||
key.to_string().into()
|
||||
}
|
||||
}
|
||||
|
||||
struct I18nInner {
|
||||
// bundles in preferred language order, with template English as the
|
||||
// last element
|
||||
bundles: Vec<FluentBundle<FluentResource>>,
|
||||
}
|
||||
|
||||
fn set_bundle_formatter_for_langs<T>(bundle: &mut FluentBundle<T>, langs: &[LanguageIdentifier]) {
|
||||
let num_formatter = NumberFormatter::new(langs);
|
||||
let formatter = move |val: &FluentValue, _intls: &Mutex<IntlLangMemoizer>| -> Option<String> {
|
||||
match val {
|
||||
FluentValue::Number(n) => Some(num_formatter.format(n.value)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
bundle.set_formatter(Some(formatter));
|
||||
}
|
||||
|
||||
fn first_available_num_format_locale(langs: &[LanguageIdentifier]) -> Option<Locale> {
|
||||
for lang in langs {
|
||||
if let Some(locale) = num_format_locale(lang) {
|
||||
|
@ -315,8 +388,8 @@ impl NumberFormatter {
|
|||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::i18n::NumberFormatter;
|
||||
use crate::i18n::{tr_args, I18n};
|
||||
use crate::i18n::{NumberFormatter, StringsGroup};
|
||||
use std::path::PathBuf;
|
||||
use unic_langid::langid;
|
||||
|
||||
|
@ -331,56 +404,48 @@ mod test {
|
|||
|
||||
#[test]
|
||||
fn i18n() {
|
||||
// English fallback
|
||||
let i18n = I18n::new(&["zz"], "../../tests/support");
|
||||
let cat = i18n.get(StringsGroup::Test);
|
||||
assert_eq!(cat.tr("valid-key"), "a valid key");
|
||||
assert_eq!(
|
||||
cat.tr("invalid-key"),
|
||||
"Missing translation key: invalid-key"
|
||||
);
|
||||
let mut ftl_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
ftl_dir.push("tests/support/ftl");
|
||||
|
||||
// English template
|
||||
let i18n = I18n::new(&["zz"], &ftl_dir);
|
||||
assert_eq!(i18n.tr_("valid-key", None), "a valid key");
|
||||
assert_eq!(i18n.tr_("invalid-key", None), "invalid-key");
|
||||
|
||||
assert_eq!(
|
||||
cat.trn("two-args-key", tr_args!["one"=>1.1, "two"=>"2"]),
|
||||
i18n.tr_("two-args-key", Some(tr_args!["one"=>1.1, "two"=>"2"])),
|
||||
"two args: 1.10 and 2"
|
||||
);
|
||||
|
||||
// commented out to avoid scary warning during unit tests
|
||||
// assert_eq!(
|
||||
// cat.trn("two-args-key", tr_args!["one"=>"testing error reporting"]),
|
||||
// "two args: testing error reporting and {$two}"
|
||||
// );
|
||||
|
||||
assert_eq!(cat.trn("plural", tr_args!["hats"=>1.0]), "You have 1 hat.");
|
||||
assert_eq!(
|
||||
cat.trn("plural", tr_args!["hats"=>1.1]),
|
||||
i18n.tr_("plural", Some(tr_args!["hats"=>1.0])),
|
||||
"You have 1 hat."
|
||||
);
|
||||
assert_eq!(
|
||||
i18n.tr_("plural", Some(tr_args!["hats"=>1.1])),
|
||||
"You have 1.10 hats."
|
||||
);
|
||||
assert_eq!(cat.trn("plural", tr_args!["hats"=>3]), "You have 3 hats.");
|
||||
|
||||
// Another language
|
||||
let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
d.push("tests/support");
|
||||
let i18n = I18n::new(&["ja_JP"], &d);
|
||||
let cat = i18n.get(StringsGroup::Test);
|
||||
assert_eq!(cat.tr("valid-key"), "キー");
|
||||
assert_eq!(cat.tr("only-in-english"), "not translated");
|
||||
assert_eq!(
|
||||
cat.tr("invalid-key"),
|
||||
"Missing translation key: invalid-key"
|
||||
i18n.tr_("plural", Some(tr_args!["hats"=>3])),
|
||||
"You have 3 hats."
|
||||
);
|
||||
|
||||
// Another language
|
||||
let i18n = I18n::new(&["ja_JP"], &ftl_dir);
|
||||
assert_eq!(i18n.tr_("valid-key", None), "キー");
|
||||
assert_eq!(i18n.tr_("only-in-english", None), "not translated");
|
||||
assert_eq!(i18n.tr_("invalid-key", None), "invalid-key");
|
||||
|
||||
assert_eq!(
|
||||
cat.trn("two-args-key", tr_args!["one"=>1, "two"=>"2"]),
|
||||
i18n.tr_("two-args-key", Some(tr_args!["one"=>1, "two"=>"2"])),
|
||||
"1と2"
|
||||
);
|
||||
|
||||
// Decimal separator
|
||||
let i18n = I18n::new(&["pl-PL"], &d);
|
||||
let cat = i18n.get(StringsGroup::Test);
|
||||
let i18n = I18n::new(&["pl-PL"], &ftl_dir);
|
||||
// falls back on English, but with Polish separators
|
||||
assert_eq!(
|
||||
cat.trn("two-args-key", tr_args!["one"=>1, "two"=>2.07]),
|
||||
i18n.tr_("two-args-key", Some(tr_args!["one"=>1, "two"=>2.07])),
|
||||
"two args: 1 and 2,07"
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use crate::cloze::expand_clozes_to_reveal_latex;
|
||||
use crate::err::{AnkiError, Result};
|
||||
use crate::i18n::{tr_args, tr_strs, I18n, StringsGroup};
|
||||
use crate::i18n::{tr_args, tr_strs, FString, I18n};
|
||||
use crate::latex::extract_latex;
|
||||
use crate::media::col::{
|
||||
for_every_note, get_note_types, mark_collection_modified, open_or_create_collection_db,
|
||||
|
@ -89,35 +89,53 @@ where
|
|||
|
||||
pub fn summarize_output(&self, output: &mut MediaCheckOutput) -> String {
|
||||
let mut buf = String::new();
|
||||
let cat = self.i18n.get(StringsGroup::MediaCheck);
|
||||
let i = &self.i18n;
|
||||
|
||||
// top summary area
|
||||
buf += &cat.trn("missing-count", tr_args!["count"=>output.missing.len()]);
|
||||
buf += &i.trn(
|
||||
FString::MediaCheckMissingCount,
|
||||
tr_args!["count"=>output.missing.len()],
|
||||
);
|
||||
buf.push('\n');
|
||||
|
||||
buf += &cat.trn("unused-count", tr_args!["count"=>output.unused.len()]);
|
||||
buf += &i.trn(
|
||||
FString::MediaCheckUnusedCount,
|
||||
tr_args!["count"=>output.unused.len()],
|
||||
);
|
||||
buf.push('\n');
|
||||
|
||||
if !output.renamed.is_empty() {
|
||||
buf += &cat.trn("renamed-count", tr_args!["count"=>output.renamed.len()]);
|
||||
buf += &i.trn(
|
||||
FString::MediaCheckRenamedCount,
|
||||
tr_args!["count"=>output.renamed.len()],
|
||||
);
|
||||
buf.push('\n');
|
||||
}
|
||||
if !output.oversize.is_empty() {
|
||||
buf += &cat.trn("oversize-count", tr_args!["count"=>output.oversize.len()]);
|
||||
buf += &i.trn(
|
||||
FString::MediaCheckOversizeCount,
|
||||
tr_args!["count"=>output.oversize.len()],
|
||||
);
|
||||
buf.push('\n');
|
||||
}
|
||||
if !output.dirs.is_empty() {
|
||||
buf += &cat.trn("subfolder-count", tr_args!["count"=>output.dirs.len()]);
|
||||
buf += &i.trn(
|
||||
FString::MediaCheckSubfolderCount,
|
||||
tr_args!["count"=>output.dirs.len()],
|
||||
);
|
||||
buf.push('\n');
|
||||
}
|
||||
|
||||
buf.push('\n');
|
||||
|
||||
if !output.renamed.is_empty() {
|
||||
buf += &cat.tr("renamed-header");
|
||||
buf += &i.tr(FString::MediaCheckRenamedHeader);
|
||||
buf.push('\n');
|
||||
for (old, new) in &output.renamed {
|
||||
buf += &cat.trn("renamed-file", tr_strs!["old"=>old,"new"=>new]);
|
||||
buf += &i.trn(
|
||||
FString::MediaCheckRenamedFile,
|
||||
tr_strs!["old"=>old,"new"=>new],
|
||||
);
|
||||
buf.push('\n');
|
||||
}
|
||||
buf.push('\n')
|
||||
|
@ -125,10 +143,10 @@ where
|
|||
|
||||
if !output.oversize.is_empty() {
|
||||
output.oversize.sort();
|
||||
buf += &cat.tr("oversize-header");
|
||||
buf += &i.tr(FString::MediaCheckOversizeHeader);
|
||||
buf.push('\n');
|
||||
for fname in &output.oversize {
|
||||
buf += &cat.trn("oversize-file", tr_strs!["filename"=>fname]);
|
||||
buf += &i.trn(FString::MediaCheckOversizeFile, tr_strs!["filename"=>fname]);
|
||||
buf.push('\n');
|
||||
}
|
||||
buf.push('\n')
|
||||
|
@ -136,10 +154,13 @@ where
|
|||
|
||||
if !output.dirs.is_empty() {
|
||||
output.dirs.sort();
|
||||
buf += &cat.tr("subfolder-header");
|
||||
buf += &i.tr(FString::MediaCheckSubfolderHeader);
|
||||
buf.push('\n');
|
||||
for fname in &output.dirs {
|
||||
buf += &cat.trn("subfolder-file", tr_strs!["filename"=>fname]);
|
||||
buf += &i.trn(
|
||||
FString::MediaCheckSubfolderFile,
|
||||
tr_strs!["filename"=>fname],
|
||||
);
|
||||
buf.push('\n');
|
||||
}
|
||||
buf.push('\n')
|
||||
|
@ -147,10 +168,10 @@ where
|
|||
|
||||
if !output.missing.is_empty() {
|
||||
output.missing.sort();
|
||||
buf += &cat.tr("missing-header");
|
||||
buf += &i.tr(FString::MediaCheckMissingHeader);
|
||||
buf.push('\n');
|
||||
for fname in &output.missing {
|
||||
buf += &cat.trn("missing-file", tr_strs!["filename"=>fname]);
|
||||
buf += &i.trn(FString::MediaCheckMissingFile, tr_strs!["filename"=>fname]);
|
||||
buf.push('\n');
|
||||
}
|
||||
buf.push('\n')
|
||||
|
@ -158,10 +179,10 @@ where
|
|||
|
||||
if !output.unused.is_empty() {
|
||||
output.unused.sort();
|
||||
buf += &cat.tr("unused-header");
|
||||
buf += &i.tr(FString::MediaCheckUnusedHeader);
|
||||
buf.push('\n');
|
||||
for fname in &output.unused {
|
||||
buf += &cat.trn("unused-file", tr_strs!["filename"=>fname]);
|
||||
buf += &i.trn(FString::MediaCheckUnusedFile, tr_strs!["filename"=>fname]);
|
||||
buf.push('\n');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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::{tr_args, I18n, StringsGroup};
|
||||
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 {
|
||||
|
@ -11,10 +11,16 @@ pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String {
|
|||
// we don't show fractional values except for months/years
|
||||
_ => span.as_unit().round(),
|
||||
};
|
||||
let unit = span.unit().as_str();
|
||||
let args = tr_args!["amount" => amount];
|
||||
i18n.get(StringsGroup::Scheduling)
|
||||
.trn(&format!("answer-button-time-{}", unit), args)
|
||||
let key = match span.unit() {
|
||||
TimespanUnit::Seconds => FString::SchedulingAnswerButtonTimeSeconds,
|
||||
TimespanUnit::Minutes => FString::SchedulingAnswerButtonTimeMinutes,
|
||||
TimespanUnit::Hours => FString::SchedulingAnswerButtonTimeHours,
|
||||
TimespanUnit::Days => FString::SchedulingAnswerButtonTimeDays,
|
||||
TimespanUnit::Months => FString::SchedulingAnswerButtonTimeMonths,
|
||||
TimespanUnit::Years => FString::SchedulingAnswerButtonTimeYears,
|
||||
};
|
||||
i18n.trn(key, args)
|
||||
}
|
||||
|
||||
/// Describe the given seconds using the largest appropriate unit
|
||||
|
@ -22,10 +28,16 @@ pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String {
|
|||
pub fn time_span(seconds: f32, i18n: &I18n) -> String {
|
||||
let span = Timespan::from_secs(seconds).natural_span();
|
||||
let amount = span.as_unit();
|
||||
let unit = span.unit().as_str();
|
||||
let args = tr_args!["amount" => amount];
|
||||
i18n.get(StringsGroup::Scheduling)
|
||||
.trn(&format!("time-span-{}", unit), args)
|
||||
let key = match span.unit() {
|
||||
TimespanUnit::Seconds => FString::SchedulingTimeSpanSeconds,
|
||||
TimespanUnit::Minutes => FString::SchedulingTimeSpanMinutes,
|
||||
TimespanUnit::Hours => FString::SchedulingTimeSpanHours,
|
||||
TimespanUnit::Days => FString::SchedulingTimeSpanDays,
|
||||
TimespanUnit::Months => FString::SchedulingTimeSpanMonths,
|
||||
TimespanUnit::Years => FString::SchedulingTimeSpanYears,
|
||||
};
|
||||
i18n.trn(key, args)
|
||||
}
|
||||
|
||||
// fixme: this doesn't belong here
|
||||
|
@ -40,8 +52,7 @@ pub fn studied_today(cards: usize, secs: f32, i18n: &I18n) -> String {
|
|||
};
|
||||
let args = tr_args!["amount" => amount, "unit" => unit,
|
||||
"cards" => cards, "secs-per-card" => secs_per];
|
||||
i18n.get(StringsGroup::Statistics)
|
||||
.trn("studied-today", args)
|
||||
i18n.trn(FString::StatisticsStudiedToday, args)
|
||||
}
|
||||
|
||||
// fixme: this doesn't belong here
|
||||
|
@ -58,10 +69,8 @@ pub fn learning_congrats(remaining: usize, next_due: f32, i18n: &I18n) -> String
|
|||
let remaining_args = tr_args!["remaining" => remaining];
|
||||
format!(
|
||||
"{} {}",
|
||||
i18n.get(StringsGroup::Scheduling)
|
||||
.trn("next-learn-due", next_args),
|
||||
i18n.get(StringsGroup::Scheduling)
|
||||
.trn("learn-remaining", remaining_args)
|
||||
i18n.trn(FString::SchedulingNextLearnDue, next_args),
|
||||
i18n.trn(FString::SchedulingLearnRemaining, remaining_args)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use crate::err::{AnkiError, Result, TemplateError};
|
||||
use crate::i18n::{tr_strs, I18n, I18nCategory, StringsGroup};
|
||||
use crate::i18n::{tr_strs, FString, I18n};
|
||||
use crate::template_filters::apply_filters;
|
||||
use lazy_static::lazy_static;
|
||||
use nom;
|
||||
|
@ -196,14 +196,13 @@ fn parse_inner<'a, I: Iterator<Item = TemplateResult<Token<'a>>>>(
|
|||
}
|
||||
|
||||
fn template_error_to_anki_error(err: TemplateError, q_side: bool, i18n: &I18n) -> AnkiError {
|
||||
let cat = i18n.get(StringsGroup::CardTemplates);
|
||||
let header = cat.tr(if q_side {
|
||||
"front-side-problem"
|
||||
let header = i18n.tr(if q_side {
|
||||
FString::CardTemplateRenderingFrontSideProblem
|
||||
} else {
|
||||
"back-side-problem"
|
||||
FString::CardTemplateRenderingBackSideProblem
|
||||
});
|
||||
let details = localized_template_error(&cat, err);
|
||||
let more_info = cat.tr("more-info");
|
||||
let details = localized_template_error(i18n, err);
|
||||
let more_info = i18n.tr(FString::CardTemplateRenderingMoreInfo);
|
||||
let info = format!(
|
||||
"{}<br>{}<br><a href='{}'>{}</a>",
|
||||
header, details, TEMPLATE_ERROR_LINK, more_info
|
||||
|
@ -212,13 +211,14 @@ fn template_error_to_anki_error(err: TemplateError, q_side: bool, i18n: &I18n) -
|
|||
AnkiError::TemplateError { info }
|
||||
}
|
||||
|
||||
fn localized_template_error(cat: &I18nCategory, err: TemplateError) -> String {
|
||||
fn localized_template_error(i18n: &I18n, err: TemplateError) -> String {
|
||||
match err {
|
||||
TemplateError::NoClosingBrackets(tag) => {
|
||||
cat.trn("no-closing-brackets", tr_strs!("tag"=>tag, "missing"=>"}}"))
|
||||
}
|
||||
TemplateError::ConditionalNotClosed(tag) => cat.trn(
|
||||
"conditional-not-closed",
|
||||
TemplateError::NoClosingBrackets(tag) => i18n.trn(
|
||||
FString::CardTemplateRenderingNoClosingBrackets,
|
||||
tr_strs!("tag"=>tag, "missing"=>"}}"),
|
||||
),
|
||||
TemplateError::ConditionalNotClosed(tag) => i18n.trn(
|
||||
FString::CardTemplateRenderingConditionalNotClosed,
|
||||
tr_strs!("missing"=>format!("{{{{/{}}}}}", tag)),
|
||||
),
|
||||
TemplateError::ConditionalNotOpen {
|
||||
|
@ -226,15 +226,15 @@ fn localized_template_error(cat: &I18nCategory, err: TemplateError) -> String {
|
|||
currently_open,
|
||||
} => {
|
||||
if let Some(open) = currently_open {
|
||||
cat.trn(
|
||||
"wrong-conditional-closed",
|
||||
i18n.trn(
|
||||
FString::CardTemplateRenderingWrongConditionalClosed,
|
||||
tr_strs!(
|
||||
"found"=>format!("{{{{/{}}}}}", closed),
|
||||
"expected"=>format!("{{{{/{}}}}}", open)),
|
||||
)
|
||||
} else {
|
||||
cat.trn(
|
||||
"conditional-not-open",
|
||||
i18n.trn(
|
||||
FString::CardTemplateRenderingConditionalNotOpen,
|
||||
tr_strs!(
|
||||
"found"=>format!("{{{{/{}}}}}", closed),
|
||||
"missing1"=>format!("{{{{#{}}}}}", closed),
|
||||
|
@ -243,8 +243,8 @@ fn localized_template_error(cat: &I18nCategory, err: TemplateError) -> String {
|
|||
)
|
||||
}
|
||||
}
|
||||
TemplateError::FieldNotFound { field, filters } => cat.trn(
|
||||
"no-such-field",
|
||||
TemplateError::FieldNotFound { field, filters } => i18n.trn(
|
||||
FString::CardTemplateRenderingNoSuchField,
|
||||
tr_strs!(
|
||||
"found"=>format!("{{{{{}{}}}}}", filters, field),
|
||||
"field"=>field),
|
||||
|
@ -508,12 +508,11 @@ pub fn render_card(
|
|||
|
||||
// check if the front side was empty
|
||||
if !qtmpl.renders_with_fields(context.nonempty_fields) {
|
||||
let cat = i18n.get(StringsGroup::CardTemplates);
|
||||
let info = format!(
|
||||
"{}<br><a href='{}'>{}</a>",
|
||||
cat.tr("empty-front"),
|
||||
i18n.tr(FString::CardTemplateRenderingEmptyFront),
|
||||
TEMPLATE_BLANK_LINK,
|
||||
cat.tr("more-info")
|
||||
i18n.tr(FString::CardTemplateRenderingMoreInfo)
|
||||
);
|
||||
return Err(AnkiError::TemplateError { info });
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue