From a4a49f41d782f5c76648a2a043a3bb45f885cd35 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 14:54:43 +0300 Subject: [PATCH] feat: ship the interface in eight languages, with per-language plurals and mirroring --- SPEC.md | 3 + .../reader_settings_controller.dart | 15 ++ app/lib/application/internal_actions.dart | 13 ++ app/lib/application/pages/bookmarks_page.dart | 1 + app/lib/application/pages/history_page.dart | 1 + app/lib/application/pages/hub_page.dart | 1 + app/lib/application/pages/page_blocks.dart | 32 +++- app/lib/application/pages/posts_page.dart | 1 + app/lib/application/pages/settings_page.dart | 18 ++ .../application/pages/subscriptions_page.dart | 1 + app/lib/application/pages/tags_page.dart | 2 + app/lib/database/database.dart | 27 ++- app/lib/database/database.g.dart | 73 +++++++- app/lib/l10n/locales.dart | 89 +++++++++ app/lib/l10n/strings.dart | 63 +++++-- app/lib/presentation/app.dart | 34 ++++ app/locales/de/main.ftl | 172 +++++++++++++++++ app/locales/en-US/main.ftl | 15 ++ app/locales/es/main.ftl | 172 +++++++++++++++++ app/locales/fr/main.ftl | 172 +++++++++++++++++ app/locales/he/main.ftl | 177 ++++++++++++++++++ app/locales/ja/main.ftl | 172 +++++++++++++++++ app/locales/pt/main.ftl | 172 +++++++++++++++++ app/locales/ru/main.ftl | 177 ++++++++++++++++++ app/pubspec.lock | 9 +- app/pubspec.yaml | 9 + app/test/app_widget_test.dart | 44 +++++ app/test/locales_test.dart | 162 ++++++++++++++++ 28 files changed, 1795 insertions(+), 32 deletions(-) create mode 100644 app/lib/l10n/locales.dart create mode 100644 app/locales/de/main.ftl create mode 100644 app/locales/es/main.ftl create mode 100644 app/locales/fr/main.ftl create mode 100644 app/locales/he/main.ftl create mode 100644 app/locales/ja/main.ftl create mode 100644 app/locales/pt/main.ftl create mode 100644 app/locales/ru/main.ftl create mode 100644 app/test/locales_test.dart diff --git a/SPEC.md b/SPEC.md index 0059bc0..aa1d361 100644 --- a/SPEC.md +++ b/SPEC.md @@ -73,6 +73,9 @@ The v1 client presents one reader screen: - Each library page carries a search box that narrows it by title or address, and each row carries its own actions: remove a bookmark, leave a site or check it now, forget a page. History lists one row per address, headed by the most recent visit, rather than repeating a page once per visit. Clearing history takes a second step on a page the reader can simply leave, since it cannot be undone. - A change is a link too: following `mews:do/` applies it and rerenders the page in place without adding a history entry. Only a link followed from one of the client's own pages is honored, so a fetched page cannot clear history or change a setting on the reader's behalf (spec 8.1). A form on one of these pages is routed the same way rather than sent to the network. - Our own pages are built from the same blocks as a page but are interface, not an article, so the renderer lays them out differently: wider before they stop growing, denser, rows divided by a line instead of bulleted, section headings quieter, a note under a row set smaller and dimmer, links carried by weight rather than an underline, and a one-field form on a single line. +- The interface ships in English, German, Spanish, French, Hebrew, Japanese, Portuguese and Russian. It follows the device's language by default and the reader can choose another on the settings page, where each language is named in its own words so it can be found from an interface they cannot read. A message a language has not translated falls back to English rather than disappearing. +- Hebrew mirrors the whole interface. Flutter's own widgets follow the same language, and our own pages take their direction from it; a fetched page keeps declaring its own, so an English interface still renders a Hebrew page right to left. +- Plurals are separate messages rather than a rule inside one, and the choice is made in code from the language's own rules: Russian separates two to four from the rest, Hebrew has a form for two, French and Portuguese count zero as singular, and Japanese has one form. A language needing a form English lacks names it with the same id and that suffix. - Fonts are bundled, never fetched. The reader picks between two sans faces, Atkinson Hyperlegible and Luciole, and two serifs, Literata and Charis SIL; all four carry regular, italic, bold and bold italic, which the renderer needs for emphasis. The chosen face applies to the whole app, chrome included, so the address bar and the page below it are one face. - A character the chosen face has no glyph for is looked for in that face's own fallbacks before the platform is asked. Hebrew always comes from a bundled Hebrew face matched to the shape of the chosen one, so it never depends on what the device has installed. The serifs also cover Greek, Cyrillic and a wide Latin range and fall back to each other; the two sans faces are narrow and have no bundled sans to turn to, so for those the platform answers, and it answers a sans with a sans. Scripts nothing bundled covers, such as Arabic and the CJK languages, always come from the platform. - Adding a font is four TTFs in `app/assets/fonts/` with its licence beside them, one `fonts` entry in `app/pubspec.yaml`, one entry in `app/lib/application/fonts.dart`, and one label in `app/locales/en-US/main.ftl`. A family named in the registry but missing from pubspec would render in the platform's font without saying so, so `app/test/fonts_test.dart` checks that the two agree. diff --git a/app/lib/application/controllers/reader_settings_controller.dart b/app/lib/application/controllers/reader_settings_controller.dart index cac0415..1ab97e9 100644 --- a/app/lib/application/controllers/reader_settings_controller.dart +++ b/app/lib/application/controllers/reader_settings_controller.dart @@ -1,6 +1,7 @@ import 'package:signals/signals.dart'; import '../../database/database.dart'; +import '../../l10n/locales.dart'; import '../fonts.dart'; /// The reader's presentation choices (spec 8.2) plus the per-app limits they @@ -25,6 +26,10 @@ class ReaderSettingsController { final themeMode = signal('system'); final variant = signal('site'); + /// The interface language: a shipped locale tag, or [systemLanguage] to take + /// whatever the device asks for. + final language = signal(systemLanguage); + Future load() async { final settings = await _db.readSettings(); if (settings == null) return; @@ -32,6 +37,7 @@ class ReaderSettingsController { fontScale.value = settings.fontScale; themeMode.value = settings.themeMode; variant.value = settings.variant; + language.value = settings.language; } Future setFont(String family) async { @@ -71,10 +77,19 @@ class ReaderSettingsController { return true; } + Future setLanguage(String tag) async { + if (tag != systemLanguage && matchLocale(tag) == null) return false; + if (tag == language.value) return false; + language.value = tag; + await _persist(); + return true; + } + Future _persist() => _db.saveSettings( font: font.value, fontScale: fontScale.value, themeMode: themeMode.value, variant: variant.value, + language: language.value, ); } diff --git a/app/lib/application/internal_actions.dart b/app/lib/application/internal_actions.dart index 539b15d..e227fda 100644 --- a/app/lib/application/internal_actions.dart +++ b/app/lib/application/internal_actions.dart @@ -15,15 +15,18 @@ import 'pages/mews_urls.dart'; class InternalActions { InternalActions({ required MewsDatabase db, + required Future Function() applyLanguage, required ReaderSettingsController settings, required BookmarksController bookmarks, required SubscriptionsController subscriptions, }) : _db = db, + _applyLanguage = applyLanguage, _settings = settings, _bookmarks = bookmarks, _subscriptions = subscriptions; final MewsDatabase _db; + final Future Function() _applyLanguage; final ReaderSettingsController _settings; final BookmarksController _bookmarks; final SubscriptionsController _subscriptions; @@ -37,6 +40,7 @@ class InternalActions { 'set-size' => _setSize(args['step']), 'set-theme' => _settings.setThemeMode(args['value'] ?? ''), 'set-colors' => _settings.setVariant(args['value'] ?? ''), + 'set-language' => _setLanguage(args['value']), 'mark-read' => _markRead(args, read: true), 'mark-unread' => _markRead(args, read: false), 'refresh-subscriptions' => _refreshAll(), @@ -52,6 +56,15 @@ class InternalActions { }; } + /// Changing the language has to load its messages before the page is built + /// again, or the rerender would still read the old ones. + Future _setLanguage(String? tag) async { + if (tag == null || tag.isEmpty) return false; + if (!await _settings.setLanguage(tag)) return false; + await _applyLanguage(); + return true; + } + Future _setSize(String? step) => switch (step) { 'up' => _settings.increaseSize(), 'down' => _settings.decreaseSize(), diff --git a/app/lib/application/pages/bookmarks_page.dart b/app/lib/application/pages/bookmarks_page.dart index 1f727ee..dd741b8 100644 --- a/app/lib/application/pages/bookmarks_page.dart +++ b/app/lib/application/pages/bookmarks_page.dart @@ -20,6 +20,7 @@ Future buildBookmarksPage( return internalPage( url: url, title: title, + strings: strings, body: [ ...chrome(title: title, current: url, strings: strings), textForm( diff --git a/app/lib/application/pages/history_page.dart b/app/lib/application/pages/history_page.dart index 3a1715d..96a72ae 100644 --- a/app/lib/application/pages/history_page.dart +++ b/app/lib/application/pages/history_page.dart @@ -17,6 +17,7 @@ Future buildHistoryPage( return internalPage( url: url, title: title, + strings: strings, body: [ ...chrome(title: title, current: url, strings: strings), textForm( diff --git a/app/lib/application/pages/hub_page.dart b/app/lib/application/pages/hub_page.dart index 846e02f..c5d1317 100644 --- a/app/lib/application/pages/hub_page.dart +++ b/app/lib/application/pages/hub_page.dart @@ -23,6 +23,7 @@ Future buildHubPage( return internalPage( url: url, title: strings.t('hub-title'), + strings: strings, body: [ HeadingBlock(1, [MewsText(strings.t('hub-title'))]), itemList([ diff --git a/app/lib/application/pages/page_blocks.dart b/app/lib/application/pages/page_blocks.dart index 7f395a8..0fe1396 100644 --- a/app/lib/application/pages/page_blocks.dart +++ b/app/lib/application/pages/page_blocks.dart @@ -196,25 +196,35 @@ ListBlock itemList(List items) => /// Wraps blocks as an internal document. The marker makes the renderer treat /// it as a Mews page rather than prepending the "not a Mews page" notice, and /// no entries are offered so an internal page is never subscribable. +/// +/// Unlike a fetched page, which declares its own language, one of ours is +/// written in whichever language the reader set, so it takes its direction +/// from that and mirrors when the interface does. MewsPage internalPage({ required Uri url, required String title, required List body, + required Strings strings, }) { return MewsPage( url: url.toString(), specVersion: 'internal', title: title, body: body, - lang: 'en', - direction: TextDirection.ltr, + lang: strings.tag, + direction: strings.isRtl ? TextDirection.rtl : TextDirection.ltr, variant: MewsVariant.plain, entries: const [], ); } -/// Picks the message for [count]. Plurals are separate messages, so the -/// choice happens here and Fluent formats the number. +/// Picks the message for [count]. Plurals are separate messages rather than a +/// rule inside one, so the choice happens here. +/// +/// A language needing a form English does not have, such as Russian's "few" +/// or Hebrew's "two", names it with the same id and that suffix. Those are +/// derived from [many] rather than passed at every call, and a language +/// without them falls through to [many]. String countText( Strings strings, int count, { @@ -222,11 +232,15 @@ String countText( required String one, required String many, }) { - return switch (count) { - 0 => strings.t(none), - 1 => strings.t(one), - _ => strings.t(many, {'count': count}), - }; + if (count == 0) return strings.t(none); + final suffix = strings.pluralSuffixFor(count); + if (suffix == 'one') return strings.t(one, {'count': count}); + if (suffix != 'many') { + final stem = many.substring(0, many.lastIndexOf('-')); + final text = strings.lookup('$stem-$suffix', {'count': count}); + if (text != null) return text; + } + return strings.t(many, {'count': count}); } /// YYYY-MM-DD, the shape a Mews entry link uses (spec 6.1). diff --git a/app/lib/application/pages/posts_page.dart b/app/lib/application/pages/posts_page.dart index 32b5beb..f0fc628 100644 --- a/app/lib/application/pages/posts_page.dart +++ b/app/lib/application/pages/posts_page.dart @@ -23,6 +23,7 @@ Future buildPostsPage( return internalPage( url: url, title: title, + strings: strings, body: [ ...chrome(title: title, current: url, strings: strings), ParagraphBlock([ diff --git a/app/lib/application/pages/settings_page.dart b/app/lib/application/pages/settings_page.dart index 663ad71..eaa3343 100644 --- a/app/lib/application/pages/settings_page.dart +++ b/app/lib/application/pages/settings_page.dart @@ -1,4 +1,5 @@ import '../../l10n/strings.dart'; +import '../../l10n/locales.dart'; import '../../mews/document.dart'; import '../fonts.dart'; import '../controllers/reader_settings_controller.dart' @@ -33,6 +34,7 @@ Future buildSettingsPage( return internalPage( url: url, title: title, + strings: strings, body: [ ...chrome(title: title, current: url, strings: strings), HeadingBlock(2, [MewsText(strings.t('settings-font-heading'))]), @@ -80,6 +82,22 @@ Future buildSettingsPage( current: mode == settings.themeMode.value, ), ]), + // The language a reader wants may not be the one they can currently + // read, so each is named in its own words. + HeadingBlock(2, [MewsText(strings.t('settings-language-heading'))]), + choiceRow([ + ( + href: MewsUrls.action('set-language', {'value': systemLanguage}), + label: strings.t('settings-language-system'), + current: settings.language.value == systemLanguage, + ), + for (final locale in appLocales) + ( + href: MewsUrls.action('set-language', {'value': locale.tag}), + label: strings.t(locale.labelId), + current: locale.tag == settings.language.value, + ), + ]), HeadingBlock(2, [MewsText(strings.t('settings-variant-heading'))]), choiceRow([ for (final variant in ReaderSettingsController.variants) diff --git a/app/lib/application/pages/subscriptions_page.dart b/app/lib/application/pages/subscriptions_page.dart index 89156cf..a7fa3be 100644 --- a/app/lib/application/pages/subscriptions_page.dart +++ b/app/lib/application/pages/subscriptions_page.dart @@ -21,6 +21,7 @@ Future buildSubscriptionsPage( return internalPage( url: url, title: title, + strings: strings, body: [ ...chrome(title: title, current: url, strings: strings), textForm( diff --git a/app/lib/application/pages/tags_page.dart b/app/lib/application/pages/tags_page.dart index bf6b57a..badd7dd 100644 --- a/app/lib/application/pages/tags_page.dart +++ b/app/lib/application/pages/tags_page.dart @@ -42,6 +42,7 @@ Future _index( return internalPage( url: url, title: title, + strings: strings, body: [ ...chrome(title: title, current: url, strings: strings), if (counts.isEmpty) @@ -111,6 +112,7 @@ Future _assign( return internalPage( url: url, title: title, + strings: strings, body: [ HeadingBlock(1, [MewsText(title)]), ParagraphBlock([ diff --git a/app/lib/database/database.dart b/app/lib/database/database.dart index 398abb4..46f8cff 100644 --- a/app/lib/database/database.dart +++ b/app/lib/database/database.dart @@ -82,6 +82,7 @@ class ReaderSettings extends Table { RealColumn get fontScale => real().withDefault(const Constant(1.0))(); TextColumn get themeMode => text().withDefault(const Constant('system'))(); TextColumn get variant => text().withDefault(const Constant('site'))(); + TextColumn get language => text().withDefault(const Constant('system'))(); @override Set get primaryKey => {id}; @@ -116,13 +117,27 @@ class MewsDatabase extends _$MewsDatabase { MewsDatabase.test(super.executor); @override - int get schemaVersion => 2; + int get schemaVersion => 3; - /// Pre-release: the entry store replaces the old list of seen entry URLs and - /// the library tables gain real primary keys, so an existing database is - /// recreated rather than migrated. @override - MigrationStrategy get migration => destructiveFallback; + MigrationStrategy get migration => MigrationStrategy( + onCreate: (m) => m.createAll(), + onUpgrade: (m, from, to) async { + // Version 1 predates the entry store and the library tables' real + // primary keys. Nothing of it is worth carrying, so it is recreated; + // later versions only gain columns and keep what the reader has. + if (from < 2) { + for (final entity in allSchemaEntities.toList().reversed) { + await m.drop(entity); + } + await m.createAll(); + return; + } + if (from < 3) { + await m.addColumn(readerSettings, readerSettings.language); + } + }, + ); static QueryExecutor _open() => LazyDatabase(() async { final dir = await getApplicationDocumentsDirectory(); @@ -522,6 +537,7 @@ class MewsDatabase extends _$MewsDatabase { required double fontScale, required String themeMode, required String variant, + required String language, }) => into(readerSettings).insert( ReaderSettingsCompanion.insert( @@ -530,6 +546,7 @@ class MewsDatabase extends _$MewsDatabase { fontScale: Value(fontScale), themeMode: Value(themeMode), variant: Value(variant), + language: Value(language), ), mode: InsertMode.insertOrReplace); diff --git a/app/lib/database/database.g.dart b/app/lib/database/database.g.dart index afa737e..675dec8 100644 --- a/app/lib/database/database.g.dart +++ b/app/lib/database/database.g.dart @@ -1711,6 +1711,18 @@ class $ReaderSettingsTable extends ReaderSettings requiredDuringInsert: false, defaultValue: const Constant('site'), ); + static const VerificationMeta _languageMeta = const VerificationMeta( + 'language', + ); + @override + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('system'), + ); @override List get $columns => [ id, @@ -1718,6 +1730,7 @@ class $ReaderSettingsTable extends ReaderSettings fontScale, themeMode, variant, + language, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1758,6 +1771,12 @@ class $ReaderSettingsTable extends ReaderSettings variant.isAcceptableOrUnknown(data['variant']!, _variantMeta), ); } + if (data.containsKey('language')) { + context.handle( + _languageMeta, + language.isAcceptableOrUnknown(data['language']!, _languageMeta), + ); + } return context; } @@ -1787,6 +1806,10 @@ class $ReaderSettingsTable extends ReaderSettings DriftSqlType.string, data['${effectivePrefix}variant'], )!, + language: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}language'], + )!, ); } @@ -1802,12 +1825,14 @@ class ReaderSetting extends DataClass implements Insertable { final double fontScale; final String themeMode; final String variant; + final String language; const ReaderSetting({ required this.id, required this.font, required this.fontScale, required this.themeMode, required this.variant, + required this.language, }); @override Map toColumns(bool nullToAbsent) { @@ -1817,6 +1842,7 @@ class ReaderSetting extends DataClass implements Insertable { map['font_scale'] = Variable(fontScale); map['theme_mode'] = Variable(themeMode); map['variant'] = Variable(variant); + map['language'] = Variable(language); return map; } @@ -1827,6 +1853,7 @@ class ReaderSetting extends DataClass implements Insertable { fontScale: Value(fontScale), themeMode: Value(themeMode), variant: Value(variant), + language: Value(language), ); } @@ -1841,6 +1868,7 @@ class ReaderSetting extends DataClass implements Insertable { fontScale: serializer.fromJson(json['fontScale']), themeMode: serializer.fromJson(json['themeMode']), variant: serializer.fromJson(json['variant']), + language: serializer.fromJson(json['language']), ); } @override @@ -1852,6 +1880,7 @@ class ReaderSetting extends DataClass implements Insertable { 'fontScale': serializer.toJson(fontScale), 'themeMode': serializer.toJson(themeMode), 'variant': serializer.toJson(variant), + 'language': serializer.toJson(language), }; } @@ -1861,12 +1890,14 @@ class ReaderSetting extends DataClass implements Insertable { double? fontScale, String? themeMode, String? variant, + String? language, }) => ReaderSetting( id: id ?? this.id, font: font ?? this.font, fontScale: fontScale ?? this.fontScale, themeMode: themeMode ?? this.themeMode, variant: variant ?? this.variant, + language: language ?? this.language, ); ReaderSetting copyWithCompanion(ReaderSettingsCompanion data) { return ReaderSetting( @@ -1875,6 +1906,7 @@ class ReaderSetting extends DataClass implements Insertable { fontScale: data.fontScale.present ? data.fontScale.value : this.fontScale, themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, variant: data.variant.present ? data.variant.value : this.variant, + language: data.language.present ? data.language.value : this.language, ); } @@ -1885,13 +1917,15 @@ class ReaderSetting extends DataClass implements Insertable { ..write('font: $font, ') ..write('fontScale: $fontScale, ') ..write('themeMode: $themeMode, ') - ..write('variant: $variant') + ..write('variant: $variant, ') + ..write('language: $language') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, font, fontScale, themeMode, variant); + int get hashCode => + Object.hash(id, font, fontScale, themeMode, variant, language); @override bool operator ==(Object other) => identical(this, other) || @@ -1900,7 +1934,8 @@ class ReaderSetting extends DataClass implements Insertable { other.font == this.font && other.fontScale == this.fontScale && other.themeMode == this.themeMode && - other.variant == this.variant); + other.variant == this.variant && + other.language == this.language); } class ReaderSettingsCompanion extends UpdateCompanion { @@ -1909,12 +1944,14 @@ class ReaderSettingsCompanion extends UpdateCompanion { final Value fontScale; final Value themeMode; final Value variant; + final Value language; const ReaderSettingsCompanion({ this.id = const Value.absent(), this.font = const Value.absent(), this.fontScale = const Value.absent(), this.themeMode = const Value.absent(), this.variant = const Value.absent(), + this.language = const Value.absent(), }); ReaderSettingsCompanion.insert({ this.id = const Value.absent(), @@ -1922,6 +1959,7 @@ class ReaderSettingsCompanion extends UpdateCompanion { this.fontScale = const Value.absent(), this.themeMode = const Value.absent(), this.variant = const Value.absent(), + this.language = const Value.absent(), }); static Insertable custom({ Expression? id, @@ -1929,6 +1967,7 @@ class ReaderSettingsCompanion extends UpdateCompanion { Expression? fontScale, Expression? themeMode, Expression? variant, + Expression? language, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -1936,6 +1975,7 @@ class ReaderSettingsCompanion extends UpdateCompanion { if (fontScale != null) 'font_scale': fontScale, if (themeMode != null) 'theme_mode': themeMode, if (variant != null) 'variant': variant, + if (language != null) 'language': language, }); } @@ -1945,6 +1985,7 @@ class ReaderSettingsCompanion extends UpdateCompanion { Value? fontScale, Value? themeMode, Value? variant, + Value? language, }) { return ReaderSettingsCompanion( id: id ?? this.id, @@ -1952,6 +1993,7 @@ class ReaderSettingsCompanion extends UpdateCompanion { fontScale: fontScale ?? this.fontScale, themeMode: themeMode ?? this.themeMode, variant: variant ?? this.variant, + language: language ?? this.language, ); } @@ -1973,6 +2015,9 @@ class ReaderSettingsCompanion extends UpdateCompanion { if (variant.present) { map['variant'] = Variable(variant.value); } + if (language.present) { + map['language'] = Variable(language.value); + } return map; } @@ -1983,7 +2028,8 @@ class ReaderSettingsCompanion extends UpdateCompanion { ..write('font: $font, ') ..write('fontScale: $fontScale, ') ..write('themeMode: $themeMode, ') - ..write('variant: $variant') + ..write('variant: $variant, ') + ..write('language: $language') ..write(')')) .toString(); } @@ -3362,6 +3408,7 @@ typedef $$ReaderSettingsTableCreateCompanionBuilder = Value fontScale, Value themeMode, Value variant, + Value language, }); typedef $$ReaderSettingsTableUpdateCompanionBuilder = ReaderSettingsCompanion Function({ @@ -3370,6 +3417,7 @@ typedef $$ReaderSettingsTableUpdateCompanionBuilder = Value fontScale, Value themeMode, Value variant, + Value language, }); class $$ReaderSettingsTableFilterComposer @@ -3405,6 +3453,11 @@ class $$ReaderSettingsTableFilterComposer column: $table.variant, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get language => $composableBuilder( + column: $table.language, + builder: (column) => ColumnFilters(column), + ); } class $$ReaderSettingsTableOrderingComposer @@ -3440,6 +3493,11 @@ class $$ReaderSettingsTableOrderingComposer column: $table.variant, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get language => $composableBuilder( + column: $table.language, + builder: (column) => ColumnOrderings(column), + ); } class $$ReaderSettingsTableAnnotationComposer @@ -3465,6 +3523,9 @@ class $$ReaderSettingsTableAnnotationComposer GeneratedColumn get variant => $composableBuilder(column: $table.variant, builder: (column) => column); + + GeneratedColumn get language => + $composableBuilder(column: $table.language, builder: (column) => column); } class $$ReaderSettingsTableTableManager @@ -3505,12 +3566,14 @@ class $$ReaderSettingsTableTableManager Value fontScale = const Value.absent(), Value themeMode = const Value.absent(), Value variant = const Value.absent(), + Value language = const Value.absent(), }) => ReaderSettingsCompanion( id: id, font: font, fontScale: fontScale, themeMode: themeMode, variant: variant, + language: language, ), createCompanionCallback: ({ @@ -3519,12 +3582,14 @@ class $$ReaderSettingsTableTableManager Value fontScale = const Value.absent(), Value themeMode = const Value.absent(), Value variant = const Value.absent(), + Value language = const Value.absent(), }) => ReaderSettingsCompanion.insert( id: id, font: font, fontScale: fontScale, themeMode: themeMode, variant: variant, + language: language, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) diff --git a/app/lib/l10n/locales.dart b/app/lib/l10n/locales.dart new file mode 100644 index 0000000..869d641 --- /dev/null +++ b/app/lib/l10n/locales.dart @@ -0,0 +1,89 @@ +/// The interface languages the client ships. Pure data: the settings page +/// offers them, the loader reads their files, and the app tells Flutter which +/// one is in use. +library; + +/// One shipped language: the tag used in settings and in the asset path, and +/// the id of its name in the locale files. A language is always named in its +/// own words, so a reader who cannot read the current interface can still find +/// theirs. +typedef AppLocale = ({String tag, String labelId}); + +/// Offered in this order, English first and then alphabetically by endonym. +const appLocales = [ + (tag: 'en-US', labelId: 'language-en'), + (tag: 'de', labelId: 'language-de'), + (tag: 'es', labelId: 'language-es'), + (tag: 'fr', labelId: 'language-fr'), + (tag: 'he', labelId: 'language-he'), + (tag: 'ja', labelId: 'language-ja'), + (tag: 'pt', labelId: 'language-pt'), + (tag: 'ru', labelId: 'language-ru'), +]; + +/// What the reader's language setting holds when they have not chosen one. +const systemLanguage = 'system'; + +const fallbackLocale = 'en-US'; + +/// Written right to left, so the whole interface mirrors. +const rtlLocales = {'he'}; + +bool isRightToLeft(String tag) => rtlLocales.contains(_languageOf(tag)); + +/// The shipped locale for [tag], or null when nothing matches. A tag matches +/// on its language alone, so a device set to de-AT reads the German files. +String? matchLocale(String tag) { + final language = _languageOf(tag); + for (final locale in appLocales) { + if (locale.tag == tag) return locale.tag; + } + for (final locale in appLocales) { + if (_languageOf(locale.tag) == language) return locale.tag; + } + return null; +} + +/// The first of [tags] the client ships, for resolving the device's list of +/// preferred languages. +String resolveLocale(Iterable tags) { + for (final tag in tags) { + final match = matchLocale(tag); + if (match != null) return match; + } + return fallbackLocale; +} + +String _languageOf(String tag) => + tag.split(RegExp('[-_]')).first.toLowerCase(); + +/// Which plural message a count needs, as a message id suffix. Zero is handled +/// before this, so every language can say "none" in its own way. +/// +/// Only the languages the client ships are described (CLDR cardinal rules): +/// Russian separates two to four from the rest, Hebrew has a form for two, +/// French and Portuguese treat zero and one alike, and Japanese has one form. +String pluralSuffix(String tag, int count) { + final n = count.abs(); + switch (_languageOf(tag)) { + case 'ru': + final mod10 = n % 10; + final mod100 = n % 100; + if (mod10 == 1 && mod100 != 11) return 'one'; + if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) { + return 'few'; + } + return 'many'; + case 'he': + if (n == 1) return 'one'; + if (n == 2) return 'two'; + return 'many'; + case 'fr': + case 'pt': + return n <= 1 ? 'one' : 'many'; + case 'ja': + return 'many'; + default: + return n == 1 ? 'one' : 'many'; + } +} diff --git a/app/lib/l10n/strings.dart b/app/lib/l10n/strings.dart index 2f416a0..ab61bae 100644 --- a/app/lib/l10n/strings.dart +++ b/app/lib/l10n/strings.dart @@ -2,27 +2,66 @@ import 'package:fluent/fluent.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; +import 'locales.dart'; + /// Loads and formats the app's Fluent messages. All user-facing strings come -/// from locales/en-US/main.ftl; lookups fall back to the message ID when a -/// message is missing so that missing translations are obvious in the UI. +/// from `locales//main.ftl`. +/// +/// A message missing from the chosen language falls back to English, and a +/// message missing from English too comes back as its own id, so a gap shows +/// in the interface rather than crashing or rendering as nothing. class Strings { - final FluentBundle _bundle = FluentBundle('en-US'); + FluentBundle _bundle = FluentBundle(fallbackLocale); + FluentBundle _fallback = FluentBundle(fallbackLocale); - /// Loads the messages from the app's asset bundle. + String _tag = fallbackLocale; + + /// The language now in use, as a shipped locale tag. + String get tag => _tag; + + bool get isRtl => isRightToLeft(_tag); + + /// Loads English, which every other language falls back to. Future load() async { - final source = await rootBundle.loadString('locales/en-US/main.ftl'); - loadSource(source); + _fallback = await _bundleFor(fallbackLocale); + _bundle = _fallback; + _tag = fallbackLocale; } - /// Loads messages from an already-read source, so tests can read the file - /// from disk instead of the asset bundle. - void loadSource(String source) { - _bundle.addMessages(source); + /// Switches to [tag], keeping English underneath it. + Future use(String tag) async { + if (tag == _tag) return; + _bundle = tag == fallbackLocale ? _fallback : await _bundleFor(tag); + _tag = tag; } - String t(String id, [Map args = const {}]) { - return _bundle.format(id, args: args) ?? id; + Future _bundleFor(String tag) async { + final bundle = FluentBundle(tag); + bundle.addMessages(await rootBundle.loadString('locales/$tag/main.ftl')); + return bundle; } + + /// Loads messages from an already-read source, so tests can read files from + /// disk instead of the asset bundle. + void loadSource(String source, {String tag = fallbackLocale}) { + final bundle = FluentBundle(tag); + bundle.addMessages(source); + _bundle = bundle; + _tag = tag; + if (tag == fallbackLocale) _fallback = bundle; + } + + /// The message for [id], or null when neither the chosen language nor + /// English has one. Used where a missing message is expected, such as a + /// plural form a language does not need. + String? lookup(String id, [Map args = const {}]) => + _bundle.format(id, args: args) ?? _fallback.format(id, args: args); + + String t(String id, [Map args = const {}]) => + lookup(id, args) ?? id; + + /// Which plural message [count] needs in the language in use. + String pluralSuffixFor(int count) => pluralSuffix(_tag, count); } /// Provides [Strings] to the widget tree. diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index 1b1313c..2ef33e6 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -1,7 +1,10 @@ import 'dart:async' show unawaited; +import 'dart:ui' show PlatformDispatcher; + import 'package:actors/actors.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../application/actors/fetch_actor.dart'; @@ -13,6 +16,7 @@ import '../application/fonts.dart'; import '../application/internal_actions.dart'; import '../application/internal_pages.dart'; import '../database/database.dart'; +import '../l10n/locales.dart'; import '../l10n/strings.dart'; import '../mews/document.dart'; import 'reader/palettes.dart'; @@ -38,6 +42,7 @@ class AppDependencies { strings: this.strings); internalActions = InternalActions( db: db, + applyLanguage: applyLanguage, settings: settings, bookmarks: bookmarks, subscriptions: subscriptions, @@ -65,8 +70,20 @@ class AppDependencies { late final InternalPages internalPages; late final InternalActions internalActions; + /// The language to read the interface in: the reader's choice, or the best + /// match for what the device asks for. + String get activeLocale => settings.language.value == systemLanguage + ? resolveLocale( + PlatformDispatcher.instance.locales.map((l) => l.toLanguageTag())) + : settings.language.value; + + /// Loads the chosen language's messages. Called at startup and whenever the + /// reader changes the setting. + Future applyLanguage() => strings.use(activeLocale); + Future start() async { await settings.load(); + await applyLanguage(); await bookmarks.load(); await subscriptions.load(); await browser.restoreLastSession(); @@ -129,13 +146,30 @@ class _ThemedApp extends StatelessWidget { }; final settings = deps.settings; final font = settings.font.value; + // Read so the whole app rebuilds when the reader changes language. + settings.language.value; final page = deps.browser.currentPage.value; final variant = settings.variant.value == 'site' ? (page?.variant ?? MewsVariant.plain) : pageVariantFromName(settings.variant.value); + // Flutter's own widgets and the text direction follow the same + // language, so a right-to-left interface mirrors without us asking. + final locale = deps.activeLocale; return MaterialApp( title: 'Mews', + locale: Locale(locale.split('-').first, + locale.contains('-') ? locale.split('-').last : null), + supportedLocales: [ + for (final l in appLocales) + Locale(l.tag.split('-').first, + l.tag.contains('-') ? l.tag.split('-').last : null), + ], + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], themeMode: mode, theme: _themeFor( paletteFor(pageVariant: variant, readerVariant: 'site', dark: false), diff --git a/app/locales/de/main.ftl b/app/locales/de/main.ftl new file mode 100644 index 0000000..5e74386 --- /dev/null +++ b/app/locales/de/main.ftl @@ -0,0 +1,172 @@ +## Reader chrome + +reader-address-placeholder = Webadresse eingeben +# Accessibility label for the address field. +reader-address-label = Webadresse +reader-reload = Diese Seite neu laden +reader-open-hub = Deine Mews-Seiten öffnen +reader-back = Zurück +reader-forward = Vorwärts +reader-bookmark-add = Lesezeichen hinzufügen +reader-bookmark-remove = Lesezeichen entfernen +reader-subscribe = Diese Website abonnieren +reader-unsubscribe = Abo beenden + +## Navigation + +# The strip of the app's own pages, carried by each of them. +nav-hub = Mews +nav-posts = Beiträge +nav-bookmarks = Lesezeichen +nav-subscriptions = Abos +nav-history = Verlauf +nav-tags = Schlagwörter +nav-settings = Einstellungen + +## Hub + +hub-title = Mews + +## Counts + +# Shown under an item. Format the number in code. +count-bookmarks-none = Noch keine Lesezeichen +count-bookmarks-one = 1 Lesezeichen +count-bookmarks-many = { $count } Lesezeichen +count-sites-none = Noch keine Websites +count-sites-one = 1 Website +count-sites-many = { $count } Websites +count-pages-none = Noch keine Seiten +count-pages-one = 1 Seite +count-pages-many = { $count } Seiten +count-tags-none = Noch keine Schlagwörter +count-tags-one = 1 Schlagwort +count-tags-many = { $count } Schlagwörter +count-unread-none = Keine ungelesenen Beiträge +count-unread-one = 1 ungelesener Beitrag +count-unread-many = { $count } ungelesene Beiträge + +## Posts + +posts-title = Beiträge +posts-empty = Hier erscheinen Beiträge der Websites, die du abonnierst. +posts-none-unread = Du hast alles gelesen. +posts-checking = Es wird nach neuen Beiträgen gesucht. +posts-sort-label = Sortieren nach +posts-sort-date = Datum +posts-sort-source = Website +posts-sort-unread = Ungelesene zuerst +posts-show-label = Anzeigen +posts-show-all = Alle Beiträge +posts-show-unread = Nur ungelesene +posts-mark-read = Als gelesen markieren +posts-mark-unread = Als ungelesen markieren + +## Actions + +# Link labels used on more than one page. +action-refresh = Nach neuen Beiträgen suchen +action-search = Suchen +action-clear-search = Suche zurücksetzen +action-remove-bookmark = Lesezeichen entfernen +action-unsubscribe = Abo beenden +action-edit-tags = Schlagwörter bearbeiten + +## Library pages + +# Internal pages rendered like ordinary Mews pages. +history-title = Verlauf +history-empty = Hier erscheinen die Seiten, die du besuchst. +history-no-matches = Keine Seiten passen zu deiner Suche. +history-search-label = Verlauf durchsuchen +history-forget = Aus dem Verlauf entfernen +history-clear = Verlauf löschen +history-clear-confirm = Beim Löschen des Verlaufs werden alle besuchten Seiten entfernt. Das lässt sich nicht rückgängig machen. +history-clear-yes = Verlauf jetzt löschen +history-clear-no = Verlauf behalten +bookmarks-title = Lesezeichen +bookmarks-empty = Hier erscheinen die Seiten, die du als Lesezeichen speicherst. +bookmarks-no-matches = Keine Lesezeichen passen zu deiner Suche. +bookmarks-search-label = Lesezeichen durchsuchen +subscriptions-title = Abos +subscriptions-empty = Hier erscheinen die Websites, die du abonnierst. +subscriptions-no-matches = Keine Websites passen zu deiner Suche. +subscriptions-search-label = Websites durchsuchen +# When a site was last checked for posts. Format the date in code. +subscriptions-checked = Zuletzt geprüft { $date } +subscriptions-never-checked = Noch nicht geprüft + +## Tags + +tags-title = Schlagwörter +tags-empty = Hier erscheinen die Schlagwörter, die du Lesezeichen und Websites gibst. +# The item being tagged. Shows its title, or its address until it has one. +tags-assign-title = Schlagwörter für { $name } +tags-add-label = Schlagwort hinzufügen +tags-add-submit = Hinzufügen +tags-on-item = Vergeben +tags-other = Weitere Schlagwörter +tags-remove = Dieses Schlagwort entfernen +tags-delete = Dieses Schlagwort überall löschen +tags-done = Fertig +bookmarks-show-all = Alle Lesezeichen anzeigen +# Shown when a tag narrows the list. +bookmarks-tagged = Lesezeichen mit dem Schlagwort { $tag } +subscriptions-show-all = Alle Websites anzeigen +subscriptions-tagged = Websites mit dem Schlagwort { $tag } + +## Settings page + +settings-page-title = Einstellungen +settings-font-heading = Schriftart +settings-font-atkinson = Atkinson Hyperlegible +settings-font-luciole = Luciole +settings-font-literata = Literata +settings-font-charis = Charis SIL +settings-size-heading = Schriftgröße +settings-size-decrease = Kleinere Schrift +settings-size-increase = Größere Schrift +# The text size now in use. Format the number in code. +settings-size-value = { $percent }% +settings-theme-heading = Darstellung +settings-theme-light = Hell +settings-theme-dark = Dunkel +settings-theme-system = Systemeinstellung folgen +settings-variant-heading = Farben +settings-variant-site = Website +settings-variant-warm = Warm +settings-variant-cool = Kühl +settings-variant-green = Grün +settings-variant-mono = Monochrom +settings-language-heading = Sprache +settings-language-system = Systemeinstellung folgen + +## Languages + +# Every language is named in its own words, so a reader who cannot read the +# current interface can still find theirs. Leave these untranslated. +language-en = English +language-de = Deutsch +language-es = Español +language-fr = Français +language-he = עברית +language-ja = 日本語 +language-pt = Português +language-ru = Русский + +## Forms + +# Default label for a page form's submit control when the author gave none. +form-submit-default = Absenden +form-reset-default = Zurücksetzen + +## Errors + +error-load-failed = Die Seite konnte nicht geladen werden. Prüfe die Adresse und deine Verbindung und versuche es erneut. +error-try-again = Erneut versuchen +error-broken-page = Diese Seite hat einen Auszeichnungsfehler und kann deshalb nicht angezeigt werden. +error-broken-page-show-text = Seitentext anzeigen +error-broken-page-text-title = Seitentext +error-non-mews = Dies ist keine Mews-Seite und wird deshalb schlicht angezeigt. +error-image-blocked = Dieses Bild liegt auf einer anderen Website und wird nicht angezeigt. +error-image-unavailable = Dieses Bild konnte nicht geladen werden. diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index a4ff88f..f825f6a 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -138,6 +138,21 @@ settings-variant-warm = Warm settings-variant-cool = Cool settings-variant-green = Green settings-variant-mono = Mono +settings-language-heading = Language +settings-language-system = Follow system + +## Languages + +# Every language is named in its own words, so a reader who cannot read the +# current interface can still find theirs. Leave these untranslated. +language-en = English +language-de = Deutsch +language-es = Español +language-fr = Français +language-he = עברית +language-ja = 日本語 +language-pt = Português +language-ru = Русский ## Forms diff --git a/app/locales/es/main.ftl b/app/locales/es/main.ftl new file mode 100644 index 0000000..bd0aabc --- /dev/null +++ b/app/locales/es/main.ftl @@ -0,0 +1,172 @@ +## Reader chrome + +reader-address-placeholder = Escribe una dirección web +# Accessibility label for the address field. +reader-address-label = Dirección web +reader-reload = Recargar esta página +reader-open-hub = Abrir tus páginas de Mews +reader-back = Atrás +reader-forward = Adelante +reader-bookmark-add = Añadir marcador +reader-bookmark-remove = Quitar marcador +reader-subscribe = Suscribirte a este sitio +reader-unsubscribe = Cancelar la suscripción + +## Navigation + +# The strip of the app's own pages, carried by each of them. +nav-hub = Mews +nav-posts = Entradas +nav-bookmarks = Marcadores +nav-subscriptions = Suscripciones +nav-history = Historial +nav-tags = Etiquetas +nav-settings = Ajustes + +## Hub + +hub-title = Mews + +## Counts + +# Shown under an item. Format the number in code. +count-bookmarks-none = Aún no hay marcadores +count-bookmarks-one = 1 marcador +count-bookmarks-many = { $count } marcadores +count-sites-none = Aún no hay sitios +count-sites-one = 1 sitio +count-sites-many = { $count } sitios +count-pages-none = Aún no hay páginas +count-pages-one = 1 página +count-pages-many = { $count } páginas +count-tags-none = Aún no hay etiquetas +count-tags-one = 1 etiqueta +count-tags-many = { $count } etiquetas +count-unread-none = No hay entradas sin leer +count-unread-one = 1 entrada sin leer +count-unread-many = { $count } entradas sin leer + +## Posts + +posts-title = Entradas +posts-empty = Aquí aparecen las entradas de los sitios a los que te suscribes. +posts-none-unread = Lo has leído todo. +posts-checking = Buscando entradas nuevas. +posts-sort-label = Ordenar por +posts-sort-date = Fecha +posts-sort-source = Sitio +posts-sort-unread = Sin leer primero +posts-show-label = Mostrar +posts-show-all = Todas las entradas +posts-show-unread = Solo sin leer +posts-mark-read = Marcar como leída +posts-mark-unread = Marcar como no leída + +## Actions + +# Link labels used on more than one page. +action-refresh = Buscar entradas nuevas +action-search = Buscar +action-clear-search = Borrar la búsqueda +action-remove-bookmark = Quitar marcador +action-unsubscribe = Cancelar la suscripción +action-edit-tags = Editar etiquetas + +## Library pages + +# Internal pages rendered like ordinary Mews pages. +history-title = Historial +history-empty = Aquí aparecen las páginas que visitas. +history-no-matches = Ninguna página coincide con tu búsqueda. +history-search-label = Buscar en el historial +history-forget = Quitar del historial +history-clear = Borrar el historial +history-clear-confirm = Al borrar el historial se eliminan todas las páginas que has visitado. Esto no se puede deshacer. +history-clear-yes = Borrar el historial ahora +history-clear-no = Conservar mi historial +bookmarks-title = Marcadores +bookmarks-empty = Aquí aparecen las páginas que guardas como marcador. +bookmarks-no-matches = Ningún marcador coincide con tu búsqueda. +bookmarks-search-label = Buscar en los marcadores +subscriptions-title = Suscripciones +subscriptions-empty = Aquí aparecen los sitios a los que te suscribes. +subscriptions-no-matches = Ningún sitio coincide con tu búsqueda. +subscriptions-search-label = Buscar sitios +# When a site was last checked for posts. Format the date in code. +subscriptions-checked = Última comprobación { $date } +subscriptions-never-checked = Sin comprobar todavía + +## Tags + +tags-title = Etiquetas +tags-empty = Aquí aparecen las etiquetas que añades a marcadores y sitios. +# The item being tagged. Shows its title, or its address until it has one. +tags-assign-title = Etiquetas de { $name } +tags-add-label = Añadir una etiqueta +tags-add-submit = Añadir +tags-on-item = En este elemento +tags-other = Otras etiquetas +tags-remove = Quitar esta etiqueta +tags-delete = Eliminar esta etiqueta en todas partes +tags-done = Listo +bookmarks-show-all = Mostrar todos los marcadores +# Shown when a tag narrows the list. +bookmarks-tagged = Marcadores con la etiqueta { $tag } +subscriptions-show-all = Mostrar todos los sitios +subscriptions-tagged = Sitios con la etiqueta { $tag } + +## Settings page + +settings-page-title = Ajustes +settings-font-heading = Tipografía +settings-font-atkinson = Atkinson Hyperlegible +settings-font-luciole = Luciole +settings-font-literata = Literata +settings-font-charis = Charis SIL +settings-size-heading = Tamaño del texto +settings-size-decrease = Texto más pequeño +settings-size-increase = Texto más grande +# The text size now in use. Format the number in code. +settings-size-value = { $percent }% +settings-theme-heading = Tema +settings-theme-light = Claro +settings-theme-dark = Oscuro +settings-theme-system = Seguir al sistema +settings-variant-heading = Colores +settings-variant-site = Sitio +settings-variant-warm = Cálido +settings-variant-cool = Frío +settings-variant-green = Verde +settings-variant-mono = Monocromo +settings-language-heading = Idioma +settings-language-system = Seguir al sistema + +## Languages + +# Every language is named in its own words, so a reader who cannot read the +# current interface can still find theirs. Leave these untranslated. +language-en = English +language-de = Deutsch +language-es = Español +language-fr = Français +language-he = עברית +language-ja = 日本語 +language-pt = Português +language-ru = Русский + +## Forms + +# Default label for a page form's submit control when the author gave none. +form-submit-default = Enviar +form-reset-default = Restablecer + +## Errors + +error-load-failed = No se ha podido cargar la página. Comprueba la dirección y tu conexión, y vuelve a intentarlo. +error-try-again = Volver a intentarlo +error-broken-page = Esta página tiene un error de marcado, así que no se puede mostrar. +error-broken-page-show-text = Mostrar el texto de la página +error-broken-page-text-title = Texto de la página +error-non-mews = Esta página no es una página de Mews, así que se muestra de forma sencilla. +error-image-blocked = Esta imagen está en otro sitio y no se muestra. +error-image-unavailable = No se ha podido cargar esta imagen. diff --git a/app/locales/fr/main.ftl b/app/locales/fr/main.ftl new file mode 100644 index 0000000..ad9da39 --- /dev/null +++ b/app/locales/fr/main.ftl @@ -0,0 +1,172 @@ +## Reader chrome + +reader-address-placeholder = Saisis une adresse web +# Accessibility label for the address field. +reader-address-label = Adresse web +reader-reload = Recharger cette page +reader-open-hub = Ouvrir tes pages Mews +reader-back = Revenir en arrière +reader-forward = Aller en avant +reader-bookmark-add = Ajouter un signet +reader-bookmark-remove = Retirer le signet +reader-subscribe = S'abonner à ce site +reader-unsubscribe = Se désabonner + +## Navigation + +# The strip of the app's own pages, carried by each of them. +nav-hub = Mews +nav-posts = Billets +nav-bookmarks = Signets +nav-subscriptions = Abonnements +nav-history = Historique +nav-tags = Étiquettes +nav-settings = Réglages + +## Hub + +hub-title = Mews + +## Counts + +# Shown under an item. Format the number in code. +count-bookmarks-none = Aucun signet pour l'instant +count-bookmarks-one = { $count } signet +count-bookmarks-many = { $count } signets +count-sites-none = Aucun site pour l'instant +count-sites-one = { $count } site +count-sites-many = { $count } sites +count-pages-none = Aucune page pour l'instant +count-pages-one = { $count } page +count-pages-many = { $count } pages +count-tags-none = Aucune étiquette pour l'instant +count-tags-one = { $count } étiquette +count-tags-many = { $count } étiquettes +count-unread-none = Aucun billet non lu +count-unread-one = { $count } billet non lu +count-unread-many = { $count } billets non lus + +## Posts + +posts-title = Billets +posts-empty = Les billets des sites auxquels tu es abonné apparaissent ici. +posts-none-unread = Tu as tout lu. +posts-checking = Recherche de nouveaux billets en cours. +posts-sort-label = Trier par +posts-sort-date = Date +posts-sort-source = Site +posts-sort-unread = Non lus d'abord +posts-show-label = Afficher +posts-show-all = Tous les billets +posts-show-unread = Non lus seulement +posts-mark-read = Marquer comme lu +posts-mark-unread = Marquer comme non lu + +## Actions + +# Link labels used on more than one page. +action-refresh = Chercher de nouveaux billets +action-search = Rechercher +action-clear-search = Effacer la recherche +action-remove-bookmark = Retirer le signet +action-unsubscribe = Se désabonner +action-edit-tags = Modifier les étiquettes + +## Library pages + +# Internal pages rendered like ordinary Mews pages. +history-title = Historique +history-empty = Les pages que tu visites apparaissent ici. +history-no-matches = Aucune page ne correspond à ta recherche. +history-search-label = Rechercher dans l'historique +history-forget = Retirer de l'historique +history-clear = Effacer l'historique +history-clear-confirm = Effacer l'historique supprime toutes les pages visitées. Cette action est définitive. +history-clear-yes = Effacer l'historique maintenant +history-clear-no = Garder mon historique +bookmarks-title = Signets +bookmarks-empty = Les pages que tu mets en signet apparaissent ici. +bookmarks-no-matches = Aucun signet ne correspond à ta recherche. +bookmarks-search-label = Rechercher dans les signets +subscriptions-title = Abonnements +subscriptions-empty = Les sites auxquels tu t'abonnes apparaissent ici. +subscriptions-no-matches = Aucun site ne correspond à ta recherche. +subscriptions-search-label = Rechercher un site +# When a site was last checked for posts. Format the date in code. +subscriptions-checked = Dernière vérification { $date } +subscriptions-never-checked = Pas encore vérifié + +## Tags + +tags-title = Étiquettes +tags-empty = Les étiquettes que tu ajoutes aux signets et aux sites apparaissent ici. +# The item being tagged. Shows its title, or its address until it has one. +tags-assign-title = Étiquettes de { $name } +tags-add-label = Ajouter une étiquette +tags-add-submit = Ajouter +tags-on-item = Sur cet élément +tags-other = Autres étiquettes +tags-remove = Retirer cette étiquette +tags-delete = Supprimer cette étiquette partout +tags-done = Terminé +bookmarks-show-all = Afficher tous les signets +# Shown when a tag narrows the list. +bookmarks-tagged = Signets portant l'étiquette { $tag } +subscriptions-show-all = Afficher tous les sites +subscriptions-tagged = Sites portant l'étiquette { $tag } + +## Settings page + +settings-page-title = Réglages +settings-font-heading = Police +settings-font-atkinson = Atkinson Hyperlegible +settings-font-luciole = Luciole +settings-font-literata = Literata +settings-font-charis = Charis SIL +settings-size-heading = Taille du texte +settings-size-decrease = Texte plus petit +settings-size-increase = Texte plus grand +# The text size now in use. Format the number in code. +settings-size-value = { $percent }% +settings-theme-heading = Thème +settings-theme-light = Clair +settings-theme-dark = Sombre +settings-theme-system = Suivre le système +settings-variant-heading = Couleurs +settings-variant-site = Site +settings-variant-warm = Chaud +settings-variant-cool = Froid +settings-variant-green = Vert +settings-variant-mono = Monochrome +settings-language-heading = Langue +settings-language-system = Suivre le système + +## Languages + +# Every language is named in its own words, so a reader who cannot read the +# current interface can still find theirs. Leave these untranslated. +language-en = English +language-de = Deutsch +language-es = Español +language-fr = Français +language-he = עברית +language-ja = 日本語 +language-pt = Português +language-ru = Русский + +## Forms + +# Default label for a page form's submit control when the author gave none. +form-submit-default = Envoyer +form-reset-default = Réinitialiser + +## Errors + +error-load-failed = La page n'a pas pu être chargée. Vérifie l'adresse et ta connexion, puis réessaie. +error-try-again = Réessayer +error-broken-page = Cette page comporte une erreur de balisage et ne peut donc pas être affichée. +error-broken-page-show-text = Afficher le texte de la page +error-broken-page-text-title = Texte de la page +error-non-mews = Cette page n'est pas une page Mews, elle est donc affichée simplement. +error-image-blocked = Cette image se trouve sur un autre site et n'est pas affichée. +error-image-unavailable = Cette image n'a pas pu être chargée. diff --git a/app/locales/he/main.ftl b/app/locales/he/main.ftl new file mode 100644 index 0000000..e453275 --- /dev/null +++ b/app/locales/he/main.ftl @@ -0,0 +1,177 @@ +## Reader chrome + +reader-address-placeholder = הזינו כתובת אינטרנט +# Accessibility label for the address field. +reader-address-label = כתובת אינטרנט +reader-reload = טעינה מחדש של הדף +reader-open-hub = פתיחת דפי Mews שלכם +reader-back = חזרה +reader-forward = קדימה +reader-bookmark-add = הוספת סימנייה +reader-bookmark-remove = הסרת הסימנייה +reader-subscribe = הרשמה לאתר הזה +reader-unsubscribe = ביטול ההרשמה + +## Navigation + +# The strip of the app's own pages, carried by each of them. +nav-hub = Mews +nav-posts = רשומות +nav-bookmarks = סימניות +nav-subscriptions = הרשמות +nav-history = היסטוריה +nav-tags = תגיות +nav-settings = הגדרות + +## Hub + +hub-title = Mews + +## Counts + +# Shown under an item. Format the number in code. +count-bookmarks-none = אין עדיין סימניות +count-bookmarks-one = סימנייה אחת +count-bookmarks-many = { $count } סימניות +count-bookmarks-two = שתי סימניות +count-sites-none = אין עדיין אתרים +count-sites-one = אתר אחד +count-sites-many = { $count } אתרים +count-sites-two = שני אתרים +count-pages-none = אין עדיין דפים +count-pages-one = דף אחד +count-pages-many = { $count } דפים +count-pages-two = שני דפים +count-tags-none = אין עדיין תגיות +count-tags-one = תגית אחת +count-tags-many = { $count } תגיות +count-tags-two = שתי תגיות +count-unread-none = אין רשומות שלא נקראו +count-unread-one = רשומה אחת שלא נקראה +count-unread-many = { $count } רשומות שלא נקראו +count-unread-two = שתי רשומות שלא נקראו + +## Posts + +posts-title = רשומות +posts-empty = רשומות מהאתרים שנרשמתם אליהם יופיעו כאן. +posts-none-unread = קראתם הכול. +posts-checking = מתבצעת בדיקה אחר רשומות חדשות. +posts-sort-label = מיון לפי +posts-sort-date = תאריך +posts-sort-source = אתר +posts-sort-unread = שלא נקראו תחילה +posts-show-label = הצגה +posts-show-all = כל הרשומות +posts-show-unread = שלא נקראו בלבד +posts-mark-read = סימון כנקראה +posts-mark-unread = סימון כלא נקראה + +## Actions + +# Link labels used on more than one page. +action-refresh = בדיקה אחר רשומות חדשות +action-search = חיפוש +action-clear-search = ניקוי החיפוש +action-remove-bookmark = הסרת הסימנייה +action-unsubscribe = ביטול ההרשמה +action-edit-tags = עריכת התגיות + +## Library pages + +# Internal pages rendered like ordinary Mews pages. +history-title = היסטוריה +history-empty = הדפים שתבקרו בהם יופיעו כאן. +history-no-matches = אין דפים התואמים לחיפוש. +history-search-label = חיפוש בהיסטוריה +history-forget = הסרה מההיסטוריה +history-clear = ניקוי ההיסטוריה +history-clear-confirm = ניקוי ההיסטוריה מסיר כל דף שביקרתם בו. אי אפשר לבטל את הפעולה. +history-clear-yes = לנקות את ההיסטוריה +history-clear-no = להשאיר את ההיסטוריה +bookmarks-title = סימניות +bookmarks-empty = הדפים שתסמנו יופיעו כאן. +bookmarks-no-matches = אין סימניות התואמות לחיפוש. +bookmarks-search-label = חיפוש בסימניות +subscriptions-title = הרשמות +subscriptions-empty = האתרים שתירשמו אליהם יופיעו כאן. +subscriptions-no-matches = אין אתרים התואמים לחיפוש. +subscriptions-search-label = חיפוש אתרים +# When a site was last checked for posts. Format the date in code. +subscriptions-checked = נבדק לאחרונה { $date } +subscriptions-never-checked = טרם נבדק + +## Tags + +tags-title = תגיות +tags-empty = התגיות שתוסיפו לסימניות ולאתרים יופיעו כאן. +# The item being tagged. Shows its title, or its address until it has one. +tags-assign-title = תגיות עבור { $name } +tags-add-label = הוספת תגית +tags-add-submit = הוספה +tags-on-item = על הפריט הזה +tags-other = תגיות נוספות +tags-remove = הסרת התגית הזו +tags-delete = מחיקת התגית הזו מכל מקום +tags-done = סיום +bookmarks-show-all = הצגת כל הסימניות +# Shown when a tag narrows the list. +bookmarks-tagged = סימניות עם התגית { $tag } +subscriptions-show-all = הצגת כל האתרים +subscriptions-tagged = אתרים עם התגית { $tag } + +## Settings page + +settings-page-title = הגדרות +settings-font-heading = גופן +settings-font-atkinson = Atkinson Hyperlegible +settings-font-luciole = Luciole +settings-font-literata = Literata +settings-font-charis = Charis SIL +settings-size-heading = גודל הטקסט +settings-size-decrease = טקסט קטן יותר +settings-size-increase = טקסט גדול יותר +# The text size now in use. Format the number in code. +settings-size-value = { $percent }% +settings-theme-heading = ערכת נושא +settings-theme-light = בהירה +settings-theme-dark = כהה +settings-theme-system = לפי המערכת +settings-variant-heading = צבעים +settings-variant-site = של האתר +settings-variant-warm = חמים +settings-variant-cool = קרים +settings-variant-green = ירוקים +settings-variant-mono = מונוכרום +settings-language-heading = שפה +settings-language-system = לפי המערכת + +## Languages + +# Every language is named in its own words, so a reader who cannot read the +# current interface can still find theirs. Leave these untranslated. +language-en = English +language-de = Deutsch +language-es = Español +language-fr = Français +language-he = עברית +language-ja = 日本語 +language-pt = Português +language-ru = Русский + +## Forms + +# Default label for a page form's submit control when the author gave none. +form-submit-default = שליחה +form-reset-default = איפוס + +## Errors + +error-load-failed = לא ניתן לטעון את הדף. בדקו את הכתובת ואת החיבור ונסו שוב. +error-try-again = ניסיון חוזר +error-broken-page = בדף הזה יש שגיאת סימון, ולכן אי אפשר להציג אותו. +error-broken-page-show-text = הצגת הטקסט של הדף +error-broken-page-text-title = הטקסט של הדף +error-non-mews = הדף הזה אינו דף Mews, ולכן הוא מוצג בפשטות. +error-image-blocked = התמונה הזו נמצאת באתר אחר ואינה מוצגת. +error-image-unavailable = לא ניתן לטעון את התמונה הזו. diff --git a/app/locales/ja/main.ftl b/app/locales/ja/main.ftl new file mode 100644 index 0000000..0602648 --- /dev/null +++ b/app/locales/ja/main.ftl @@ -0,0 +1,172 @@ +## Reader chrome + +reader-address-placeholder = ウェブアドレスを入力 +# Accessibility label for the address field. +reader-address-label = ウェブアドレス +reader-reload = このページを再読み込み +reader-open-hub = Mews のページを開く +reader-back = 戻る +reader-forward = 進む +reader-bookmark-add = ブックマークに追加 +reader-bookmark-remove = ブックマークを削除 +reader-subscribe = このサイトを購読 +reader-unsubscribe = 購読をやめる + +## Navigation + +# The strip of the app's own pages, carried by each of them. +nav-hub = Mews +nav-posts = 投稿 +nav-bookmarks = ブックマーク +nav-subscriptions = 購読 +nav-history = 履歴 +nav-tags = タグ +nav-settings = 設定 + +## Hub + +hub-title = Mews + +## Counts + +# Shown under an item. Format the number in code. +count-bookmarks-none = ブックマークはまだありません +count-bookmarks-one = ブックマーク { $count } 件 +count-bookmarks-many = ブックマーク { $count } 件 +count-sites-none = サイトはまだありません +count-sites-one = サイト { $count } 件 +count-sites-many = サイト { $count } 件 +count-pages-none = ページはまだありません +count-pages-one = ページ { $count } 件 +count-pages-many = ページ { $count } 件 +count-tags-none = タグはまだありません +count-tags-one = タグ { $count } 件 +count-tags-many = タグ { $count } 件 +count-unread-none = 未読の投稿はありません +count-unread-one = 未読の投稿 { $count } 件 +count-unread-many = 未読の投稿 { $count } 件 + +## Posts + +posts-title = 投稿 +posts-empty = 購読しているサイトの投稿がここに表示されます。 +posts-none-unread = すべて読み終えました。 +posts-checking = 新しい投稿を確認しています。 +posts-sort-label = 並べ替え +posts-sort-date = 日付 +posts-sort-source = サイト +posts-sort-unread = 未読を先に +posts-show-label = 表示 +posts-show-all = すべての投稿 +posts-show-unread = 未読のみ +posts-mark-read = 既読にする +posts-mark-unread = 未読にする + +## Actions + +# Link labels used on more than one page. +action-refresh = 新しい投稿を確認 +action-search = 検索 +action-clear-search = 検索を消去 +action-remove-bookmark = ブックマークを削除 +action-unsubscribe = 購読をやめる +action-edit-tags = タグを編集 + +## Library pages + +# Internal pages rendered like ordinary Mews pages. +history-title = 履歴 +history-empty = 訪れたページがここに表示されます。 +history-no-matches = 検索に一致するページはありません。 +history-search-label = 履歴を検索 +history-forget = 履歴から削除 +history-clear = 履歴を消去 +history-clear-confirm = 履歴を消去すると、訪れたページがすべて削除されます。元に戻せません。 +history-clear-yes = 履歴を消去する +history-clear-no = 履歴を残す +bookmarks-title = ブックマーク +bookmarks-empty = ブックマークしたページがここに表示されます。 +bookmarks-no-matches = 検索に一致するブックマークはありません。 +bookmarks-search-label = ブックマークを検索 +subscriptions-title = 購読 +subscriptions-empty = 購読しているサイトがここに表示されます。 +subscriptions-no-matches = 検索に一致するサイトはありません。 +subscriptions-search-label = サイトを検索 +# When a site was last checked for posts. Format the date in code. +subscriptions-checked = 最終確認 { $date } +subscriptions-never-checked = 未確認 + +## Tags + +tags-title = タグ +tags-empty = ブックマークやサイトに付けたタグがここに表示されます。 +# The item being tagged. Shows its title, or its address until it has one. +tags-assign-title = { $name } のタグ +tags-add-label = タグを追加 +tags-add-submit = 追加 +tags-on-item = この項目のタグ +tags-other = ほかのタグ +tags-remove = このタグを外す +tags-delete = このタグをすべてから削除 +tags-done = 完了 +bookmarks-show-all = すべてのブックマークを表示 +# Shown when a tag narrows the list. +bookmarks-tagged = タグ { $tag } のブックマーク +subscriptions-show-all = すべてのサイトを表示 +subscriptions-tagged = タグ { $tag } のサイト + +## Settings page + +settings-page-title = 設定 +settings-font-heading = 書体 +settings-font-atkinson = Atkinson Hyperlegible +settings-font-luciole = Luciole +settings-font-literata = Literata +settings-font-charis = Charis SIL +settings-size-heading = 文字の大きさ +settings-size-decrease = 小さく +settings-size-increase = 大きく +# The text size now in use. Format the number in code. +settings-size-value = { $percent }% +settings-theme-heading = テーマ +settings-theme-light = ライト +settings-theme-dark = ダーク +settings-theme-system = システムに合わせる +settings-variant-heading = 配色 +settings-variant-site = サイト +settings-variant-warm = 暖色 +settings-variant-cool = 寒色 +settings-variant-green = グリーン +settings-variant-mono = モノクロ +settings-language-heading = 言語 +settings-language-system = システムに合わせる + +## Languages + +# Every language is named in its own words, so a reader who cannot read the +# current interface can still find theirs. Leave these untranslated. +language-en = English +language-de = Deutsch +language-es = Español +language-fr = Français +language-he = עברית +language-ja = 日本語 +language-pt = Português +language-ru = Русский + +## Forms + +# Default label for a page form's submit control when the author gave none. +form-submit-default = 送信 +form-reset-default = リセット + +## Errors + +error-load-failed = ページを読み込めませんでした。アドレスと接続を確認して、もう一度お試しください。 +error-try-again = もう一度試す +error-broken-page = このページにはマークアップの誤りがあるため、表示できません。 +error-broken-page-show-text = ページのテキストを表示 +error-broken-page-text-title = ページのテキスト +error-non-mews = これは Mews のページではないため、簡素に表示しています。 +error-image-blocked = この画像は別のサイトにあるため表示しません。 +error-image-unavailable = この画像を読み込めませんでした。 diff --git a/app/locales/pt/main.ftl b/app/locales/pt/main.ftl new file mode 100644 index 0000000..2c402a5 --- /dev/null +++ b/app/locales/pt/main.ftl @@ -0,0 +1,172 @@ +## Reader chrome + +reader-address-placeholder = Escreve um endereço web +# Accessibility label for the address field. +reader-address-label = Endereço web +reader-reload = Recarregar esta página +reader-open-hub = Abrir as tuas páginas Mews +reader-back = Retroceder +reader-forward = Avançar +reader-bookmark-add = Adicionar marcador +reader-bookmark-remove = Remover marcador +reader-subscribe = Subscrever este site +reader-unsubscribe = Cancelar a subscrição + +## Navigation + +# The strip of the app's own pages, carried by each of them. +nav-hub = Mews +nav-posts = Publicações +nav-bookmarks = Marcadores +nav-subscriptions = Subscrições +nav-history = Histórico +nav-tags = Etiquetas +nav-settings = Definições + +## Hub + +hub-title = Mews + +## Counts + +# Shown under an item. Format the number in code. +count-bookmarks-none = Ainda não há marcadores +count-bookmarks-one = { $count } marcador +count-bookmarks-many = { $count } marcadores +count-sites-none = Ainda não há sites +count-sites-one = { $count } site +count-sites-many = { $count } sites +count-pages-none = Ainda não há páginas +count-pages-one = { $count } página +count-pages-many = { $count } páginas +count-tags-none = Ainda não há etiquetas +count-tags-one = { $count } etiqueta +count-tags-many = { $count } etiquetas +count-unread-none = Não há publicações por ler +count-unread-one = { $count } publicação por ler +count-unread-many = { $count } publicações por ler + +## Posts + +posts-title = Publicações +posts-empty = Aqui aparecem as publicações dos sites que subscreves. +posts-none-unread = Já leste tudo. +posts-checking = A procurar publicações novas. +posts-sort-label = Ordenar por +posts-sort-date = Data +posts-sort-source = Site +posts-sort-unread = Por ler primeiro +posts-show-label = Mostrar +posts-show-all = Todas as publicações +posts-show-unread = Apenas por ler +posts-mark-read = Marcar como lida +posts-mark-unread = Marcar como não lida + +## Actions + +# Link labels used on more than one page. +action-refresh = Procurar publicações novas +action-search = Procurar +action-clear-search = Limpar a procura +action-remove-bookmark = Remover marcador +action-unsubscribe = Cancelar a subscrição +action-edit-tags = Editar etiquetas + +## Library pages + +# Internal pages rendered like ordinary Mews pages. +history-title = Histórico +history-empty = Aqui aparecem as páginas que visitas. +history-no-matches = Nenhuma página corresponde à tua procura. +history-search-label = Procurar no histórico +history-forget = Remover do histórico +history-clear = Limpar o histórico +history-clear-confirm = Limpar o histórico remove todas as páginas que visitaste. Não é possível anular. +history-clear-yes = Limpar o histórico agora +history-clear-no = Manter o meu histórico +bookmarks-title = Marcadores +bookmarks-empty = Aqui aparecem as páginas que guardas como marcador. +bookmarks-no-matches = Nenhum marcador corresponde à tua procura. +bookmarks-search-label = Procurar nos marcadores +subscriptions-title = Subscrições +subscriptions-empty = Aqui aparecem os sites que subscreves. +subscriptions-no-matches = Nenhum site corresponde à tua procura. +subscriptions-search-label = Procurar sites +# When a site was last checked for posts. Format the date in code. +subscriptions-checked = Última verificação { $date } +subscriptions-never-checked = Ainda não verificado + +## Tags + +tags-title = Etiquetas +tags-empty = Aqui aparecem as etiquetas que dás a marcadores e sites. +# The item being tagged. Shows its title, or its address until it has one. +tags-assign-title = Etiquetas de { $name } +tags-add-label = Adicionar uma etiqueta +tags-add-submit = Adicionar +tags-on-item = Neste item +tags-other = Outras etiquetas +tags-remove = Remover esta etiqueta +tags-delete = Eliminar esta etiqueta em todo o lado +tags-done = Concluído +bookmarks-show-all = Mostrar todos os marcadores +# Shown when a tag narrows the list. +bookmarks-tagged = Marcadores com a etiqueta { $tag } +subscriptions-show-all = Mostrar todos os sites +subscriptions-tagged = Sites com a etiqueta { $tag } + +## Settings page + +settings-page-title = Definições +settings-font-heading = Tipo de letra +settings-font-atkinson = Atkinson Hyperlegible +settings-font-luciole = Luciole +settings-font-literata = Literata +settings-font-charis = Charis SIL +settings-size-heading = Tamanho do texto +settings-size-decrease = Texto mais pequeno +settings-size-increase = Texto maior +# The text size now in use. Format the number in code. +settings-size-value = { $percent }% +settings-theme-heading = Tema +settings-theme-light = Claro +settings-theme-dark = Escuro +settings-theme-system = Seguir o sistema +settings-variant-heading = Cores +settings-variant-site = Site +settings-variant-warm = Quente +settings-variant-cool = Frio +settings-variant-green = Verde +settings-variant-mono = Monocromático +settings-language-heading = Idioma +settings-language-system = Seguir o sistema + +## Languages + +# Every language is named in its own words, so a reader who cannot read the +# current interface can still find theirs. Leave these untranslated. +language-en = English +language-de = Deutsch +language-es = Español +language-fr = Français +language-he = עברית +language-ja = 日本語 +language-pt = Português +language-ru = Русский + +## Forms + +# Default label for a page form's submit control when the author gave none. +form-submit-default = Enviar +form-reset-default = Repor + +## Errors + +error-load-failed = Não foi possível carregar a página. Verifica o endereço e a tua ligação e tenta de novo. +error-try-again = Tentar de novo +error-broken-page = Esta página tem um erro de marcação, por isso não pode ser mostrada. +error-broken-page-show-text = Mostrar o texto da página +error-broken-page-text-title = Texto da página +error-non-mews = Esta não é uma página Mews, por isso é mostrada de forma simples. +error-image-blocked = Esta imagem está noutro site e não é mostrada. +error-image-unavailable = Não foi possível carregar esta imagem. diff --git a/app/locales/ru/main.ftl b/app/locales/ru/main.ftl new file mode 100644 index 0000000..57b771e --- /dev/null +++ b/app/locales/ru/main.ftl @@ -0,0 +1,177 @@ +## Reader chrome + +reader-address-placeholder = Введите веб-адрес +# Accessibility label for the address field. +reader-address-label = Веб-адрес +reader-reload = Обновить эту страницу +reader-open-hub = Открыть ваши страницы Mews +reader-back = Назад +reader-forward = Вперёд +reader-bookmark-add = Добавить закладку +reader-bookmark-remove = Удалить закладку +reader-subscribe = Подписаться на этот сайт +reader-unsubscribe = Отписаться + +## Navigation + +# The strip of the app's own pages, carried by each of them. +nav-hub = Mews +nav-posts = Записи +nav-bookmarks = Закладки +nav-subscriptions = Подписки +nav-history = История +nav-tags = Метки +nav-settings = Настройки + +## Hub + +hub-title = Mews + +## Counts + +# Shown under an item. Format the number in code. +count-bookmarks-none = Закладок пока нет +count-bookmarks-one = { $count } закладка +count-bookmarks-many = { $count } закладок +count-bookmarks-few = { $count } закладки +count-sites-none = Сайтов пока нет +count-sites-one = { $count } сайт +count-sites-many = { $count } сайтов +count-sites-few = { $count } сайта +count-pages-none = Страниц пока нет +count-pages-one = { $count } страница +count-pages-many = { $count } страниц +count-pages-few = { $count } страницы +count-tags-none = Меток пока нет +count-tags-one = { $count } метка +count-tags-many = { $count } меток +count-tags-few = { $count } метки +count-unread-none = Непрочитанных записей нет +count-unread-one = { $count } непрочитанная запись +count-unread-many = { $count } непрочитанных записей +count-unread-few = { $count } непрочитанные записи + +## Posts + +posts-title = Записи +posts-empty = Здесь появятся записи сайтов, на которые вы подписаны. +posts-none-unread = Вы всё прочитали. +posts-checking = Идёт проверка новых записей. +posts-sort-label = Сортировка +posts-sort-date = По дате +posts-sort-source = По сайту +posts-sort-unread = Непрочитанные сверху +posts-show-label = Показывать +posts-show-all = Все записи +posts-show-unread = Только непрочитанные +posts-mark-read = Отметить прочитанной +posts-mark-unread = Отметить непрочитанной + +## Actions + +# Link labels used on more than one page. +action-refresh = Проверить новые записи +action-search = Искать +action-clear-search = Очистить поиск +action-remove-bookmark = Удалить закладку +action-unsubscribe = Отписаться +action-edit-tags = Изменить метки + +## Library pages + +# Internal pages rendered like ordinary Mews pages. +history-title = История +history-empty = Здесь появятся страницы, которые вы открываете. +history-no-matches = Ни одна страница не найдена по запросу. +history-search-label = Поиск по истории +history-forget = Удалить из истории +history-clear = Очистить историю +history-clear-confirm = При очистке истории будут удалены все посещённые страницы. Это действие необратимо. +history-clear-yes = Очистить историю +history-clear-no = Оставить историю +bookmarks-title = Закладки +bookmarks-empty = Здесь появятся страницы, которые вы добавите в закладки. +bookmarks-no-matches = Ни одна закладка не найдена по запросу. +bookmarks-search-label = Поиск по закладкам +subscriptions-title = Подписки +subscriptions-empty = Здесь появятся сайты, на которые вы подпишетесь. +subscriptions-no-matches = Ни один сайт не найден по запросу. +subscriptions-search-label = Поиск по сайтам +# When a site was last checked for posts. Format the date in code. +subscriptions-checked = Последняя проверка { $date } +subscriptions-never-checked = Ещё не проверялся + +## Tags + +tags-title = Метки +tags-empty = Здесь появятся метки, которые вы добавите к закладкам и сайтам. +# The item being tagged. Shows its title, or its address until it has one. +tags-assign-title = Метки для { $name } +tags-add-label = Добавить метку +tags-add-submit = Добавить +tags-on-item = У этого элемента +tags-other = Другие метки +tags-remove = Убрать эту метку +tags-delete = Удалить эту метку везде +tags-done = Готово +bookmarks-show-all = Показать все закладки +# Shown when a tag narrows the list. +bookmarks-tagged = Закладки с меткой { $tag } +subscriptions-show-all = Показать все сайты +subscriptions-tagged = Сайты с меткой { $tag } + +## Settings page + +settings-page-title = Настройки +settings-font-heading = Шрифт +settings-font-atkinson = Atkinson Hyperlegible +settings-font-luciole = Luciole +settings-font-literata = Literata +settings-font-charis = Charis SIL +settings-size-heading = Размер текста +settings-size-decrease = Мельче +settings-size-increase = Крупнее +# The text size now in use. Format the number in code. +settings-size-value = { $percent }% +settings-theme-heading = Оформление +settings-theme-light = Светлое +settings-theme-dark = Тёмное +settings-theme-system = Как в системе +settings-variant-heading = Цвета +settings-variant-site = Сайта +settings-variant-warm = Тёплые +settings-variant-cool = Холодные +settings-variant-green = Зелёные +settings-variant-mono = Монохромные +settings-language-heading = Язык +settings-language-system = Как в системе + +## Languages + +# Every language is named in its own words, so a reader who cannot read the +# current interface can still find theirs. Leave these untranslated. +language-en = English +language-de = Deutsch +language-es = Español +language-fr = Français +language-he = עברית +language-ja = 日本語 +language-pt = Português +language-ru = Русский + +## Forms + +# Default label for a page form's submit control when the author gave none. +form-submit-default = Отправить +form-reset-default = Сбросить + +## Errors + +error-load-failed = Не удалось загрузить страницу. Проверьте адрес и подключение, затем повторите попытку. +error-try-again = Повторить попытку +error-broken-page = На этой странице ошибка разметки, поэтому показать её нельзя. +error-broken-page-show-text = Показать текст страницы +error-broken-page-text-title = Текст страницы +error-non-mews = Это не страница Mews, поэтому она показана простым текстом. +error-image-blocked = Это изображение находится на другом сайте и не показывается. +error-image-unavailable = Не удалось загрузить изображение. diff --git a/app/pubspec.lock b/app/pubspec.lock index c5f351f..9d7dada 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -254,6 +254,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -311,10 +316,10 @@ packages: dependency: transitive description: name: intl - sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.20.3" + version: "0.20.2" io: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 68cd453..4787ac2 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -10,6 +10,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter fluent: ^2.0.4 signals: ^7.1.0 @@ -34,6 +36,13 @@ flutter: assets: - locales/en-US/main.ftl + - locales/de/main.ftl + - locales/es/main.ftl + - locales/fr/main.ftl + - locales/he/main.ftl + - locales/ja/main.ftl + - locales/pt/main.ftl + - locales/ru/main.ftl fonts: - family: Atkinson Hyperlegible diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index 9a99d1f..cfa6122 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -245,6 +245,50 @@ void main() { expect(deps.browser.error.value, isNull); }); + testWidgets('choosing a language changes the interface in place', + (tester) async { + await pumpApp(tester); + await deps.browser.open(MewsUrls.settings); + await tester.pumpAndSettle(); + expect(find.text('Settings'), findsOneWidget); + + // The language group sits below the fold. + Future scrollTo(String label) async { + final target = find.textContaining(label); + for (var i = 0; i < 30 && target.evaluate().isEmpty; i++) { + await tester.drag(find.byType(ListView), const Offset(0, -200)); + await tester.pumpAndSettle(); + } + await tester.ensureVisible(target.first); + await tester.pumpAndSettle(); + } + + // Each language is named in its own words, so it can be found from an + // interface the reader cannot read. + await scrollTo('Deutsch'); + final stepsBack = deps.browser.canGoBack; + await tapRun(tester, 'Deutsch'); + expect(deps.settings.language.value, 'de'); + // Applied in place, like the other reader choices. + expect(deps.browser.currentPage.value?.url, MewsUrls.settings); + expect(deps.browser.canGoBack, stepsBack); + expect(find.text('Einstellungen'), findsOneWidget); + expect(find.text('Settings'), findsNothing); + + // Hebrew turns the whole page around. + await scrollTo('עברית'); + await tapRun(tester, 'עברית'); + expect(deps.settings.language.value, 'he'); + expect(deps.browser.currentPage.value?.direction, TextDirection.rtl); + + // Back to English. + await scrollTo('English'); + await tapRun(tester, 'English'); + expect(deps.settings.language.value, 'en-US'); + expect(deps.browser.currentPage.value?.direction, TextDirection.ltr); + expect(find.text('Settings'), findsOneWidget); + }); + testWidgets('bare mews: and an unknown name resolve predictably', (tester) async { await pumpApp(tester); diff --git a/app/test/locales_test.dart b/app/test/locales_test.dart new file mode 100644 index 0000000..c2196ed --- /dev/null +++ b/app/test/locales_test.dart @@ -0,0 +1,162 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/l10n/locales.dart'; +import 'package:mews/l10n/strings.dart'; + +final _message = RegExp(r'^([a-z0-9-]+) = (.*)$', multiLine: true); +final _placeholder = RegExp(r'\{ \$([a-z]+) \}'); + +Map _messagesOf(String tag) { + final source = File('locales/$tag/main.ftl').readAsStringSync(); + return { + for (final m in _message.allMatches(source)) m.group(1)!: m.group(2)!, + }; +} + +void main() { + final english = _messagesOf(fallbackLocale); + + test('every shipped language has a file declared as an asset', () { + final pubspec = File('pubspec.yaml').readAsStringSync(); + for (final locale in appLocales) { + expect(File('locales/${locale.tag}/main.ftl').existsSync(), isTrue, + reason: '${locale.tag} has no messages'); + expect(pubspec, contains('locales/${locale.tag}/main.ftl'), + reason: '${locale.tag} is not bundled as an asset'); + } + }); + + test('every language says everything English says', () { + for (final locale in appLocales) { + if (locale.tag == fallbackLocale) continue; + final messages = _messagesOf(locale.tag); + for (final id in english.keys) { + expect(messages, contains(id), + reason: '${locale.tag} is missing $id'); + expect(messages[id], isNotEmpty, reason: '${locale.tag}: $id is empty'); + } + // The only messages a language may add are plural forms English has no + // use for, such as Russian's few or Hebrew's two. + for (final id in messages.keys.where((i) => !english.containsKey(i))) { + expect(id, matches(r'-(two|few)$'), + reason: '${locale.tag} defines an unknown $id'); + expect(english, contains(id.replaceFirst(RegExp(r'-(two|few)$'), '-many')), + reason: '$id has no English counterpart to fall back on'); + } + } + }); + + test('every language keeps the placeholders a message needs', () { + for (final locale in appLocales) { + if (locale.tag == fallbackLocale) continue; + final messages = _messagesOf(locale.tag); + for (final entry in english.entries) { + final wanted = _placeholder + .allMatches(entry.value) + .map((m) => m.group(1)) + .toSet(); + final got = _placeholder + .allMatches(messages[entry.key]!) + .map((m) => m.group(1)) + .toSet(); + // A dropped placeholder loses the number or name it carried. + expect(got, containsAll(wanted), + reason: '${locale.tag}: ${entry.key} drops a placeholder'); + // An invented one renders as nothing. The exception is the count on a + // singular message: English writes "1 bookmark", but a language whose + // singular also covers 21 and 31 has to show the number. + final extra = got.difference(wanted); + expect( + extra.isEmpty || + (extra.length == 1 && + extra.first == 'count' && + entry.key.endsWith('-one')), + isTrue, + reason: '${locale.tag}: ${entry.key} adds $extra'); + } + } + }); + + test('a language naming a plural form defines every one it needs', () { + // The plural rules decide which suffix a count asks for; a language whose + // rules can produce a form it never defines would fall back to English. + for (final locale in appLocales) { + final messages = _messagesOf(locale.tag); + final stems = english.keys + .where((id) => id.endsWith('-many')) + .map((id) => id.substring(0, id.length - '-many'.length)); + for (final stem in stems) { + for (var count = 1; count <= 200; count++) { + final suffix = pluralSuffix(locale.tag, count); + if (suffix == 'one' || suffix == 'many') continue; + expect(messages, contains('$stem-$suffix'), + reason: '${locale.tag} needs $stem-$suffix for $count'); + } + } + } + }); + + group('choosing a language', () { + test('matches on the language alone, so a region still fits', () { + expect(matchLocale('de-AT'), 'de'); + expect(matchLocale('pt-BR'), 'pt'); + expect(matchLocale('en-GB'), 'en-US'); + expect(matchLocale('en-US'), 'en-US'); + expect(matchLocale('is'), isNull); + }); + + test('takes the first of the device languages it ships', () { + expect(resolveLocale(['is', 'fo', 'ru-RU']), 'ru'); + expect(resolveLocale(['is', 'fo']), fallbackLocale); + expect(resolveLocale([]), fallbackLocale); + }); + + test('knows which languages mirror', () { + expect(isRightToLeft('he'), isTrue); + expect(isRightToLeft('en-US'), isFalse); + }); + }); + + group('plural rules', () { + test('Russian separates two to four from the rest', () { + expect(pluralSuffix('ru', 1), 'one'); + expect(pluralSuffix('ru', 21), 'one'); + expect(pluralSuffix('ru', 11), 'many'); + expect(pluralSuffix('ru', 2), 'few'); + expect(pluralSuffix('ru', 24), 'few'); + expect(pluralSuffix('ru', 12), 'many'); + expect(pluralSuffix('ru', 5), 'many'); + }); + + test('Hebrew has a form for two', () { + expect(pluralSuffix('he', 1), 'one'); + expect(pluralSuffix('he', 2), 'two'); + expect(pluralSuffix('he', 3), 'many'); + }); + + test('French and Portuguese read one for a single thing', () { + expect(pluralSuffix('fr', 1), 'one'); + expect(pluralSuffix('fr', 2), 'many'); + expect(pluralSuffix('pt', 1), 'one'); + }); + + test('Japanese has one form', () { + expect(pluralSuffix('ja', 1), 'many'); + expect(pluralSuffix('ja', 7), 'many'); + }); + }); + + test('a message missing from a language falls back to English', () { + final strings = Strings() + ..loadSource(File('locales/en-US/main.ftl').readAsStringSync()) + ..loadSource('nav-posts = Beiträge', tag: 'de'); + + expect(strings.t('nav-posts'), 'Beiträge'); + // Not translated in this stub, so English stands in rather than an id. + expect(strings.t('nav-bookmarks'), 'Bookmarks'); + expect(strings.t('no-such-message'), 'no-such-message'); + expect(strings.lookup('no-such-message'), isNull); + }); +}