diff --git a/.github/scripts/contrib.sh b/.github/scripts/contrib.sh index 438aa7ebf..055e917ae 100755 --- a/.github/scripts/contrib.sh +++ b/.github/scripts/contrib.sh @@ -1,6 +1,6 @@ #!/bin/bash -set -e +set -eo pipefail antispam=", at the domain " diff --git a/Makefile b/Makefile index 2dd4d4bf9..43b9615ce 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ build-qt: .PHONY: clean clean: clean-dist - set -e && \ + set -eo pipefail && \ for dir in $(DEVEL); do \ $(SUBMAKE) -C $$dir clean; \ done @@ -90,7 +90,7 @@ clean-dist: .PHONY: check check: pyenv buildhash - set -e && \ + set -eo pipefail && \ for dir in $(CHECKABLE_RS); do \ $(SUBMAKE) -C $$dir check; \ done; \ @@ -116,3 +116,16 @@ add-buildhash: ver=$$(cat meta/version); \ hash=$$(cat meta/buildhash); \ rename "s/-$${ver}-/-$${ver}+$${hash}-/" dist/*-$$ver-* + + +.PHONY: pull-i18n +pull-i18n: + (cd rslib/ftl && scripts/fetch-latest-translations) + (cd qt/ftl && scripts/fetch-latest-translations) + (cd qt/i18n && ./pull-git) + +.PHONY: push-i18n +push-i18n: pull-i18n + (cd rslib/ftl && scripts/upload-latest-templates) + (cd qt/ftl && scripts/upload-latest-templates) + (cd qt/i18n && ./sync-po-git) diff --git a/README.contributing b/README.contributing index 645d59218..80a2bd88c 100644 --- a/README.contributing +++ b/README.contributing @@ -104,7 +104,7 @@ You can do this automatically by adding the following into .git/hooks/pre-commit or .git/hooks/pre-push and making it executable. #!/bin/bash -set -e +set -eo pipefail make check You may need to adjust the PATH variable so that things like a local install diff --git a/pylib/tests/test_schedv1.py b/pylib/tests/test_schedv1.py index edff07beb..d59264997 100644 --- a/pylib/tests/test_schedv1.py +++ b/pylib/tests/test_schedv1.py @@ -516,7 +516,7 @@ def test_nextIvl(): assert ni(c, 3) == 21600000 # (* 100 2.5 1.3 86400)28080000.0 assert ni(c, 4) == 28080000 - assert without_unicode_isolation(d.sched.nextIvlStr(c, 4)) == "10.83mo" + assert without_unicode_isolation(d.sched.nextIvlStr(c, 4)) == "10.8mo" def test_misc(): diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index 2b8f996ac..0df6c2005 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -613,7 +613,7 @@ def test_nextIvl(): assert ni(c, 3) == 21600000 # (* 100 2.5 1.3 86400)28080000.0 assert ni(c, 4) == 28080000 - assert without_unicode_isolation(d.sched.nextIvlStr(c, 4)) == "10.83mo" + assert without_unicode_isolation(d.sched.nextIvlStr(c, 4)) == "10.8mo" def test_bury(): diff --git a/qt/aqt/deckconf.py b/qt/aqt/deckconf.py index 629ef354f..6527ff22b 100644 --- a/qt/aqt/deckconf.py +++ b/qt/aqt/deckconf.py @@ -6,6 +6,7 @@ from operator import itemgetter import aqt from anki.consts import NEW_CARDS_RANDOM from anki.lang import _, ngettext +from aqt import gui_hooks from aqt.qt import * from aqt.utils import ( askUser, @@ -28,6 +29,7 @@ class DeckConf(QDialog): self._origNewOrder = None self.form = aqt.forms.dconf.Ui_Dialog() self.form.setupUi(self) + gui_hooks.deck_conf_did_setup_ui_form(self) self.mw.checkpoint(_("Options")) self.setupCombos() self.setupConfs() @@ -40,6 +42,7 @@ class DeckConf(QDialog): self.setWindowTitle(_("Options for %s") % self.deck["name"]) # qt doesn't size properly with altered fonts otherwise restoreGeom(self, "deckconf", adjustSize=True) + gui_hooks.deck_conf_will_show(self) self.show() self.exec_() saveGeom(self, "deckconf") @@ -218,6 +221,7 @@ class DeckConf(QDialog): f.replayQuestion.setChecked(c.get("replayq", True)) # description f.desc.setPlainText(self.deck["desc"]) + gui_hooks.deck_conf_did_load_config(self, self.deck, self.conf) def onRestore(self): self.mw.progress.start() @@ -301,6 +305,7 @@ class DeckConf(QDialog): c["replayq"] = f.replayQuestion.isChecked() # description self.deck["desc"] = f.desc.toPlainText() + gui_hooks.deck_conf_will_save_config(self, self.deck, self.conf) self.mw.col.decks.save(self.deck) self.mw.col.decks.save(self.conf) diff --git a/qt/aqt/gui_hooks.py b/qt/aqt/gui_hooks.py index bc6dd98e7..de0ec671d 100644 --- a/qt/aqt/gui_hooks.py +++ b/qt/aqt/gui_hooks.py @@ -518,6 +518,114 @@ class _DeckBrowserWillShowOptionsMenuHook: deck_browser_will_show_options_menu = _DeckBrowserWillShowOptionsMenuHook() +class _DeckConfDidLoadConfigHook: + """Called once widget state has been set from deck config""" + + _hooks: List[Callable[["aqt.deckconf.DeckConf", Any, Any], None]] = [] + + def append(self, cb: Callable[["aqt.deckconf.DeckConf", Any, Any], None]) -> None: + """(deck_conf: aqt.deckconf.DeckConf, deck: Any, config: Any)""" + self._hooks.append(cb) + + def remove(self, cb: Callable[["aqt.deckconf.DeckConf", Any, Any], None]) -> None: + if cb in self._hooks: + self._hooks.remove(cb) + + def __call__( + self, deck_conf: aqt.deckconf.DeckConf, deck: Any, config: Any + ) -> None: + for hook in self._hooks: + try: + hook(deck_conf, deck, config) + except: + # if the hook fails, remove it + self._hooks.remove(hook) + raise + + +deck_conf_did_load_config = _DeckConfDidLoadConfigHook() + + +class _DeckConfDidSetupUiFormHook: + """Allows modifying or adding widgets in the deck options UI form""" + + _hooks: List[Callable[["aqt.deckconf.DeckConf"], None]] = [] + + def append(self, cb: Callable[["aqt.deckconf.DeckConf"], None]) -> None: + """(deck_conf: aqt.deckconf.DeckConf)""" + self._hooks.append(cb) + + def remove(self, cb: Callable[["aqt.deckconf.DeckConf"], None]) -> None: + if cb in self._hooks: + self._hooks.remove(cb) + + def __call__(self, deck_conf: aqt.deckconf.DeckConf) -> None: + for hook in self._hooks: + try: + hook(deck_conf) + except: + # if the hook fails, remove it + self._hooks.remove(hook) + raise + + +deck_conf_did_setup_ui_form = _DeckConfDidSetupUiFormHook() + + +class _DeckConfWillSaveConfigHook: + """Called before widget state is saved to config""" + + _hooks: List[Callable[["aqt.deckconf.DeckConf", Any, Any], None]] = [] + + def append(self, cb: Callable[["aqt.deckconf.DeckConf", Any, Any], None]) -> None: + """(deck_conf: aqt.deckconf.DeckConf, deck: Any, config: Any)""" + self._hooks.append(cb) + + def remove(self, cb: Callable[["aqt.deckconf.DeckConf", Any, Any], None]) -> None: + if cb in self._hooks: + self._hooks.remove(cb) + + def __call__( + self, deck_conf: aqt.deckconf.DeckConf, deck: Any, config: Any + ) -> None: + for hook in self._hooks: + try: + hook(deck_conf, deck, config) + except: + # if the hook fails, remove it + self._hooks.remove(hook) + raise + + +deck_conf_will_save_config = _DeckConfWillSaveConfigHook() + + +class _DeckConfWillShowHook: + """Allows modifying the deck options dialog before it is shown""" + + _hooks: List[Callable[["aqt.deckconf.DeckConf"], None]] = [] + + def append(self, cb: Callable[["aqt.deckconf.DeckConf"], None]) -> None: + """(deck_conf: aqt.deckconf.DeckConf)""" + self._hooks.append(cb) + + def remove(self, cb: Callable[["aqt.deckconf.DeckConf"], None]) -> None: + if cb in self._hooks: + self._hooks.remove(cb) + + def __call__(self, deck_conf: aqt.deckconf.DeckConf) -> None: + for hook in self._hooks: + try: + hook(deck_conf) + except: + # if the hook fails, remove it + self._hooks.remove(hook) + raise + + +deck_conf_will_show = _DeckConfWillShowHook() + + class _EditorDidFireTypingTimerHook: _hooks: List[Callable[["anki.notes.Note"], None]] = [] diff --git a/qt/i18n/build-mo-files b/qt/i18n/build-mo-files index 4666a7e44..8feaf7cfe 100755 --- a/qt/i18n/build-mo-files +++ b/qt/i18n/build-mo-files @@ -2,6 +2,7 @@ # # build mo files # +set -eo pipefail targetDir="../aqt_data/locale/gettext" mkdir -p $targetDir @@ -9,9 +10,9 @@ mkdir -p $targetDir echo "Compiling *.po..." for file in po/desktop/*/anki.po do - outdir=$(echo $file | \ + outdir=$(echo "$file" | \ perl -pe "s%po/desktop/(.*)/anki.po%$targetDir/\1/LC_MESSAGES%") outfile="$outdir/anki.mo" mkdir -p $outdir - msgmerge --for-msgfmt $file po/desktop/anki.pot | msgfmt - --output-file=$outfile + msgmerge -q "$file" po/desktop/anki.pot | msgfmt - --output-file="$outfile" done diff --git a/qt/i18n/copy-qt-files b/qt/i18n/copy-qt-files index 7905cc848..0a8a7eb25 100755 --- a/qt/i18n/copy-qt-files +++ b/qt/i18n/copy-qt-files @@ -1,6 +1,6 @@ #!/bin/bash -set -e +set -eo pipefail out=../aqt_data/locale/qt mkdir -p "$out" diff --git a/qt/i18n/sync-po-git b/qt/i18n/sync-po-git index 55c778d9f..5d5910185 100755 --- a/qt/i18n/sync-po-git +++ b/qt/i18n/sync-po-git @@ -1,4 +1,5 @@ #!/bin/bash +set -eo pipefail # pull any pending changes from git repos ./pull-git diff --git a/qt/i18n/update-po-template b/qt/i18n/update-po-template index 34a223037..a67197e01 100755 --- a/qt/i18n/update-po-template +++ b/qt/i18n/update-po-template @@ -2,7 +2,7 @@ # # update template .pot file from source code strings # - +set -eo pipefail topDir=$(dirname $0)/../.. cd $topDir diff --git a/qt/tools/build_ui.sh b/qt/tools/build_ui.sh index 33deeb4df..80e28d37a 100755 --- a/qt/tools/build_ui.sh +++ b/qt/tools/build_ui.sh @@ -4,7 +4,7 @@ # should be on the path. # -set -e +set -eo pipefail if [ ! -d "designer" ] then diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 17e55ea25..b92d0a969 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -121,6 +121,28 @@ hooks = [ legacy_hook="prepareQA", doc="Can modify card text before review/preview.", ), + # Deck options + ################### + Hook( + name="deck_conf_did_setup_ui_form", + args=["deck_conf: aqt.deckconf.DeckConf"], + doc="Allows modifying or adding widgets in the deck options UI form", + ), + Hook( + name="deck_conf_will_show", + args=["deck_conf: aqt.deckconf.DeckConf"], + doc="Allows modifying the deck options dialog before it is shown", + ), + Hook( + name="deck_conf_did_load_config", + args=["deck_conf: aqt.deckconf.DeckConf", "deck: Any", "config: Any"], + doc="Called once widget state has been set from deck config", + ), + Hook( + name="deck_conf_will_save_config", + args=["deck_conf: aqt.deckconf.DeckConf", "deck: Any", "config: Any"], + doc="Called before widget state is saved to config", + ), # Browser ################### Hook( diff --git a/rslib/Cargo.toml b/rslib/Cargo.toml index dbdf92e51..0b6d23b79 100644 --- a/rslib/Cargo.toml +++ b/rslib/Cargo.toml @@ -31,8 +31,8 @@ coarsetime = "=0.1.11" utime = "0.2.1" serde-aux = "0.6.1" unic-langid = { version = "0.8.0", features = ["macros"] } -fluent = "0.10.2" -intl-memoizer = "0.3.0" +fluent = { git = "https://github.com/ankitects/fluent-rs.git", branch="32bit-panic" } +intl-memoizer = { git = "https://github.com/ankitects/fluent-rs.git", branch="32bit-panic" } num-format = "0.4.0" [target.'cfg(target_vendor="apple")'.dependencies] diff --git a/rslib/build.rs b/rslib/build.rs index 8e4f645e6..bf365568f 100644 --- a/rslib/build.rs +++ b/rslib/build.rs @@ -15,6 +15,8 @@ fn get_identifiers(ftl_text: &str) -> Vec { } } + idents.sort(); + idents } diff --git a/rslib/src/i18n/mod.rs b/rslib/src/i18n/mod.rs index b0f650447..4f6c4626c 100644 --- a/rslib/src/i18n/mod.rs +++ b/rslib/src/i18n/mod.rs @@ -323,7 +323,11 @@ fn set_bundle_formatter_for_langs(bundle: &mut FluentBundle, langs: &[Lang let num_formatter = NumberFormatter::new(langs); let formatter = move |val: &FluentValue, _intls: &Mutex| -> Option { match val { - FluentValue::Number(n) => Some(num_formatter.format(n.value)), + FluentValue::Number(n) => { + let mut num = n.clone(); + num.options.maximum_fraction_digits = Some(2); + Some(num_formatter.format(num.as_string().to_string())) + } _ => None, } }; @@ -371,17 +375,12 @@ impl NumberFormatter { } } - fn format(&self, num: f64) -> String { - // is it an integer? - if (num - num.round()).abs() < std::f64::EPSILON { - num.to_string() + /// Given a pre-formatted number, change the decimal separator as appropriate. + fn format(&self, num: String) -> String { + if self.decimal_separator != "." { + num.replace(".", self.decimal_separator) } else { - // currently we hard-code to 2 decimal places - let mut formatted = format!("{:.2}", num); - if self.decimal_separator != "." { - formatted = formatted.replace(".", self.decimal_separator) - } - formatted + num } } } @@ -395,11 +394,8 @@ mod test { #[test] fn numbers() { - let fmter = NumberFormatter::new(&[]); - assert_eq!(&fmter.format(1.0), "1"); - assert_eq!(&fmter.format(1.007), "1.01"); let fmter = NumberFormatter::new(&[langid!("pl-PL")]); - assert_eq!(&fmter.format(1.007), "1,01"); + assert_eq!(&fmter.format("1.007".to_string()), "1,007"); } #[test] @@ -414,7 +410,7 @@ mod test { assert_eq!( i18n.tr_("two-args-key", Some(tr_args!["one"=>1.1, "two"=>"2"])), - "two args: 1.10 and 2" + "two args: 1.1 and 2" ); assert_eq!( @@ -423,7 +419,7 @@ mod test { ); assert_eq!( i18n.tr_("plural", Some(tr_args!["hats"=>1.1])), - "You have 1.10 hats." + "You have 1.1 hats." ); assert_eq!( i18n.tr_("plural", Some(tr_args!["hats"=>3])), diff --git a/rslib/src/sched/timespan.rs b/rslib/src/sched/timespan.rs index 9d426ee8a..9c496811e 100644 --- a/rslib/src/sched/timespan.rs +++ b/rslib/src/sched/timespan.rs @@ -7,8 +7,9 @@ use crate::i18n::{tr_args, FString, I18n}; pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String { let span = Timespan::from_secs(seconds).natural_span(); let amount = match span.unit() { - TimespanUnit::Months | TimespanUnit::Years => span.as_unit(), - // we don't show fractional values except for months/years + // 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]; @@ -173,7 +174,7 @@ mod test { let i18n = I18n::new(&["zz"], ""); assert_eq!(answer_button_time(30.0, &i18n), "30s"); assert_eq!(answer_button_time(70.0, &i18n), "1m"); - assert_eq!(answer_button_time(1.1 * MONTH, &i18n), "1.10mo"); + assert_eq!(answer_button_time(1.1 * MONTH, &i18n), "1.1mo"); } #[test] @@ -181,7 +182,7 @@ mod test { let i18n = I18n::new(&["zz"], ""); assert_eq!(time_span(1.0, &i18n), "1 second"); assert_eq!(time_span(30.0, &i18n), "30 seconds"); - assert_eq!(time_span(90.0, &i18n), "1.50 minutes"); + assert_eq!(time_span(90.0, &i18n), "1.5 minutes"); } #[test]