From f3e967518f28038ad1c072da62c8696192843ad1 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 12 Aug 2026 17:55:00 +0200 Subject: [PATCH 1/2] feat(manager): explain import outcomes --- apps/mod-manager/lib/core/mgr_ffi.dart | 246 ++++- apps/mod-manager/lib/home_page.dart | 157 ++- apps/mod-manager/lib/l10n/app_de.arb | 8 + apps/mod-manager/lib/l10n/app_en.arb | 28 + apps/mod-manager/lib/l10n/app_es.arb | 8 + apps/mod-manager/lib/l10n/app_fr.arb | 8 + apps/mod-manager/lib/l10n/app_it.arb | 8 + apps/mod-manager/lib/l10n/app_ja.arb | 8 + .../lib/l10n/app_localizations.dart | 48 + .../lib/l10n/app_localizations_de.dart | 44 + .../lib/l10n/app_localizations_en.dart | 43 + .../lib/l10n/app_localizations_es.dart | 43 + .../lib/l10n/app_localizations_fr.dart | 44 + .../lib/l10n/app_localizations_it.dart | 43 + .../lib/l10n/app_localizations_ja.dart | 43 + .../lib/l10n/app_localizations_pl.dart | 44 + .../lib/l10n/app_localizations_pt.dart | 88 ++ .../lib/l10n/app_localizations_ru.dart | 44 + .../lib/l10n/app_localizations_zh.dart | 82 ++ apps/mod-manager/lib/l10n/app_pl.arb | 8 + apps/mod-manager/lib/l10n/app_pt.arb | 8 + apps/mod-manager/lib/l10n/app_pt_BR.arb | 8 + apps/mod-manager/lib/l10n/app_ru.arb | 8 + apps/mod-manager/lib/l10n/app_zh.arb | 8 + apps/mod-manager/lib/l10n/app_zh_Hans.arb | 8 + .../lib/library/domain/library_notifier.dart | 69 +- .../lib/library/ui/import_feedback.dart | 221 ++++ .../lib/library/ui/import_source_picker.dart | 34 + apps/mod-manager/test/core/mgr_ffi_test.dart | 235 ++++- .../test/home_page_import_test.dart | 995 ++++++++++++++++++ .../test/library/library_notifier_test.dart | 140 ++- 31 files changed, 2690 insertions(+), 89 deletions(-) create mode 100644 apps/mod-manager/lib/library/ui/import_feedback.dart create mode 100644 apps/mod-manager/lib/library/ui/import_source_picker.dart create mode 100644 apps/mod-manager/test/home_page_import_test.dart diff --git a/apps/mod-manager/lib/core/mgr_ffi.dart b/apps/mod-manager/lib/core/mgr_ffi.dart index 7fa5dd966..f6b0297bf 100644 --- a/apps/mod-manager/lib/core/mgr_ffi.dart +++ b/apps/mod-manager/lib/core/mgr_ffi.dart @@ -2,6 +2,9 @@ import '../library/domain/models.dart'; import '../preflight/domain/models.dart'; import 'core_service.dart'; +const _maxImportErrorCandidates = 2; +const _maxImportCandidateRunes = 256; + /// Typed wrappers over the gore-ffi mod-manager commands (`mgr_*`). /// /// Every command returns the standard envelope `{ok, ...}` / @@ -23,7 +26,11 @@ class MgrFfi { final String code when code.isNotEmpty => code, _ => 'UNKNOWN', }; - throw MgrFfiException('$cmd: ${e['message'] ?? e}', code: code); + throw MgrFfiException( + '$cmd: ${e['message'] ?? e}', + code: code, + details: _mgrErrorDetails(code, e['details']), + ); } // Non-map error value (string, number, null, ...): stringify it. throw MgrFfiException('$cmd: ${e ?? 'unknown error'}'); @@ -42,22 +49,12 @@ class MgrFfi { final mods = []; final modIds = {}; for (final raw in rawMods) { - if (raw is! Map || - raw['id'] is! String || - raw['kind'] is! String || - raw['name'] is! String || - raw['components'] is! List || - (raw['components']! as List).any( - (component) => component is! Map || component['type'] is! String, - )) { - throw MgrFfiException('mgr_library_list: malformed library entry'); - } - final parsed = raw.cast(); - final id = parsed['id']! as String; + final parsed = _libraryEntry(raw, command: 'mgr_library_list'); + final id = parsed.id; if (id.isEmpty || !modIds.add(id)) { throw MgrFfiException('mgr_library_list: invalid library id set'); } - mods.add(ModEntryMetaView.fromJson(parsed)); + mods.add(parsed); } final loadoutMap = rawLoadout.cast(); final format = loadoutMap['format']; @@ -81,14 +78,30 @@ class MgrFfi { return (mods, LoadoutView.fromJson(loadoutMap)); } - /// Import a mod file/folder into the library; returns its library entry. - Future import(String path) async { + /// Import a mod file/folder into the library and retain Native's exact + /// disposition and verified match method. + Future import(String path) async { final r = await _call('mgr_import', {'path': path}); final entry = r['entry']; - if (entry is! Map) { - throw MgrFfiException('mgr_import: response is missing entry'); + final disposition = MgrImportDisposition.fromWire(r['disposition']); + final matchedBy = MgrImportMatchedBy.fromWire(r['matched_by']); + if (disposition == null || + matchedBy == null || + !_validImportOutcome(disposition, matchedBy)) { + throw MgrFfiException( + 'mgr_import: malformed import outcome', + code: 'IMPORT_INVALID_RESPONSE', + ); } - return ModEntryMetaView.fromJson(entry.cast()); + return MgrImportOutcome( + entry: _libraryEntry( + entry, + command: 'mgr_import', + errorCode: 'IMPORT_INVALID_RESPONSE', + ), + disposition: disposition, + matchedBy: matchedBy, + ); } /// Remove a mod from the library. True when an entry was actually removed. @@ -146,6 +159,15 @@ class MgrFfi { } } +bool _validImportOutcome( + MgrImportDisposition disposition, + MgrImportMatchedBy matchedBy, +) => switch (disposition) { + MgrImportDisposition.created => matchedBy == MgrImportMatchedBy.none, + MgrImportDisposition.updated || + MgrImportDisposition.unchanged => matchedBy != MgrImportMatchedBy.none, +}; + /// True for `true` and for positive counts — tolerates the Rust side /// reporting `removed` as either a bool or a count. bool _truthy(Object? value) => value == true || (value is num && value > 0); @@ -155,8 +177,188 @@ List> _maps(Object? value) => value is List ? [for (final item in value.whereType()) item.cast()] : const []; +ModEntryMetaView _libraryEntry( + Object? raw, { + required String command, + String errorCode = 'UNKNOWN', +}) { + if (raw is! Map || + raw.keys.any((key) => key is! String) || + raw['id'] is! String || + (raw['id']! as String).isEmpty || + raw['kind'] is! String || + raw['name'] is! String || + raw['components'] is! List || + (raw['components']! as List).any( + (component) => component is! Map || component['type'] is! String, + )) { + throw MgrFfiException('$command: malformed library entry', code: errorCode); + } + return ModEntryMetaView.fromJson(raw.cast()); +} + +MgrFfiErrorDetails? _mgrErrorDetails(String code, Object? raw) { + if (raw is! Map || raw.keys.any((key) => key is! String)) return null; + final details = raw.cast(); + return switch (code) { + 'IMPORT_DUPLICATE_AMBIGUOUS' => _duplicateImportDetails(details), + 'IMPORT_IDENTITY_CONFLICT' => _identityConflictDetails(details), + _ => null, + }; +} + +MgrImportDuplicateAmbiguousDetails? _duplicateImportDetails( + Map details, +) { + final rawCandidates = details['candidate_ids']; + if (rawCandidates is! List || + rawCandidates.isEmpty || + rawCandidates.length > _maxImportErrorCandidates) { + return null; + } + final candidates = []; + for (final raw in rawCandidates) { + final id = _boundedImportCandidate(raw); + if (id == null) return null; + candidates.add(MgrImportCandidate(id: id)); + } + return MgrImportDuplicateAmbiguousDetails( + candidates: List.unmodifiable(candidates), + ); +} + +MgrImportIdentityConflictDetails? _identityConflictDetails( + Map details, +) { + final rawCandidates = details['candidates']; + if (rawCandidates is! List || + rawCandidates.isEmpty || + rawCandidates.length > _maxImportErrorCandidates) { + return null; + } + final candidates = []; + for (final raw in rawCandidates) { + if (raw is! Map || raw.keys.any((key) => key is! String)) return null; + final candidate = raw.cast(); + final id = _boundedImportCandidate(candidate['id']); + final rawMatchedBy = candidate['matched_by']; + if (id == null || + rawMatchedBy is! List || + rawMatchedBy.isEmpty || + rawMatchedBy.length > 3) { + return null; + } + final matchedBy = []; + for (final role in rawMatchedBy) { + final parsed = MgrImportMatchedBy.fromWire(role); + if (parsed == null || + parsed == MgrImportMatchedBy.none || + matchedBy.contains(parsed)) { + return null; + } + matchedBy.add(parsed); + } + candidates.add( + MgrImportCandidate(id: id, matchedBy: List.unmodifiable(matchedBy)), + ); + } + return MgrImportIdentityConflictDetails( + candidates: List.unmodifiable(candidates), + ); +} + +String? _boundedImportCandidate(Object? raw) { + if (raw is! String || raw.isEmpty) return null; + final runes = raw.runes.take(_maxImportCandidateRunes + 1).toList(); + if (runes.length > _maxImportCandidateRunes) return null; + return raw; +} + +enum MgrImportDisposition { + created, + updated, + unchanged; + + static MgrImportDisposition? fromWire(Object? value) => switch (value) { + 'created' => created, + 'updated' => updated, + 'unchanged' => unchanged, + _ => null, + }; +} + +enum MgrImportMatchedBy { + none, + source, + content, + entryId; + + static MgrImportMatchedBy? fromWire(Object? value) => switch (value) { + 'none' => none, + 'source' => source, + 'content' => content, + 'entry_id' => entryId, + _ => null, + }; + + String get wireName => switch (this) { + none => 'none', + source => 'source', + content => 'content', + entryId => 'entry_id', + }; +} + +class MgrImportOutcome { + const MgrImportOutcome({ + required this.entry, + required this.disposition, + required this.matchedBy, + }); + + final ModEntryMetaView entry; + final MgrImportDisposition disposition; + final MgrImportMatchedBy matchedBy; + + MgrImportOutcome withEntry(ModEntryMetaView authoritativeEntry) => + MgrImportOutcome( + entry: authoritativeEntry, + disposition: disposition, + matchedBy: matchedBy, + ); +} + +sealed class MgrFfiErrorDetails { + const MgrFfiErrorDetails(); +} + +sealed class MgrImportRefusalDetails extends MgrFfiErrorDetails { + const MgrImportRefusalDetails(this.candidates); + + final List candidates; +} + +class MgrImportDuplicateAmbiguousDetails extends MgrImportRefusalDetails { + const MgrImportDuplicateAmbiguousDetails({ + required List candidates, + }) : super(candidates); +} + +class MgrImportIdentityConflictDetails extends MgrImportRefusalDetails { + const MgrImportIdentityConflictDetails({ + required List candidates, + }) : super(candidates); +} + +class MgrImportCandidate { + const MgrImportCandidate({this.id = '', this.matchedBy = const []}); + + final String id; + final List matchedBy; +} + class MgrFfiException implements Exception { - MgrFfiException(this.message, {this.code = 'UNKNOWN'}); + MgrFfiException(this.message, {this.code = 'UNKNOWN', this.details}); final String message; @@ -165,6 +367,10 @@ class MgrFfiException implements Exception { /// STUDIO_DEPLOY_ACTIVE. final String code; + /// Machine-readable, bounded facts for known failures. Unknown or malformed + /// detail objects stay null; callers never recover facts from [message]. + final MgrFfiErrorDetails? details; + @override String toString() => message; } diff --git a/apps/mod-manager/lib/home_page.dart b/apps/mod-manager/lib/home_page.dart index 5056bcf03..bbfc75bd8 100644 --- a/apps/mod-manager/lib/home_page.dart +++ b/apps/mod-manager/lib/home_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,11 +9,14 @@ import 'app/ui/about_dialog.dart'; import 'app/ui/window_chrome.dart'; import 'conflicts/ui/conflict_panel.dart'; import 'core/diagnostic_text.dart'; +import 'core/mgr_ffi.dart'; import 'l10n/app_localizations.dart'; import 'library/domain/conflicts_provider.dart'; import 'library/domain/library_notifier.dart'; import 'library/domain/models.dart'; import 'library/ui/detail_panel.dart'; +import 'library/ui/import_feedback.dart'; +import 'library/ui/import_source_picker.dart'; import 'library/ui/mod_list.dart'; import 'preflight/domain/models.dart'; import 'preflight/domain/preflight_notifier.dart'; @@ -37,7 +39,12 @@ bool _preflightFindingUsesRetry(PreflightCheckView finding) => /// Home: a Mods tab (library list + detail + conflicts, with an import/apply /// action bar) and the unchanged Settings tab. class HomePage extends ConsumerStatefulWidget { - const HomePage({super.key}); + const HomePage({ + super.key, + this.importSourcePicker = const FileSelectorImportSourcePicker(), + }); + + final ImportSourcePicker importSourcePicker; @override ConsumerState createState() => _HomePageState(); @@ -61,6 +68,8 @@ class _HomePageState extends ConsumerState { ); ({FocusNode node, String? removedModId})? _pendingFocus; int _pendingFocusGeneration = 0; + int _selectionGeneration = 0; + bool _importRequestActive = false; ({String root, int generation})? _preflightRetryFocusRequest; String? get _gameRoot => gameRootFromExe(ref.read(gameExePathProvider)); @@ -141,6 +150,86 @@ class _HomePageState extends ConsumerState { : null; } + bool get _importAuthorityAvailable { + final library = ref.read(libraryProvider); + return !library.busy && + library.authoritative && + !ref.read(statusProvider).busy && + !ref.read(preflightProvider).busy && + !ref.read(conflictsProvider).isLoading; + } + + bool get _canStartImport => + !_importRequestActive && _importAuthorityAvailable; + + Future _importFolder() => + _pickAndImport(() => widget.importSourcePicker.pickFolder()); + + Future _importFile() => _pickAndImport( + () => widget.importSourcePicker.pickFile( + dialogLabel: AppLocalizations.of(context).actionImport, + ), + ); + + Future _pickAndImport(Future Function() pick) async { + if (!_canStartImport) return; + final selectionTicket = _selectionGeneration; + setState(() => _importRequestActive = true); + try { + String? path; + try { + path = await pick(); + } catch (error) { + if (!mounted) return; + showImportFailureFeedback( + context, + MgrFfiException( + 'import picker: $error', + code: 'IMPORT_PICKER_FAILED', + ), + ref.read(libraryProvider), + ); + return; + } + if (!mounted || + path == null || + path.trim().isEmpty || + !_importAuthorityAvailable) { + return; + } + + MgrImportOutcome? outcome; + try { + outcome = await ref.read(libraryProvider.notifier).import(path); + } on MgrFfiException catch (error) { + if (!mounted) return; + final library = ref.read(libraryProvider); + showImportFailureFeedback(context, error, library); + return; + } catch (error) { + if (!mounted) return; + final library = ref.read(libraryProvider); + showImportFailureFeedback( + context, + MgrFfiException('$error', code: 'IMPORT_UNKNOWN_FAILURE'), + library, + ); + return; + } + if (!mounted || outcome == null) return; + + final library = ref.read(libraryProvider); + if (selectionTicket == _selectionGeneration && + library.authoritative && + library.modById(outcome.entry.id) != null) { + ref.read(selectedModProvider.notifier).state = outcome.entry.id; + } + showImportSuccessFeedback(context, outcome); + } finally { + if (mounted) setState(() => _importRequestActive = false); + } + } + void _settlePreflightRetryFocus( PreflightState? previous, PreflightState next, @@ -257,6 +346,7 @@ class _HomePageState extends ConsumerState { _refreshPreflightWhenIdle(); }); ref.listen(selectedModProvider, (previous, next) { + _selectionGeneration++; final pending = _pendingFocus; if (pending?.removedModId != null && next != null && @@ -365,6 +455,9 @@ class _HomePageState extends ConsumerState { children: [ _ModsTab( importFocusNode: _importFocusNode, + importFolder: _importFolder, + importFile: _importFile, + importRequestActive: _importRequestActive, libraryRefreshFocusNode: _libraryRefreshFocusNode, statusDetailsFocusNode: _statusDetailsFocusNode, settingsGamePathFocusNode: _settingsGamePathFocusNode, @@ -389,6 +482,9 @@ class _HomePageState extends ConsumerState { class _ModsTab extends ConsumerWidget { const _ModsTab({ required this.importFocusNode, + required this.importFolder, + required this.importFile, + required this.importRequestActive, required this.libraryRefreshFocusNode, required this.statusDetailsFocusNode, required this.settingsGamePathFocusNode, @@ -400,6 +496,9 @@ class _ModsTab extends ConsumerWidget { }); final FocusNode importFocusNode; + final Future Function() importFolder; + final Future Function() importFile; + final bool importRequestActive; final FocusNode libraryRefreshFocusNode; final FocusNode statusDetailsFocusNode; final FocusNode settingsGamePathFocusNode; @@ -412,38 +511,6 @@ class _ModsTab extends ConsumerWidget { String? _gameRoot(WidgetRef ref) => gameRootFromExe(ref.read(gameExePathProvider)); - Future _importFolder(WidgetRef ref) async { - final path = await getDirectoryPath(); - final library = ref.read(libraryProvider); - if (path == null || - library.busy || - !library.authoritative || - ref.read(statusProvider).busy || - ref.read(preflightProvider).busy || - ref.read(conflictsProvider).isLoading) { - return; - } - await ref.read(libraryProvider.notifier).import(path); - } - - Future _importFile(WidgetRef ref, AppLocalizations l10n) async { - final group = XTypeGroup( - label: l10n.actionImport, - extensions: const ['zip', 'pak', 'utoc', 'lcache', 'bank', 'Cache'], - ); - final file = await openFile(acceptedTypeGroups: [group]); - final library = ref.read(libraryProvider); - if (file == null || - library.busy || - !library.authoritative || - ref.read(statusProvider).busy || - ref.read(preflightProvider).busy || - ref.read(conflictsProvider).isLoading) { - return; - } - await ref.read(libraryProvider.notifier).import(file.path); - } - Future _apply( BuildContext context, WidgetRef ref, @@ -713,7 +780,8 @@ class _ModsTab extends ConsumerWidget { final conflictCount = conflicts.value?.length ?? 0; final operationsBusy = library.busy || status.busy || preflight.busy || conflicts.isLoading; - final libraryMutationsBlocked = operationsBusy || !library.authoritative; + final libraryMutationsBlocked = + importRequestActive || operationsBusy || !library.authoritative; final compactConflictPanel = media.size.height < 560 && (media.size.width < 784 || media.textScaler.scale(1) > 1.35); @@ -754,15 +822,19 @@ class _ModsTab extends ConsumerWidget { ), menuChildren: [ MenuItemButton( + key: const ValueKey('import-folder-action'), leadingIcon: const Icon(Icons.folder_open), - onPressed: libraryMutationsBlocked ? null : () => _importFolder(ref), + onPressed: libraryMutationsBlocked + ? null + : () => unawaited(importFolder()), child: Text(l10n.importFolder), ), MenuItemButton( + key: const ValueKey('import-file-action'), leadingIcon: const Icon(Icons.insert_drive_file_outlined), onPressed: libraryMutationsBlocked ? null - : () => _importFile(ref, l10n), + : () => unawaited(importFile()), child: Text(l10n.importFile), ), ], @@ -1230,11 +1302,12 @@ class _InfoBanner extends ConsumerWidget { ); } if (library.error != null) { + final error = _boundedDiagnostic(library.error!); children.add( _line( theme, Icons.error_outline, - library.error!, + error, theme.colorScheme.error, action: TextButton.icon( key: const ValueKey('library-refresh-action'), @@ -1377,6 +1450,14 @@ class _InfoBanner extends ConsumerWidget { return '$friendly $value${bounded.truncated ? '…' : ''}'; } + String _boundedDiagnostic(String raw) { + final bounded = boundedDiagnosticText(raw, 512); + return switch (bounded.value) { + final value? => '$value${bounded.truncated ? '…' : ''}', + null => '—', + }; + } + Widget _line( ThemeData theme, IconData icon, diff --git a/apps/mod-manager/lib/l10n/app_de.arb b/apps/mod-manager/lib/l10n/app_de.arb index 2563e618d..04c1eb1a3 100644 --- a/apps/mod-manager/lib/l10n/app_de.arb +++ b/apps/mod-manager/lib/l10n/app_de.arb @@ -71,6 +71,14 @@ "commonOk": "OK", "importFolder": "Ordner importieren…", "importFile": "Datei importieren…", + "importOutcomeCreated": "„{name}“ wurde zur Bibliothek hinzugefügt.", + "importOutcomeUpdated": "„{name}“ wurde in der Bibliothek aktualisiert.", + "importOutcomeUnchanged": "„{name}“ ist bereits in der Bibliothek vorhanden.", + "importOutcomeMatchedBy": "{method, select, none {Es gab keine Übereinstimmung mit einem vorhandenen Bibliothekseintrag.} source {Übereinstimmung anhand derselben Importquelle.} content {Übereinstimmung anhand nachweislich identischer Inhalte.} entry_id {Übereinstimmung anhand der Mod-ID.} other {Details zur Übereinstimmung sind nicht verfügbar.}}", + "importRefusalDuplicateAmbiguous": "Dieser Import stimmt mit mehreren Bibliothekseinträgen überein. Überprüfe oder entferne die Duplikate und versuche es erneut.", + "importRefusalIdentityConflict": "Die Importquelle und der Inhalt stimmen mit unterschiedlichen Bibliothekseinträgen überein. Überprüfe oder entferne die betroffenen Einträge und versuche es erneut.", + "importFailed": "Der Import konnte nicht abgeschlossen werden. Unterstützte Quellen: Ordner, ZIP, lose *_P.pak-Dateien, vollständige .utoc/.ucas-Dateisätze (optional .pak), .lcache, .bank und PrecompiledScript*.Cache. Entpacke .7z oder .rar zuerst und importiere dann den Ordner. Die Quelle wird möglicherweise nicht unterstützt, ist beschädigt oder unvollständig. Der Mod wurde möglicherweise bereits hinzugefügt oder aktualisiert; aktualisiere und überprüfe die Bibliothek, bevor du es erneut versuchst.", + "importOutcomeUnknown": "Das Importergebnis konnte nicht überprüft werden. Wähle „Aktualisieren“, um die Bibliothek zu prüfen.", "applyTooltip": "Ladeliste auf das Spiel anwenden", "undeployAllAction": "Alle Bereitstellungen aufheben", "undeployAllConfirm": "Alles vom Manager Bereitgestellte aus dem Spiel entfernen?", diff --git a/apps/mod-manager/lib/l10n/app_en.arb b/apps/mod-manager/lib/l10n/app_en.arb index a28ba889a..59a90b94d 100644 --- a/apps/mod-manager/lib/l10n/app_en.arb +++ b/apps/mod-manager/lib/l10n/app_en.arb @@ -105,6 +105,34 @@ "commonOk": "OK", "importFolder": "Import folder…", "importFile": "Import file…", + "importOutcomeCreated": "Added “{name}” to the library.", + "@importOutcomeCreated": { + "placeholders": { + "name": { "type": "String" } + } + }, + "importOutcomeUpdated": "Updated “{name}” in the library.", + "@importOutcomeUpdated": { + "placeholders": { + "name": { "type": "String" } + } + }, + "importOutcomeUnchanged": "“{name}” is already in the library.", + "@importOutcomeUnchanged": { + "placeholders": { + "name": { "type": "String" } + } + }, + "importOutcomeMatchedBy": "{method, select, none {No existing library entry was matched.} source {Matched by the same import source.} content {Matched by verified identical content.} entry_id {Matched by mod ID.} other {Match details are unavailable.}}", + "@importOutcomeMatchedBy": { + "placeholders": { + "method": { "type": "String" } + } + }, + "importRefusalDuplicateAmbiguous": "This import matches more than one library entry. Review or remove the duplicates, then try again.", + "importRefusalIdentityConflict": "The import source and its content match different library entries. Review or remove the conflicting entries, then try again.", + "importFailed": "The import could not be completed. Supported sources: folders, ZIP, loose *_P.pak files, complete .utoc/.ucas sets (optional .pak), .lcache, .bank, and PrecompiledScript*.Cache. Extract .7z or .rar first, then import the folder. The source may be unsupported, corrupt, or incomplete. The mod may already have been added or updated; refresh and check the library before trying again.", + "importOutcomeUnknown": "The import result could not be verified. Choose Refresh to check the library.", "applyTooltip": "Apply the loadout to the game", "undeployAllAction": "Undeploy all", "undeployAllConfirm": "Remove everything the manager deployed from the game?", diff --git a/apps/mod-manager/lib/l10n/app_es.arb b/apps/mod-manager/lib/l10n/app_es.arb index fdfac02f2..91743a9f9 100644 --- a/apps/mod-manager/lib/l10n/app_es.arb +++ b/apps/mod-manager/lib/l10n/app_es.arb @@ -71,6 +71,14 @@ "commonOk": "OK", "importFolder": "Importar carpeta…", "importFile": "Importar archivo…", + "importOutcomeCreated": "Se añadió «{name}» a la biblioteca.", + "importOutcomeUpdated": "Se actualizó «{name}» en la biblioteca.", + "importOutcomeUnchanged": "«{name}» ya está en la biblioteca.", + "importOutcomeMatchedBy": "{method, select, none {No hubo coincidencias con entradas existentes de la biblioteca.} source {Coincidencia por la misma fuente de importación.} content {Coincidencia por contenido idéntico verificado.} entry_id {Coincidencia por el ID del mod.} other {Los detalles de la coincidencia no están disponibles.}}", + "importRefusalDuplicateAmbiguous": "Esta importación coincide con varias entradas de la biblioteca. Revisa o elimina los duplicados y vuelve a intentarlo.", + "importRefusalIdentityConflict": "La fuente de importación y su contenido coinciden con distintas entradas de la biblioteca. Revisa o elimina las entradas en conflicto y vuelve a intentarlo.", + "importFailed": "No se pudo completar la importación. Fuentes compatibles: carpetas, ZIP, archivos *_P.pak sueltos, conjuntos .utoc/.ucas completos (con .pak opcional), .lcache, .bank y PrecompiledScript*.Cache. Extrae primero los archivos .7z o .rar y después importa la carpeta. La fuente puede no ser compatible, estar dañada o incompleta. Es posible que el mod ya se haya añadido o actualizado; actualiza y comprueba la biblioteca antes de volver a intentarlo.", + "importOutcomeUnknown": "No se pudo verificar el resultado de la importación. Selecciona Actualizar para comprobar la biblioteca.", "applyTooltip": "Aplicar la configuración al juego", "undeployAllAction": "Retirar todo", "undeployAllConfirm": "¿Quitar del juego todo lo que desplegó el gestor?", diff --git a/apps/mod-manager/lib/l10n/app_fr.arb b/apps/mod-manager/lib/l10n/app_fr.arb index 695ddf3b0..0f8a4e8df 100644 --- a/apps/mod-manager/lib/l10n/app_fr.arb +++ b/apps/mod-manager/lib/l10n/app_fr.arb @@ -71,6 +71,14 @@ "commonOk": "OK", "importFolder": "Importer un dossier…", "importFile": "Importer un fichier…", + "importOutcomeCreated": "« {name} » a été ajouté à la bibliothèque.", + "importOutcomeUpdated": "« {name} » a été mis à jour dans la bibliothèque.", + "importOutcomeUnchanged": "« {name} » se trouve déjà dans la bibliothèque.", + "importOutcomeMatchedBy": "{method, select, none {Aucune correspondance avec une entrée existante de la bibliothèque.} source {Correspondance avec la même source d’importation.} content {Correspondance avec un contenu identique vérifié.} entry_id {Correspondance avec l’ID du mod.} other {Les détails de la correspondance ne sont pas disponibles.}}", + "importRefusalDuplicateAmbiguous": "Cette importation correspond à plusieurs entrées de la bibliothèque. Vérifiez ou supprimez les doublons, puis réessayez.", + "importRefusalIdentityConflict": "La source d’importation et son contenu correspondent à des entrées différentes de la bibliothèque. Vérifiez ou supprimez les entrées en conflit, puis réessayez.", + "importFailed": "L’importation n’a pas pu être terminée. Sources prises en charge : dossiers, ZIP, fichiers *_P.pak autonomes, ensembles .utoc/.ucas complets (.pak facultatif), .lcache, .bank et PrecompiledScript*.Cache. Extrayez d’abord les archives .7z ou .rar, puis importez le dossier. La source peut être non prise en charge, endommagée ou incomplète. Le mod a peut-être déjà été ajouté ou mis à jour ; actualisez et vérifiez la bibliothèque avant de réessayer.", + "importOutcomeUnknown": "Le résultat de l’importation n’a pas pu être vérifié. Sélectionnez Actualiser pour vérifier la bibliothèque.", "applyTooltip": "Appliquer la configuration au jeu", "undeployAllAction": "Tout retirer", "undeployAllConfirm": "Retirer du jeu tout ce que le gestionnaire a déployé ?", diff --git a/apps/mod-manager/lib/l10n/app_it.arb b/apps/mod-manager/lib/l10n/app_it.arb index e36afe252..57c68e776 100644 --- a/apps/mod-manager/lib/l10n/app_it.arb +++ b/apps/mod-manager/lib/l10n/app_it.arb @@ -71,6 +71,14 @@ "commonOk": "OK", "importFolder": "Importa cartella…", "importFile": "Importa file…", + "importOutcomeCreated": "Il mod «{name}» è stato aggiunto alla libreria.", + "importOutcomeUpdated": "Il mod «{name}» è stato aggiornato nella libreria.", + "importOutcomeUnchanged": "Il mod «{name}» è già nella libreria.", + "importOutcomeMatchedBy": "{method, select, none {Nessuna corrispondenza con le voci esistenti della libreria.} source {Corrispondenza con la stessa origine di importazione.} content {Corrispondenza con contenuti identici verificati.} entry_id {Corrispondenza con l’ID del mod.} other {I dettagli della corrispondenza non sono disponibili.}}", + "importRefusalDuplicateAmbiguous": "Questa importazione corrisponde a più di una voce della libreria. Controlla o rimuovi i duplicati, quindi riprova.", + "importRefusalIdentityConflict": "L’origine dell’importazione e il suo contenuto corrispondono a voci diverse della libreria. Controlla o rimuovi le voci in conflitto, quindi riprova.", + "importFailed": "Non è stato possibile completare l’importazione. Sorgenti supportate: cartelle, ZIP, file *_P.pak autonomi, set .utoc/.ucas completi (.pak facoltativo), .lcache, .bank e PrecompiledScript*.Cache. Estrai prima gli archivi .7z o .rar, quindi importa la cartella. La sorgente potrebbe non essere supportata, essere danneggiata o incompleta. Il mod potrebbe essere già stato aggiunto o aggiornato; aggiorna e controlla la libreria prima di riprovare.", + "importOutcomeUnknown": "Non è stato possibile verificare il risultato dell’importazione. Seleziona Aggiorna per controllare la libreria.", "applyTooltip": "Applica la configurazione al gioco", "undeployAllAction": "Ritira tutto", "undeployAllConfirm": "Rimuovere dal gioco tutto ciò che il gestore ha distribuito?", diff --git a/apps/mod-manager/lib/l10n/app_ja.arb b/apps/mod-manager/lib/l10n/app_ja.arb index d93d90bde..feee6778c 100644 --- a/apps/mod-manager/lib/l10n/app_ja.arb +++ b/apps/mod-manager/lib/l10n/app_ja.arb @@ -71,6 +71,14 @@ "commonOk": "OK", "importFolder": "フォルダーをインポート…", "importFile": "ファイルをインポート…", + "importOutcomeCreated": "「{name}」をライブラリに追加しました。", + "importOutcomeUpdated": "ライブラリ内の「{name}」を更新しました。", + "importOutcomeUnchanged": "「{name}」はすでにライブラリにあります。", + "importOutcomeMatchedBy": "{method, select, none {既存のライブラリエントリとの一致はありません。} source {同じインポート元に基づく一致です。} content {同一であることが確認されたコンテンツに基づく一致です。} entry_id {Mod ID に基づく一致です。} other {一致方法の詳細を確認できません。}}", + "importRefusalDuplicateAmbiguous": "このインポートは複数のライブラリエントリに一致します。重複を確認するか削除してから、もう一度お試しください。", + "importRefusalIdentityConflict": "インポート元とそのコンテンツが、ライブラリ内の別々のエントリに一致しています。競合するエントリを確認するか削除してから、もう一度お試しください。", + "importFailed": "インポートを完了できませんでした。対応するソースは、フォルダー、ZIP、単体の *_P.pak、完全な .utoc/.ucas セット(.pak は任意)、.lcache、.bank、PrecompiledScript*.Cache です。.7z または .rar は先に展開し、そのフォルダーをインポートしてください。ソースは未対応、破損、または不完全な可能性があります。Mod はすでに追加または更新されている可能性があります。ライブラリを更新して確認してから、もう一度お試しください。", + "importOutcomeUnknown": "インポート結果を確認できませんでした。[更新]を選択してライブラリを確認してください。", "applyTooltip": "ロードアウトをゲームに適用", "undeployAllAction": "すべて解除", "undeployAllConfirm": "マネージャーがデプロイしたものをすべてゲームから削除しますか?", diff --git a/apps/mod-manager/lib/l10n/app_localizations.dart b/apps/mod-manager/lib/l10n/app_localizations.dart index a3852fb79..8823ae0ba 100644 --- a/apps/mod-manager/lib/l10n/app_localizations.dart +++ b/apps/mod-manager/lib/l10n/app_localizations.dart @@ -542,6 +542,54 @@ abstract class AppLocalizations { /// **'Import file…'** String get importFile; + /// No description provided for @importOutcomeCreated. + /// + /// In en, this message translates to: + /// **'Added “{name}” to the library.'** + String importOutcomeCreated(String name); + + /// No description provided for @importOutcomeUpdated. + /// + /// In en, this message translates to: + /// **'Updated “{name}” in the library.'** + String importOutcomeUpdated(String name); + + /// No description provided for @importOutcomeUnchanged. + /// + /// In en, this message translates to: + /// **'“{name}” is already in the library.'** + String importOutcomeUnchanged(String name); + + /// No description provided for @importOutcomeMatchedBy. + /// + /// In en, this message translates to: + /// **'{method, select, none {No existing library entry was matched.} source {Matched by the same import source.} content {Matched by verified identical content.} entry_id {Matched by mod ID.} other {Match details are unavailable.}}'** + String importOutcomeMatchedBy(String method); + + /// No description provided for @importRefusalDuplicateAmbiguous. + /// + /// In en, this message translates to: + /// **'This import matches more than one library entry. Review or remove the duplicates, then try again.'** + String get importRefusalDuplicateAmbiguous; + + /// No description provided for @importRefusalIdentityConflict. + /// + /// In en, this message translates to: + /// **'The import source and its content match different library entries. Review or remove the conflicting entries, then try again.'** + String get importRefusalIdentityConflict; + + /// No description provided for @importFailed. + /// + /// In en, this message translates to: + /// **'The import could not be completed. Supported sources: folders, ZIP, loose *_P.pak files, complete .utoc/.ucas sets (optional .pak), .lcache, .bank, and PrecompiledScript*.Cache. Extract .7z or .rar first, then import the folder. The source may be unsupported, corrupt, or incomplete. The mod may already have been added or updated; refresh and check the library before trying again.'** + String get importFailed; + + /// No description provided for @importOutcomeUnknown. + /// + /// In en, this message translates to: + /// **'The import result could not be verified. Choose Refresh to check the library.'** + String get importOutcomeUnknown; + /// No description provided for @applyTooltip. /// /// In en, this message translates to: diff --git a/apps/mod-manager/lib/l10n/app_localizations_de.dart b/apps/mod-manager/lib/l10n/app_localizations_de.dart index 7e78a3b5b..9d2d96213 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_de.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_de.dart @@ -253,6 +253,50 @@ class AppLocalizationsDe extends AppLocalizations { @override String get importFile => 'Datei importieren…'; + @override + String importOutcomeCreated(String name) { + return '„$name“ wurde zur Bibliothek hinzugefügt.'; + } + + @override + String importOutcomeUpdated(String name) { + return '„$name“ wurde in der Bibliothek aktualisiert.'; + } + + @override + String importOutcomeUnchanged(String name) { + return '„$name“ ist bereits in der Bibliothek vorhanden.'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': + 'Es gab keine Übereinstimmung mit einem vorhandenen Bibliothekseintrag.', + 'source': 'Übereinstimmung anhand derselben Importquelle.', + 'content': 'Übereinstimmung anhand nachweislich identischer Inhalte.', + 'entry_id': 'Übereinstimmung anhand der Mod-ID.', + 'other': 'Details zur Übereinstimmung sind nicht verfügbar.', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => + 'Dieser Import stimmt mit mehreren Bibliothekseinträgen überein. Überprüfe oder entferne die Duplikate und versuche es erneut.'; + + @override + String get importRefusalIdentityConflict => + 'Die Importquelle und der Inhalt stimmen mit unterschiedlichen Bibliothekseinträgen überein. Überprüfe oder entferne die betroffenen Einträge und versuche es erneut.'; + + @override + String get importFailed => + 'Der Import konnte nicht abgeschlossen werden. Unterstützte Quellen: Ordner, ZIP, lose *_P.pak-Dateien, vollständige .utoc/.ucas-Dateisätze (optional .pak), .lcache, .bank und PrecompiledScript*.Cache. Entpacke .7z oder .rar zuerst und importiere dann den Ordner. Die Quelle wird möglicherweise nicht unterstützt, ist beschädigt oder unvollständig. Der Mod wurde möglicherweise bereits hinzugefügt oder aktualisiert; aktualisiere und überprüfe die Bibliothek, bevor du es erneut versuchst.'; + + @override + String get importOutcomeUnknown => + 'Das Importergebnis konnte nicht überprüft werden. Wähle „Aktualisieren“, um die Bibliothek zu prüfen.'; + @override String get applyTooltip => 'Ladeliste auf das Spiel anwenden'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_en.dart b/apps/mod-manager/lib/l10n/app_localizations_en.dart index 628298cea..42702afeb 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_en.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_en.dart @@ -249,6 +249,49 @@ class AppLocalizationsEn extends AppLocalizations { @override String get importFile => 'Import file…'; + @override + String importOutcomeCreated(String name) { + return 'Added “$name” to the library.'; + } + + @override + String importOutcomeUpdated(String name) { + return 'Updated “$name” in the library.'; + } + + @override + String importOutcomeUnchanged(String name) { + return '“$name” is already in the library.'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': 'No existing library entry was matched.', + 'source': 'Matched by the same import source.', + 'content': 'Matched by verified identical content.', + 'entry_id': 'Matched by mod ID.', + 'other': 'Match details are unavailable.', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => + 'This import matches more than one library entry. Review or remove the duplicates, then try again.'; + + @override + String get importRefusalIdentityConflict => + 'The import source and its content match different library entries. Review or remove the conflicting entries, then try again.'; + + @override + String get importFailed => + 'The import could not be completed. Supported sources: folders, ZIP, loose *_P.pak files, complete .utoc/.ucas sets (optional .pak), .lcache, .bank, and PrecompiledScript*.Cache. Extract .7z or .rar first, then import the folder. The source may be unsupported, corrupt, or incomplete. The mod may already have been added or updated; refresh and check the library before trying again.'; + + @override + String get importOutcomeUnknown => + 'The import result could not be verified. Choose Refresh to check the library.'; + @override String get applyTooltip => 'Apply the loadout to the game'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_es.dart b/apps/mod-manager/lib/l10n/app_localizations_es.dart index 0a8965878..686a74768 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_es.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_es.dart @@ -253,6 +253,49 @@ class AppLocalizationsEs extends AppLocalizations { @override String get importFile => 'Importar archivo…'; + @override + String importOutcomeCreated(String name) { + return 'Se añadió «$name» a la biblioteca.'; + } + + @override + String importOutcomeUpdated(String name) { + return 'Se actualizó «$name» en la biblioteca.'; + } + + @override + String importOutcomeUnchanged(String name) { + return '«$name» ya está en la biblioteca.'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': 'No hubo coincidencias con entradas existentes de la biblioteca.', + 'source': 'Coincidencia por la misma fuente de importación.', + 'content': 'Coincidencia por contenido idéntico verificado.', + 'entry_id': 'Coincidencia por el ID del mod.', + 'other': 'Los detalles de la coincidencia no están disponibles.', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => + 'Esta importación coincide con varias entradas de la biblioteca. Revisa o elimina los duplicados y vuelve a intentarlo.'; + + @override + String get importRefusalIdentityConflict => + 'La fuente de importación y su contenido coinciden con distintas entradas de la biblioteca. Revisa o elimina las entradas en conflicto y vuelve a intentarlo.'; + + @override + String get importFailed => + 'No se pudo completar la importación. Fuentes compatibles: carpetas, ZIP, archivos *_P.pak sueltos, conjuntos .utoc/.ucas completos (con .pak opcional), .lcache, .bank y PrecompiledScript*.Cache. Extrae primero los archivos .7z o .rar y después importa la carpeta. La fuente puede no ser compatible, estar dañada o incompleta. Es posible que el mod ya se haya añadido o actualizado; actualiza y comprueba la biblioteca antes de volver a intentarlo.'; + + @override + String get importOutcomeUnknown => + 'No se pudo verificar el resultado de la importación. Selecciona Actualizar para comprobar la biblioteca.'; + @override String get applyTooltip => 'Aplicar la configuración al juego'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_fr.dart b/apps/mod-manager/lib/l10n/app_localizations_fr.dart index fda2742fc..38e7e931e 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_fr.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_fr.dart @@ -254,6 +254,50 @@ class AppLocalizationsFr extends AppLocalizations { @override String get importFile => 'Importer un fichier…'; + @override + String importOutcomeCreated(String name) { + return '« $name » a été ajouté à la bibliothèque.'; + } + + @override + String importOutcomeUpdated(String name) { + return '« $name » a été mis à jour dans la bibliothèque.'; + } + + @override + String importOutcomeUnchanged(String name) { + return '« $name » se trouve déjà dans la bibliothèque.'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': + 'Aucune correspondance avec une entrée existante de la bibliothèque.', + 'source': 'Correspondance avec la même source d’importation.', + 'content': 'Correspondance avec un contenu identique vérifié.', + 'entry_id': 'Correspondance avec l’ID du mod.', + 'other': 'Les détails de la correspondance ne sont pas disponibles.', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => + 'Cette importation correspond à plusieurs entrées de la bibliothèque. Vérifiez ou supprimez les doublons, puis réessayez.'; + + @override + String get importRefusalIdentityConflict => + 'La source d’importation et son contenu correspondent à des entrées différentes de la bibliothèque. Vérifiez ou supprimez les entrées en conflit, puis réessayez.'; + + @override + String get importFailed => + 'L’importation n’a pas pu être terminée. Sources prises en charge : dossiers, ZIP, fichiers *_P.pak autonomes, ensembles .utoc/.ucas complets (.pak facultatif), .lcache, .bank et PrecompiledScript*.Cache. Extrayez d’abord les archives .7z ou .rar, puis importez le dossier. La source peut être non prise en charge, endommagée ou incomplète. Le mod a peut-être déjà été ajouté ou mis à jour ; actualisez et vérifiez la bibliothèque avant de réessayer.'; + + @override + String get importOutcomeUnknown => + 'Le résultat de l’importation n’a pas pu être vérifié. Sélectionnez Actualiser pour vérifier la bibliothèque.'; + @override String get applyTooltip => 'Appliquer la configuration au jeu'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_it.dart b/apps/mod-manager/lib/l10n/app_localizations_it.dart index 6250f26b3..1df7ce022 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_it.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_it.dart @@ -254,6 +254,49 @@ class AppLocalizationsIt extends AppLocalizations { @override String get importFile => 'Importa file…'; + @override + String importOutcomeCreated(String name) { + return 'Il mod «$name» è stato aggiunto alla libreria.'; + } + + @override + String importOutcomeUpdated(String name) { + return 'Il mod «$name» è stato aggiornato nella libreria.'; + } + + @override + String importOutcomeUnchanged(String name) { + return 'Il mod «$name» è già nella libreria.'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': 'Nessuna corrispondenza con le voci esistenti della libreria.', + 'source': 'Corrispondenza con la stessa origine di importazione.', + 'content': 'Corrispondenza con contenuti identici verificati.', + 'entry_id': 'Corrispondenza con l’ID del mod.', + 'other': 'I dettagli della corrispondenza non sono disponibili.', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => + 'Questa importazione corrisponde a più di una voce della libreria. Controlla o rimuovi i duplicati, quindi riprova.'; + + @override + String get importRefusalIdentityConflict => + 'L’origine dell’importazione e il suo contenuto corrispondono a voci diverse della libreria. Controlla o rimuovi le voci in conflitto, quindi riprova.'; + + @override + String get importFailed => + 'Non è stato possibile completare l’importazione. Sorgenti supportate: cartelle, ZIP, file *_P.pak autonomi, set .utoc/.ucas completi (.pak facoltativo), .lcache, .bank e PrecompiledScript*.Cache. Estrai prima gli archivi .7z o .rar, quindi importa la cartella. La sorgente potrebbe non essere supportata, essere danneggiata o incompleta. Il mod potrebbe essere già stato aggiunto o aggiornato; aggiorna e controlla la libreria prima di riprovare.'; + + @override + String get importOutcomeUnknown => + 'Non è stato possibile verificare il risultato dell’importazione. Seleziona Aggiorna per controllare la libreria.'; + @override String get applyTooltip => 'Applica la configurazione al gioco'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_ja.dart b/apps/mod-manager/lib/l10n/app_localizations_ja.dart index dac64ae3e..073af9803 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_ja.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_ja.dart @@ -240,6 +240,49 @@ class AppLocalizationsJa extends AppLocalizations { @override String get importFile => 'ファイルをインポート…'; + @override + String importOutcomeCreated(String name) { + return '「$name」をライブラリに追加しました。'; + } + + @override + String importOutcomeUpdated(String name) { + return 'ライブラリ内の「$name」を更新しました。'; + } + + @override + String importOutcomeUnchanged(String name) { + return '「$name」はすでにライブラリにあります。'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': '既存のライブラリエントリとの一致はありません。', + 'source': '同じインポート元に基づく一致です。', + 'content': '同一であることが確認されたコンテンツに基づく一致です。', + 'entry_id': 'Mod ID に基づく一致です。', + 'other': '一致方法の詳細を確認できません。', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => + 'このインポートは複数のライブラリエントリに一致します。重複を確認するか削除してから、もう一度お試しください。'; + + @override + String get importRefusalIdentityConflict => + 'インポート元とそのコンテンツが、ライブラリ内の別々のエントリに一致しています。競合するエントリを確認するか削除してから、もう一度お試しください。'; + + @override + String get importFailed => + 'インポートを完了できませんでした。対応するソースは、フォルダー、ZIP、単体の *_P.pak、完全な .utoc/.ucas セット(.pak は任意)、.lcache、.bank、PrecompiledScript*.Cache です。.7z または .rar は先に展開し、そのフォルダーをインポートしてください。ソースは未対応、破損、または不完全な可能性があります。Mod はすでに追加または更新されている可能性があります。ライブラリを更新して確認してから、もう一度お試しください。'; + + @override + String get importOutcomeUnknown => + 'インポート結果を確認できませんでした。[更新]を選択してライブラリを確認してください。'; + @override String get applyTooltip => 'ロードアウトをゲームに適用'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_pl.dart b/apps/mod-manager/lib/l10n/app_localizations_pl.dart index c48e98fae..8ff60a72e 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_pl.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_pl.dart @@ -251,6 +251,50 @@ class AppLocalizationsPl extends AppLocalizations { @override String get importFile => 'Importuj plik…'; + @override + String importOutcomeCreated(String name) { + return 'Dodano „$name” do biblioteki.'; + } + + @override + String importOutcomeUpdated(String name) { + return 'Zaktualizowano „$name” w bibliotece.'; + } + + @override + String importOutcomeUnchanged(String name) { + return '„$name” jest już w bibliotece.'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': 'Nie dopasowano żadnego istniejącego wpisu w bibliotece.', + 'source': 'Dopasowano na podstawie tego samego źródła importu.', + 'content': + 'Dopasowano na podstawie zawartości, której identyczność została potwierdzona.', + 'entry_id': 'Dopasowano na podstawie identyfikatora moda.', + 'other': 'Szczegóły dopasowania są niedostępne.', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => + 'Ten import pasuje do więcej niż jednego wpisu w bibliotece. Sprawdź lub usuń duplikaty, a następnie spróbuj ponownie.'; + + @override + String get importRefusalIdentityConflict => + 'Źródło importu i jego zawartość pasują do różnych wpisów w bibliotece. Sprawdź lub usuń wpisy powodujące konflikt, a następnie spróbuj ponownie.'; + + @override + String get importFailed => + 'Nie udało się ukończyć importu. Obsługiwane źródła: foldery, ZIP, samodzielne pliki *_P.pak, kompletne zestawy .utoc/.ucas (opcjonalnie .pak), .lcache, .bank i PrecompiledScript*.Cache. Najpierw rozpakuj archiwum .7z lub .rar, a następnie zaimportuj folder. Źródło może być nieobsługiwane, uszkodzone lub niekompletne. Mod mógł już zostać dodany lub zaktualizowany; odśwież i sprawdź bibliotekę przed kolejną próbą.'; + + @override + String get importOutcomeUnknown => + 'Nie udało się zweryfikować wyniku importu. Wybierz Odśwież, aby sprawdzić bibliotekę.'; + @override String get applyTooltip => 'Zastosuj zestaw modów do gry'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_pt.dart b/apps/mod-manager/lib/l10n/app_localizations_pt.dart index 0511cac08..75e3ca1cf 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_pt.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_pt.dart @@ -256,6 +256,50 @@ class AppLocalizationsPt extends AppLocalizations { @override String get importFile => 'Importar ficheiro…'; + @override + String importOutcomeCreated(String name) { + return '«$name» foi adicionado à biblioteca.'; + } + + @override + String importOutcomeUpdated(String name) { + return '«$name» foi atualizado na biblioteca.'; + } + + @override + String importOutcomeUnchanged(String name) { + return '«$name» já se encontra na biblioteca.'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': + 'Não houve correspondência com nenhuma entrada existente da biblioteca.', + 'source': 'Correspondência com a mesma origem de importação.', + 'content': 'Correspondência com conteúdo idêntico verificado.', + 'entry_id': 'Correspondência com o ID do mod.', + 'other': 'Os detalhes da correspondência não estão disponíveis.', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => + 'Esta importação corresponde a mais do que uma entrada da biblioteca. Reveja ou remova as entradas duplicadas e tente novamente.'; + + @override + String get importRefusalIdentityConflict => + 'A origem da importação e o respetivo conteúdo correspondem a entradas diferentes da biblioteca. Reveja ou remova as entradas em conflito e tente novamente.'; + + @override + String get importFailed => + 'Não foi possível concluir a importação. Fontes suportadas: pastas, ZIP, ficheiros *_P.pak autónomos, conjuntos .utoc/.ucas completos (.pak opcional), .lcache, .bank e PrecompiledScript*.Cache. Extraia primeiro os ficheiros .7z ou .rar e depois importe a pasta. A fonte poderá não ser suportada, estar danificada ou incompleta. O mod poderá já ter sido adicionado ou atualizado; atualize e verifique a biblioteca antes de tentar novamente.'; + + @override + String get importOutcomeUnknown => + 'Não foi possível verificar o resultado da importação. Selecione Atualizar para verificar a biblioteca.'; + @override String get applyTooltip => 'Aplicar a configuração ao jogo'; @@ -728,6 +772,50 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get importFile => 'Importar arquivo…'; + @override + String importOutcomeCreated(String name) { + return '“$name” foi adicionado à biblioteca.'; + } + + @override + String importOutcomeUpdated(String name) { + return '“$name” foi atualizado na biblioteca.'; + } + + @override + String importOutcomeUnchanged(String name) { + return '“$name” já está na biblioteca.'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': + 'Não houve correspondência com nenhuma entrada existente da biblioteca.', + 'source': 'Correspondência com a mesma origem de importação.', + 'content': 'Correspondência com conteúdo idêntico verificado.', + 'entry_id': 'Correspondência com o ID do mod.', + 'other': 'Os detalhes da correspondência não estão disponíveis.', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => + 'Esta importação corresponde a mais de uma entrada da biblioteca. Revise ou remova as entradas duplicadas e tente novamente.'; + + @override + String get importRefusalIdentityConflict => + 'A origem da importação e o conteúdo correspondem a entradas diferentes da biblioteca. Revise ou remova as entradas em conflito e tente novamente.'; + + @override + String get importFailed => + 'Não foi possível concluir a importação. Fontes compatíveis: pastas, ZIP, arquivos *_P.pak avulsos, conjuntos .utoc/.ucas completos (.pak opcional), .lcache, .bank e PrecompiledScript*.Cache. Extraia primeiro os arquivos .7z ou .rar e depois importe a pasta. A fonte pode não ser compatível, estar corrompida ou incompleta. O mod talvez já tenha sido adicionado ou atualizado; atualize e verifique a biblioteca antes de tentar novamente.'; + + @override + String get importOutcomeUnknown => + 'Não foi possível verificar o resultado da importação. Selecione Atualizar para verificar a biblioteca.'; + @override String get applyTooltip => 'Aplicar a configuração ao jogo'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_ru.dart b/apps/mod-manager/lib/l10n/app_localizations_ru.dart index e3e300648..b5e87e08a 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_ru.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_ru.dart @@ -251,6 +251,50 @@ class AppLocalizationsRu extends AppLocalizations { @override String get importFile => 'Импортировать файл…'; + @override + String importOutcomeCreated(String name) { + return 'Мод «$name» добавлен в библиотеку.'; + } + + @override + String importOutcomeUpdated(String name) { + return 'Мод «$name» обновлён в библиотеке.'; + } + + @override + String importOutcomeUnchanged(String name) { + return 'Мод «$name» уже есть в библиотеке.'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': 'Совпадений с существующими записями библиотеки нет.', + 'source': 'Совпадение по тому же источнику импорта.', + 'content': + 'Совпадение по содержимому, идентичность которого подтверждена.', + 'entry_id': 'Совпадение по идентификатору мода.', + 'other': 'Сведения о совпадении недоступны.', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => + 'Этот импорт соответствует нескольким записям библиотеки. Проверьте или удалите дубликаты и повторите попытку.'; + + @override + String get importRefusalIdentityConflict => + 'Источник импорта и его содержимое соответствуют разным записям библиотеки. Проверьте или удалите конфликтующие записи и повторите попытку.'; + + @override + String get importFailed => + 'Не удалось завершить импорт. Поддерживаемые источники: папки, ZIP, отдельные файлы *_P.pak, полные наборы .utoc/.ucas (необязательный .pak), .lcache, .bank и PrecompiledScript*.Cache. Сначала распакуйте архив .7z или .rar, затем импортируйте папку. Источник может не поддерживаться, быть повреждённым или неполным. Возможно, мод уже добавлен или обновлён; обновите и проверьте библиотеку перед повторной попыткой.'; + + @override + String get importOutcomeUnknown => + 'Не удалось проверить результат импорта. Нажмите «Обновить», чтобы проверить библиотеку.'; + @override String get applyTooltip => 'Применить набор модов к игре'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_zh.dart b/apps/mod-manager/lib/l10n/app_localizations_zh.dart index 395583727..472388939 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_zh.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_zh.dart @@ -235,6 +235,47 @@ class AppLocalizationsZh extends AppLocalizations { @override String get importFile => '导入文件…'; + @override + String importOutcomeCreated(String name) { + return '已将“$name”添加到库中。'; + } + + @override + String importOutcomeUpdated(String name) { + return '已更新库中的“$name”。'; + } + + @override + String importOutcomeUnchanged(String name) { + return '“$name”已在库中。'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': '未匹配到现有库条目。', + 'source': '根据相同的导入来源匹配。', + 'content': '根据经验证相同的内容匹配。', + 'entry_id': '根据模组 ID 匹配。', + 'other': '匹配详情不可用。', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => '此导入匹配多个库条目。请检查或移除重复条目,然后重试。'; + + @override + String get importRefusalIdentityConflict => + '导入来源及其内容匹配到不同的库条目。请检查或移除冲突条目,然后重试。'; + + @override + String get importFailed => + '无法完成导入。支持的来源:文件夹、ZIP、独立的 *_P.pak、完整的 .utoc/.ucas 组合(.pak 可选)、.lcache、.bank 和 PrecompiledScript*.Cache。请先解压 .7z 或 .rar,再导入文件夹。来源可能不受支持、已损坏或不完整。模组可能已被添加或更新;请刷新并检查库状态,然后重试。'; + + @override + String get importOutcomeUnknown => '无法验证导入结果。请选择“刷新”以检查库状态。'; + @override String get applyTooltip => '将模组配置应用到游戏'; @@ -675,6 +716,47 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get importFile => '导入文件…'; + @override + String importOutcomeCreated(String name) { + return '已将“$name”添加到库中。'; + } + + @override + String importOutcomeUpdated(String name) { + return '已更新库中的“$name”。'; + } + + @override + String importOutcomeUnchanged(String name) { + return '“$name”已在库中。'; + } + + @override + String importOutcomeMatchedBy(String method) { + String _temp0 = intl.Intl.selectLogic(method, { + 'none': '未匹配到现有库条目。', + 'source': '根据相同的导入来源匹配。', + 'content': '根据经验证相同的内容匹配。', + 'entry_id': '根据模组 ID 匹配。', + 'other': '匹配详情不可用。', + }); + return '$_temp0'; + } + + @override + String get importRefusalDuplicateAmbiguous => '此导入匹配多个库条目。请检查或移除重复条目,然后重试。'; + + @override + String get importRefusalIdentityConflict => + '导入来源及其内容匹配到不同的库条目。请检查或移除冲突条目,然后重试。'; + + @override + String get importFailed => + '无法完成导入。支持的来源:文件夹、ZIP、独立的 *_P.pak、完整的 .utoc/.ucas 组合(.pak 可选)、.lcache、.bank 和 PrecompiledScript*.Cache。请先解压 .7z 或 .rar,再导入文件夹。来源可能不受支持、已损坏或不完整。模组可能已被添加或更新;请刷新并检查库状态,然后重试。'; + + @override + String get importOutcomeUnknown => '无法验证导入结果。请选择“刷新”以检查库状态。'; + @override String get applyTooltip => '将模组配置应用到游戏'; diff --git a/apps/mod-manager/lib/l10n/app_pl.arb b/apps/mod-manager/lib/l10n/app_pl.arb index 3f2c10f71..2ee0495e4 100644 --- a/apps/mod-manager/lib/l10n/app_pl.arb +++ b/apps/mod-manager/lib/l10n/app_pl.arb @@ -71,6 +71,14 @@ "commonOk": "OK", "importFolder": "Importuj folder…", "importFile": "Importuj plik…", + "importOutcomeCreated": "Dodano „{name}” do biblioteki.", + "importOutcomeUpdated": "Zaktualizowano „{name}” w bibliotece.", + "importOutcomeUnchanged": "„{name}” jest już w bibliotece.", + "importOutcomeMatchedBy": "{method, select, none {Nie dopasowano żadnego istniejącego wpisu w bibliotece.} source {Dopasowano na podstawie tego samego źródła importu.} content {Dopasowano na podstawie zawartości, której identyczność została potwierdzona.} entry_id {Dopasowano na podstawie identyfikatora moda.} other {Szczegóły dopasowania są niedostępne.}}", + "importRefusalDuplicateAmbiguous": "Ten import pasuje do więcej niż jednego wpisu w bibliotece. Sprawdź lub usuń duplikaty, a następnie spróbuj ponownie.", + "importRefusalIdentityConflict": "Źródło importu i jego zawartość pasują do różnych wpisów w bibliotece. Sprawdź lub usuń wpisy powodujące konflikt, a następnie spróbuj ponownie.", + "importFailed": "Nie udało się ukończyć importu. Obsługiwane źródła: foldery, ZIP, samodzielne pliki *_P.pak, kompletne zestawy .utoc/.ucas (opcjonalnie .pak), .lcache, .bank i PrecompiledScript*.Cache. Najpierw rozpakuj archiwum .7z lub .rar, a następnie zaimportuj folder. Źródło może być nieobsługiwane, uszkodzone lub niekompletne. Mod mógł już zostać dodany lub zaktualizowany; odśwież i sprawdź bibliotekę przed kolejną próbą.", + "importOutcomeUnknown": "Nie udało się zweryfikować wyniku importu. Wybierz Odśwież, aby sprawdzić bibliotekę.", "applyTooltip": "Zastosuj zestaw modów do gry", "undeployAllAction": "Wycofaj wszystko", "undeployAllConfirm": "Usunąć z gry wszystko, co wdrożył menedżer?", diff --git a/apps/mod-manager/lib/l10n/app_pt.arb b/apps/mod-manager/lib/l10n/app_pt.arb index 47b886346..89d05590a 100644 --- a/apps/mod-manager/lib/l10n/app_pt.arb +++ b/apps/mod-manager/lib/l10n/app_pt.arb @@ -71,6 +71,14 @@ "commonOk": "OK", "importFolder": "Importar pasta…", "importFile": "Importar ficheiro…", + "importOutcomeCreated": "«{name}» foi adicionado à biblioteca.", + "importOutcomeUpdated": "«{name}» foi atualizado na biblioteca.", + "importOutcomeUnchanged": "«{name}» já se encontra na biblioteca.", + "importOutcomeMatchedBy": "{method, select, none {Não houve correspondência com nenhuma entrada existente da biblioteca.} source {Correspondência com a mesma origem de importação.} content {Correspondência com conteúdo idêntico verificado.} entry_id {Correspondência com o ID do mod.} other {Os detalhes da correspondência não estão disponíveis.}}", + "importRefusalDuplicateAmbiguous": "Esta importação corresponde a mais do que uma entrada da biblioteca. Reveja ou remova as entradas duplicadas e tente novamente.", + "importRefusalIdentityConflict": "A origem da importação e o respetivo conteúdo correspondem a entradas diferentes da biblioteca. Reveja ou remova as entradas em conflito e tente novamente.", + "importFailed": "Não foi possível concluir a importação. Fontes suportadas: pastas, ZIP, ficheiros *_P.pak autónomos, conjuntos .utoc/.ucas completos (.pak opcional), .lcache, .bank e PrecompiledScript*.Cache. Extraia primeiro os ficheiros .7z ou .rar e depois importe a pasta. A fonte poderá não ser suportada, estar danificada ou incompleta. O mod poderá já ter sido adicionado ou atualizado; atualize e verifique a biblioteca antes de tentar novamente.", + "importOutcomeUnknown": "Não foi possível verificar o resultado da importação. Selecione Atualizar para verificar a biblioteca.", "applyTooltip": "Aplicar a configuração ao jogo", "undeployAllAction": "Remover tudo", "undeployAllConfirm": "Remover do jogo tudo o que o gestor implementou?", diff --git a/apps/mod-manager/lib/l10n/app_pt_BR.arb b/apps/mod-manager/lib/l10n/app_pt_BR.arb index 5c615d815..f15b792f2 100644 --- a/apps/mod-manager/lib/l10n/app_pt_BR.arb +++ b/apps/mod-manager/lib/l10n/app_pt_BR.arb @@ -71,6 +71,14 @@ "commonOk": "OK", "importFolder": "Importar pasta…", "importFile": "Importar arquivo…", + "importOutcomeCreated": "“{name}” foi adicionado à biblioteca.", + "importOutcomeUpdated": "“{name}” foi atualizado na biblioteca.", + "importOutcomeUnchanged": "“{name}” já está na biblioteca.", + "importOutcomeMatchedBy": "{method, select, none {Não houve correspondência com nenhuma entrada existente da biblioteca.} source {Correspondência com a mesma origem de importação.} content {Correspondência com conteúdo idêntico verificado.} entry_id {Correspondência com o ID do mod.} other {Os detalhes da correspondência não estão disponíveis.}}", + "importRefusalDuplicateAmbiguous": "Esta importação corresponde a mais de uma entrada da biblioteca. Revise ou remova as entradas duplicadas e tente novamente.", + "importRefusalIdentityConflict": "A origem da importação e o conteúdo correspondem a entradas diferentes da biblioteca. Revise ou remova as entradas em conflito e tente novamente.", + "importFailed": "Não foi possível concluir a importação. Fontes compatíveis: pastas, ZIP, arquivos *_P.pak avulsos, conjuntos .utoc/.ucas completos (.pak opcional), .lcache, .bank e PrecompiledScript*.Cache. Extraia primeiro os arquivos .7z ou .rar e depois importe a pasta. A fonte pode não ser compatível, estar corrompida ou incompleta. O mod talvez já tenha sido adicionado ou atualizado; atualize e verifique a biblioteca antes de tentar novamente.", + "importOutcomeUnknown": "Não foi possível verificar o resultado da importação. Selecione Atualizar para verificar a biblioteca.", "applyTooltip": "Aplicar a configuração ao jogo", "undeployAllAction": "Remover tudo", "undeployAllConfirm": "Remover do jogo tudo o que o gerenciador implantou?", diff --git a/apps/mod-manager/lib/l10n/app_ru.arb b/apps/mod-manager/lib/l10n/app_ru.arb index 277685f8b..37e344ccc 100644 --- a/apps/mod-manager/lib/l10n/app_ru.arb +++ b/apps/mod-manager/lib/l10n/app_ru.arb @@ -71,6 +71,14 @@ "commonOk": "OK", "importFolder": "Импортировать папку…", "importFile": "Импортировать файл…", + "importOutcomeCreated": "Мод «{name}» добавлен в библиотеку.", + "importOutcomeUpdated": "Мод «{name}» обновлён в библиотеке.", + "importOutcomeUnchanged": "Мод «{name}» уже есть в библиотеке.", + "importOutcomeMatchedBy": "{method, select, none {Совпадений с существующими записями библиотеки нет.} source {Совпадение по тому же источнику импорта.} content {Совпадение по содержимому, идентичность которого подтверждена.} entry_id {Совпадение по идентификатору мода.} other {Сведения о совпадении недоступны.}}", + "importRefusalDuplicateAmbiguous": "Этот импорт соответствует нескольким записям библиотеки. Проверьте или удалите дубликаты и повторите попытку.", + "importRefusalIdentityConflict": "Источник импорта и его содержимое соответствуют разным записям библиотеки. Проверьте или удалите конфликтующие записи и повторите попытку.", + "importFailed": "Не удалось завершить импорт. Поддерживаемые источники: папки, ZIP, отдельные файлы *_P.pak, полные наборы .utoc/.ucas (необязательный .pak), .lcache, .bank и PrecompiledScript*.Cache. Сначала распакуйте архив .7z или .rar, затем импортируйте папку. Источник может не поддерживаться, быть повреждённым или неполным. Возможно, мод уже добавлен или обновлён; обновите и проверьте библиотеку перед повторной попыткой.", + "importOutcomeUnknown": "Не удалось проверить результат импорта. Нажмите «Обновить», чтобы проверить библиотеку.", "applyTooltip": "Применить набор модов к игре", "undeployAllAction": "Убрать всё из игры", "undeployAllConfirm": "Удалить из игры всё, что развернул менеджер?", diff --git a/apps/mod-manager/lib/l10n/app_zh.arb b/apps/mod-manager/lib/l10n/app_zh.arb index 478aa5d4b..3b3e95a78 100644 --- a/apps/mod-manager/lib/l10n/app_zh.arb +++ b/apps/mod-manager/lib/l10n/app_zh.arb @@ -71,6 +71,14 @@ "commonOk": "确定", "importFolder": "导入文件夹…", "importFile": "导入文件…", + "importOutcomeCreated": "已将“{name}”添加到库中。", + "importOutcomeUpdated": "已更新库中的“{name}”。", + "importOutcomeUnchanged": "“{name}”已在库中。", + "importOutcomeMatchedBy": "{method, select, none {未匹配到现有库条目。} source {根据相同的导入来源匹配。} content {根据经验证相同的内容匹配。} entry_id {根据模组 ID 匹配。} other {匹配详情不可用。}}", + "importRefusalDuplicateAmbiguous": "此导入匹配多个库条目。请检查或移除重复条目,然后重试。", + "importRefusalIdentityConflict": "导入来源及其内容匹配到不同的库条目。请检查或移除冲突条目,然后重试。", + "importFailed": "无法完成导入。支持的来源:文件夹、ZIP、独立的 *_P.pak、完整的 .utoc/.ucas 组合(.pak 可选)、.lcache、.bank 和 PrecompiledScript*.Cache。请先解压 .7z 或 .rar,再导入文件夹。来源可能不受支持、已损坏或不完整。模组可能已被添加或更新;请刷新并检查库状态,然后重试。", + "importOutcomeUnknown": "无法验证导入结果。请选择“刷新”以检查库状态。", "applyTooltip": "将模组配置应用到游戏", "undeployAllAction": "撤销全部部署", "undeployAllConfirm": "从游戏中移除管理器部署的全部内容?", diff --git a/apps/mod-manager/lib/l10n/app_zh_Hans.arb b/apps/mod-manager/lib/l10n/app_zh_Hans.arb index bfb968ca0..29fa76b68 100644 --- a/apps/mod-manager/lib/l10n/app_zh_Hans.arb +++ b/apps/mod-manager/lib/l10n/app_zh_Hans.arb @@ -71,6 +71,14 @@ "commonOk": "确定", "importFolder": "导入文件夹…", "importFile": "导入文件…", + "importOutcomeCreated": "已将“{name}”添加到库中。", + "importOutcomeUpdated": "已更新库中的“{name}”。", + "importOutcomeUnchanged": "“{name}”已在库中。", + "importOutcomeMatchedBy": "{method, select, none {未匹配到现有库条目。} source {根据相同的导入来源匹配。} content {根据经验证相同的内容匹配。} entry_id {根据模组 ID 匹配。} other {匹配详情不可用。}}", + "importRefusalDuplicateAmbiguous": "此导入匹配多个库条目。请检查或移除重复条目,然后重试。", + "importRefusalIdentityConflict": "导入来源及其内容匹配到不同的库条目。请检查或移除冲突条目,然后重试。", + "importFailed": "无法完成导入。支持的来源:文件夹、ZIP、独立的 *_P.pak、完整的 .utoc/.ucas 组合(.pak 可选)、.lcache、.bank 和 PrecompiledScript*.Cache。请先解压 .7z 或 .rar,再导入文件夹。来源可能不受支持、已损坏或不完整。模组可能已被添加或更新;请刷新并检查库状态,然后重试。", + "importOutcomeUnknown": "无法验证导入结果。请选择“刷新”以检查库状态。", "applyTooltip": "将模组配置应用到游戏", "undeployAllAction": "撤销全部部署", "undeployAllConfirm": "从游戏中移除管理器部署的全部内容?", diff --git a/apps/mod-manager/lib/library/domain/library_notifier.dart b/apps/mod-manager/lib/library/domain/library_notifier.dart index fe3676dc6..ce2c10658 100644 --- a/apps/mod-manager/lib/library/domain/library_notifier.dart +++ b/apps/mod-manager/lib/library/domain/library_notifier.dart @@ -76,11 +76,70 @@ class LibraryNotifier extends StateNotifier { await _runRefresh(); } - /// Import a mod from [path] into the library, then refresh. - Future import(String path) async { - await _runMutation(() async { - await _mgr.import(path); - }); + /// Import a mod from [path], then reload Native's authoritative Store + /// snapshot. A native outcome is returned only when that reload succeeds and + /// contains the same entry id. Failures are rethrown only after the reload + /// attempt and state publication have settled, so UI callers can present the + /// typed FFI error without racing stale library data. + Future import(String path) async { + if (state.busy || !state.authoritative) return null; + state = state.copyWith(busy: true, clearError: true); + + MgrImportOutcome? nativeOutcome; + Object? operationError; + StackTrace? operationStack; + try { + nativeOutcome = await _mgr.import(path); + } catch (error, stack) { + operationError = error; + operationStack = stack; + } + + Object? reloadError; + StackTrace? reloadStack; + try { + await _refreshInline(); + } catch (error, stack) { + reloadError = error; + reloadStack = stack; + _markUnknown(); + } + + // A failed reload dominates any operation result because the UI cannot + // safely classify or select anything until authoritative state returns. + // When reload succeeds, keep operation failures out of LibraryState: the + // caller owns their localized import feedback and no raw native banner may + // race it into the page. + Object? visibleError = reloadError ?? operationError; + StackTrace? visibleStack = reloadError != null + ? reloadStack + : operationStack; + MgrImportOutcome? authoritativeOutcome; + if (visibleError == null && nativeOutcome != null) { + final authoritativeEntry = state.modById(nativeOutcome.entry.id); + if (authoritativeEntry == null) { + visibleError = MgrFfiException( + 'mgr_import: imported entry is absent from the authoritative snapshot', + code: 'IMPORT_INVALID_RESPONSE', + ); + visibleStack = StackTrace.current; + } else { + authoritativeOutcome = nativeOutcome.withEntry(authoritativeEntry); + } + } + + if (reloadError != null) { + state = state.copyWith(error: _errorMessage(reloadError)); + } + state = state.copyWith(busy: false); + + if (visibleError != null) { + Error.throwWithStackTrace( + visibleError, + visibleStack ?? StackTrace.current, + ); + } + return authoritativeOutcome; } /// Remove the mod [id] from the library, then refresh. diff --git a/apps/mod-manager/lib/library/ui/import_feedback.dart b/apps/mod-manager/lib/library/ui/import_feedback.dart new file mode 100644 index 000000000..324c34732 --- /dev/null +++ b/apps/mod-manager/lib/library/ui/import_feedback.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; + +import '../../core/diagnostic_text.dart'; +import '../../core/mgr_ffi.dart'; +import '../../l10n/app_localizations.dart'; +import '../domain/library_notifier.dart'; +import '../domain/models.dart'; + +const _maxImportDisplayNameRunes = 160; +const _maxImportErrorMessageRunes = 512; +const _maxImportTechnicalRunes = 1024; +const _maxImportCandidateIdRunes = 256; + +String importDisplayName(ModEntryMetaView entry) { + final preferred = boundedDiagnosticText( + entry.name.isEmpty ? entry.id : entry.name, + _maxImportDisplayNameRunes, + ); + if (preferred.value case final value?) { + return '$value${preferred.truncated ? '…' : ''}'; + } + final fallback = boundedDiagnosticText(entry.id, _maxImportDisplayNameRunes); + return switch (fallback.value) { + final value? => '$value${fallback.truncated ? '…' : ''}', + null => '—', + }; +} + +void showImportSuccessFeedback(BuildContext context, MgrImportOutcome outcome) { + final l10n = AppLocalizations.of(context); + final name = importDisplayName(outcome.entry); + final disposition = switch (outcome.disposition) { + MgrImportDisposition.created => l10n.importOutcomeCreated(name), + MgrImportDisposition.updated => l10n.importOutcomeUpdated(name), + MgrImportDisposition.unchanged => l10n.importOutcomeUnchanged(name), + }; + _showImportSnackBar( + context, + message: + '$disposition ${l10n.importOutcomeMatchedBy(outcome.matchedBy.wireName)}', + ); +} + +void showImportFailureFeedback( + BuildContext context, + MgrFfiException error, + LibraryState library, +) { + final l10n = AppLocalizations.of(context); + final message = !library.authoritative + ? l10n.importOutcomeUnknown + : switch (error.code) { + 'IMPORT_INVALID_RESPONSE' => l10n.importOutcomeUnknown, + 'IMPORT_DUPLICATE_AMBIGUOUS' => l10n.importRefusalDuplicateAmbiguous, + 'IMPORT_IDENTITY_CONFLICT' => l10n.importRefusalIdentityConflict, + _ => l10n.importFailed, + }; + _showImportSnackBar( + context, + message: message, + technicalDetails: _importTechnicalDetails(error, library), + ); +} + +String? _importTechnicalDetails(MgrFfiException error, LibraryState library) { + final lines = []; + // Put the bounded machine-readable witnesses first. With Native's two-item + // candidate cap, both complete candidate ids, display names, and match roles + // fit inside the overall budget; opaque native prose may use only what + // remains after those facts. + if (error.details case final MgrImportRefusalDetails details) { + for (final candidate in details.candidates) { + final safeId = boundedDiagnosticText( + candidate.id, + _maxImportCandidateIdRunes, + ); + final id = switch (safeId.value) { + final value? => '$value${safeId.truncated ? '…' : ''}', + null => '—', + }; + final entry = library.modById(candidate.id); + final name = entry == null ? null : importDisplayName(entry); + final roles = candidate.matchedBy.map((role) => role.wireName).join(', '); + final identity = name == null || name == id ? id : '$name ($id)'; + lines.add(roles.isEmpty ? identity : '$identity — $roles'); + } + } + + final diagnostic = boundedDiagnosticText( + error.message, + _maxImportErrorMessageRunes, + ); + if (diagnostic.value case final value?) { + lines.add('$value${diagnostic.truncated ? '…' : ''}'); + } + + if (lines.isEmpty) return null; + final combined = boundedDiagnosticText( + lines.join('\n'), + _maxImportTechnicalRunes, + ); + return switch (combined.value) { + final value? => '$value${combined.truncated ? '…' : ''}', + null => null, + }; +} + +void _showImportSnackBar( + BuildContext context, { + required String message, + String? technicalDetails, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + duration: technicalDetails == null + ? const Duration(seconds: 5) + : const Duration(seconds: 30), + // Technical failures contain an interactive expandable diagnostic. Keep + // it available until explicitly dismissed, including for assistive + // navigation; [duration] remains useful only for non-technical feedback. + persist: technicalDetails != null, + showCloseIcon: true, + content: ImportFeedbackContent( + message: message, + technicalDetails: technicalDetails, + ), + ), + ); +} + +class ImportFeedbackContent extends StatefulWidget { + const ImportFeedbackContent({ + super.key, + required this.message, + this.technicalDetails, + }); + + final String message; + final String? technicalDetails; + + @override + State createState() => _ImportFeedbackContentState(); +} + +class _ImportFeedbackContentState extends State { + bool _expanded = false; + late final FocusNode _detailsFocusNode = FocusNode( + debugLabel: 'import-feedback-technical-details', + ); + + @override + void dispose() { + _detailsFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final technical = widget.technicalDetails; + final maxContentHeight = (MediaQuery.sizeOf(context).height * 0.6) + .clamp(0.0, 360.0) + .toDouble(); + return ConstrainedBox( + key: const ValueKey('import-feedback-content'), + constraints: BoxConstraints(maxHeight: maxContentHeight), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: SingleChildScrollView( + key: const ValueKey('import-feedback-message-scroll'), + child: Text( + key: const ValueKey('import-feedback-message'), + widget.message, + ), + ), + ), + if (technical != null) ...[ + MergeSemantics( + child: Semantics( + expanded: _expanded, + child: TextButton( + key: const ValueKey('import-feedback-details-toggle'), + focusNode: _detailsFocusNode, + onPressed: () => setState(() => _expanded = !_expanded), + style: TextButton.styleFrom( + foregroundColor: Theme.of( + context, + ).colorScheme.inversePrimary, + padding: const EdgeInsets.symmetric( + horizontal: 0, + vertical: 4, + ), + minimumSize: const Size(44, 44), + ), + child: Text( + AppLocalizations.of(context).coreTechnicalDetails, + ), + ), + ), + ), + if (_expanded) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 72), + child: SingleChildScrollView( + key: const ValueKey('import-feedback-details-scroll'), + child: SelectableText( + technical, + key: const ValueKey('import-feedback-details'), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/apps/mod-manager/lib/library/ui/import_source_picker.dart b/apps/mod-manager/lib/library/ui/import_source_picker.dart new file mode 100644 index 000000000..5cd7a2237 --- /dev/null +++ b/apps/mod-manager/lib/library/ui/import_source_picker.dart @@ -0,0 +1,34 @@ +import 'package:file_selector/file_selector.dart'; + +typedef ImportFileOpener = + Future Function(List acceptedTypeGroups); + +Future _openImportFile(List acceptedTypeGroups) => + openFile(acceptedTypeGroups: acceptedTypeGroups); + +/// Injectable boundary around the two platform pickers used by Manager import. +/// A null path means the user cancelled and grants no mutation authority. +abstract interface class ImportSourcePicker { + Future pickFolder(); + + Future pickFile({required String dialogLabel}); +} + +class FileSelectorImportSourcePicker implements ImportSourcePicker { + const FileSelectorImportSourcePicker({this.fileOpener = _openImportFile}); + + final ImportFileOpener fileOpener; + + @override + Future pickFolder() => getDirectoryPath(); + + @override + Future pickFile({required String dialogLabel}) async { + // Native import is the bounded authority for supported source detection. + // Allow every file here so it can give the same refusal path for unknown, + // unsupported (.7z/.rar), corrupt, and incomplete sources. + final group = XTypeGroup(label: dialogLabel); + final file = await fileOpener([group]); + return file?.path; + } +} diff --git a/apps/mod-manager/test/core/mgr_ffi_test.dart b/apps/mod-manager/test/core/mgr_ffi_test.dart index 3d86d9efd..652717728 100644 --- a/apps/mod-manager/test/core/mgr_ffi_test.dart +++ b/apps/mod-manager/test/core/mgr_ffi_test.dart @@ -747,32 +747,229 @@ void main() { }); group('MgrFfi remaining commands', () { - test('import returns the new entry', () async { + Map importSuccess({ + required String disposition, + required String matchedBy, + }) => { + 'ok': true, + 'entry': { + 'id': 'mod-c', + 'kind': 'foreign_pak', + 'name': 'loose_P', + 'components': [ + { + 'type': 'loose_pak', + 'rel': 'paks/loose_P.pak', + 'targets': ['G1R/Content/Paks/~mods/loose_P.pak'], + 'coverage': 'exact', + }, + ], + }, + 'disposition': disposition, + 'matched_by': matchedBy, + }; + + test('import parses all three dispositions', () async { + for (final (wire, match, expected) in [ + ('created', 'none', MgrImportDisposition.created), + ('updated', 'source', MgrImportDisposition.updated), + ('unchanged', 'content', MgrImportDisposition.unchanged), + ]) { + final fake = FakeGoreCoreFfiService( + responses: { + 'mgr_import': importSuccess(disposition: wire, matchedBy: match), + }, + ); + final outcome = await MgrFfi(fake).import('D:/downloads/loose_P.pak'); + expect(outcome.disposition, expected); + expect(outcome.entry.id, 'mod-c'); + expect(outcome.entry.kind, 'foreign_pak'); + expect( + outcome.entry.components.single.coverage, + FootprintCoverage.exact, + ); + } + }); + + test('import parses all four verified match methods', () async { + for (final (wire, disposition, expected) in [ + ('none', 'created', MgrImportMatchedBy.none), + ('source', 'updated', MgrImportMatchedBy.source), + ('content', 'updated', MgrImportMatchedBy.content), + ('entry_id', 'updated', MgrImportMatchedBy.entryId), + ]) { + final fake = FakeGoreCoreFfiService( + responses: { + 'mgr_import': importSuccess( + disposition: disposition, + matchedBy: wire, + ), + }, + ); + final outcome = await MgrFfi(fake).import('D:/downloads/mod.zip'); + expect(outcome.matchedBy, expected); + expect(outcome.matchedBy.wireName, wire); + expect(fake.calls.single.payload, {'path': 'D:/downloads/mod.zip'}); + } + }); + + test('import rejects missing, future, or inconsistent outcomes', () async { + for (final response in [ + importSuccess(disposition: 'future', matchedBy: 'none'), + importSuccess(disposition: 'created', matchedBy: 'future'), + importSuccess(disposition: 'created', matchedBy: 'source'), + importSuccess(disposition: 'updated', matchedBy: 'none'), + importSuccess(disposition: 'unchanged', matchedBy: 'none'), + { + 'ok': true, + 'entry': { + 'id': 'mod-c', + 'kind': 'foreign_pak', + 'name': 'loose_P', + 'components': const [], + }, + }, + ]) { + await expectLater( + MgrFfi( + FakeGoreCoreFfiService(responses: {'mgr_import': response}), + ).import('D:/downloads/mod.zip'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IMPORT_INVALID_RESPONSE', + ), + ), + ); + } + }); + + test('duplicate refusal exposes bounded typed candidates', () async { final fake = FakeGoreCoreFfiService( responses: { 'mgr_import': { - 'ok': true, - 'entry': { - 'id': 'mod-c', - 'kind': 'foreign_pak', - 'name': 'loose_P', - 'components': [ - { - 'type': 'loose_pak', - 'rel': 'paks/loose_P.pak', - 'targets': ['G1R/Content/Paks/~mods/loose_P.pak'], - 'coverage': 'exact', - }, - ], + 'ok': false, + 'error': { + 'code': 'IMPORT_DUPLICATE_AMBIGUOUS', + 'message': 'verified duplicate', + 'details': { + 'candidate_ids': ['alpha', 'beta'], + }, }, }, }, ); - final entry = await MgrFfi(fake).import('D:/downloads/loose_P.pak'); - expect(entry.id, 'mod-c'); - expect(entry.kind, 'foreign_pak'); - expect(entry.components.single.coverage, FootprintCoverage.exact); - expect(fake.calls.single.payload, {'path': 'D:/downloads/loose_P.pak'}); + + await expectLater( + MgrFfi(fake).import('D:/downloads/mod.zip'), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'IMPORT_DUPLICATE_AMBIGUOUS', + ) + .having( + (error) => (error.details as MgrImportDuplicateAmbiguousDetails) + .candidates + .map((candidate) => candidate.id), + 'candidate ids', + ['alpha', 'beta'], + ), + ), + ); + }); + + test( + 'identity refusal exposes candidate match roles without parsing text', + () async { + final fake = FakeGoreCoreFfiService( + responses: { + 'mgr_import': { + 'ok': false, + 'error': { + 'code': 'IMPORT_IDENTITY_CONFLICT', + 'message': 'opaque prose that names nothing useful', + 'details': { + 'candidates': [ + { + 'id': 'alpha', + 'matched_by': ['entry_id', 'source'], + }, + { + 'id': 'beta', + 'matched_by': ['content'], + }, + ], + }, + }, + }, + }, + ); + + try { + await MgrFfi(fake).import('D:/downloads/mod.zip'); + fail('expected typed refusal'); + } on MgrFfiException catch (error) { + expect(error.code, 'IMPORT_IDENTITY_CONFLICT'); + final details = error.details as MgrImportIdentityConflictDetails; + expect(details.candidates.map((candidate) => candidate.id), [ + 'alpha', + 'beta', + ]); + expect(details.candidates.first.matchedBy, [ + MgrImportMatchedBy.entryId, + MgrImportMatchedBy.source, + ]); + expect(details.candidates.last.matchedBy, [ + MgrImportMatchedBy.content, + ]); + } + }, + ); + + test('malformed or oversized refusal candidates are not exposed', () async { + for (final details in [ + { + 'candidate_ids': ['alpha', 'beta', 'gamma'], + }, + { + 'candidate_ids': [List.filled(257, 'x').join()], + }, + { + 'candidates': [ + { + 'id': 'alpha', + 'matched_by': ['none'], + }, + ], + }, + ]) { + final code = details.containsKey('candidate_ids') + ? 'IMPORT_DUPLICATE_AMBIGUOUS' + : 'IMPORT_IDENTITY_CONFLICT'; + try { + await MgrFfi( + FakeGoreCoreFfiService( + responses: { + 'mgr_import': { + 'ok': false, + 'error': { + 'code': code, + 'message': 'refused', + 'details': details, + }, + }, + }, + ), + ).import('D:/downloads/mod.zip'); + fail('expected refusal'); + } on MgrFfiException catch (error) { + expect(error.code, code); + expect(error.details, isNull); + } + } }); test('remove/undeployAll report whether anything happened', () async { diff --git a/apps/mod-manager/test/home_page_import_test.dart b/apps/mod-manager/test/home_page_import_test.dart new file mode 100644 index 000000000..c60c2ccf5 --- /dev/null +++ b/apps/mod-manager/test/home_page_import_test.dart @@ -0,0 +1,995 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gore_manager/app/domain/shared_config.dart'; +import 'package:gore_manager/app/domain/ui_settings.dart'; +import 'package:gore_manager/core/core_service.dart'; +import 'package:gore_manager/core/mgr_ffi.dart'; +import 'package:gore_manager/core/providers.dart'; +import 'package:gore_manager/home_page.dart'; +import 'package:gore_manager/l10n/app_localizations.dart'; +import 'package:gore_manager/library/domain/library_notifier.dart'; +import 'package:gore_manager/library/domain/models.dart'; +import 'package:gore_manager/library/ui/import_feedback.dart'; +import 'package:gore_manager/library/ui/import_source_picker.dart'; +import 'package:gore_manager/library/ui/mod_list.dart'; +import 'package:path/path.dart' as p; + +class _SettingsStore implements UiSettingsStore { + @override + UiSettings read() => const UiSettings(); + + @override + void write(UiSettings settings) {} +} + +SharedConfig _config() { + final directory = Directory.systemTemp.createTempSync('gm_import_home'); + addTearDown(() { + if (directory.existsSync()) directory.deleteSync(recursive: true); + }); + return SharedConfig(File(p.join(directory.path, 'config.json'))); +} + +Map _entry(String id, String name) => { + 'id': id, + 'kind': 'foreign_pak', + 'name': name, + 'components': const [], +}; + +typedef _ImportScenario = ({ + Map response, + Map? publishedEntry, +}); + +class _ImportCore implements GoreCoreFfiService { + _ImportCore({ + List>? initialMods, + Map? scenarios, + }) : mods = [...?initialMods], + scenarios = scenarios ?? {}; + + final List> mods; + final Map scenarios; + final List<({String command, Map payload})> calls = []; + final Completer importStarted = Completer(); + Completer? importRelease; + bool failReloadAfterImport = false; + String reloadFailureMessage = 'authoritative reload failed'; + bool _importAttempted = false; + + @override + bool get isAvailable => true; + + @override + String get description => 'home-import-test'; + + @override + Future> execute( + String command, { + Map payload = const {}, + }) async { + calls.add((command: command, payload: payload)); + switch (command) { + case 'mgr_library_list' when _importAttempted && failReloadAfterImport: + return { + 'ok': false, + 'error': {'code': 'IO', 'message': reloadFailureMessage}, + }; + case 'mgr_library_list': + return { + 'ok': true, + 'mods': [for (final mod in mods) Map.from(mod)], + 'loadout': { + 'format': 1, + 'entries': [ + for (final mod in mods) {'id': mod['id'], 'enabled': false}, + ], + }, + }; + case 'mgr_import': + _importAttempted = true; + if (!importStarted.isCompleted) importStarted.complete(); + if (importRelease case final release?) await release.future; + final path = payload['path']; + final scenario = scenarios[path]; + if (scenario == null) { + return { + 'ok': false, + 'error': {'code': 'IMPORT_FAILED', 'message': 'missing fixture'}, + }; + } + if (scenario.publishedEntry case final published?) { + final id = published['id']; + mods.removeWhere((mod) => mod['id'] == id); + mods.add(Map.from(published)); + } + return scenario.response; + case 'mgr_analyze': + return {'ok': true, 'conflicts': const []}; + case 'mgr_status': + return { + 'ok': true, + 'status': {'state': 'nothing_deployed'}, + }; + case 'mgr_preflight_v1': + return fakeHealthyManagerPreflightResponse(); + default: + return {'ok': true}; + } + } +} + +class _Picker implements ImportSourcePicker { + _Picker( + Iterable folderPaths, { + Iterable filePaths = const [], + }) : _folderPaths = List.from(folderPaths), + _filePaths = List.from(filePaths); + + final List _folderPaths; + final List _filePaths; + int folderCalls = 0; + int fileCalls = 0; + + @override + Future pickFolder() async { + folderCalls++; + return _folderPaths.isEmpty ? null : _folderPaths.removeAt(0); + } + + @override + Future pickFile({required String dialogLabel}) async { + fileCalls++; + return _filePaths.isEmpty ? null : _filePaths.removeAt(0); + } +} + +Widget _home( + _ImportCore core, + ImportSourcePicker picker, { + TextScaler textScaler = TextScaler.noScaling, +}) => ProviderScope( + overrides: [ + coreServiceProvider.overrideWithValue(core), + uiSettingsStoreProvider.overrideWithValue(_SettingsStore()), + sharedConfigProvider.overrideWithValue(_config()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: child!, + ), + home: HomePage(importSourcePicker: picker), + ), +); + +Widget _feedbackHarness(Locale locale) => MaterialApp( + locale: locale, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + key: const ValueKey('show-import-failure'), + onPressed: () => showImportFailureFeedback( + context, + MgrFfiException('opaque native detail', code: 'IMPORT_FAILED'), + const LibraryState(authoritative: true), + ), + child: const Text('Show'), + ), + ), + ), +); + +Future _chooseFolder(WidgetTester tester, {bool settle = true}) async { + await tester.tap(find.byKey(const ValueKey('import-mod-action'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('import-folder-action'))); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } +} + +Future _chooseFile(WidgetTester tester) async { + await tester.tap(find.byKey(const ValueKey('import-mod-action'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('import-file-action'))); + await tester.pumpAndSettle(); +} + +Finder _snackLiveRegions() => find.descendant( + of: find.byType(SnackBar), + matching: find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.liveRegion == true, + description: 'SnackBar live region', + ), +); + +void _expectFeedbackDoesNotOwnFocus() { + final context = FocusManager.instance.primaryFocus?.context; + if (context == null) return; + final focused = find.byElementPredicate( + (element) => identical(element, context), + ); + expect( + find.descendant(of: find.byType(SnackBar), matching: focused), + findsNothing, + ); +} + +Map _successWire({ + required String id, + required String disposition, + required String matchedBy, +}) => { + 'ok': true, + 'entry': _entry(id, 'Untrusted wire name'), + 'disposition': disposition, + 'matched_by': matchedBy, +}; + +void main() { + test( + 'all twelve locales provide the complete import feedback surface', + () async { + expect(AppLocalizations.supportedLocales, hasLength(12)); + for (final locale in AppLocalizations.supportedLocales) { + final l10n = await AppLocalizations.delegate.load(locale); + final values = [ + l10n.importOutcomeCreated('Exact name'), + l10n.importOutcomeUpdated('Exact name'), + l10n.importOutcomeUnchanged('Exact name'), + l10n.importOutcomeMatchedBy('none'), + l10n.importOutcomeMatchedBy('source'), + l10n.importOutcomeMatchedBy('content'), + l10n.importOutcomeMatchedBy('entry_id'), + l10n.importRefusalDuplicateAmbiguous, + l10n.importRefusalIdentityConflict, + l10n.importFailed, + l10n.importOutcomeUnknown, + ]; + expect(values.every((value) => value.trim().isNotEmpty), isTrue); + for (final token in const [ + 'ZIP', + '*_P.pak', + '.utoc', + '.ucas', + '.lcache', + '.bank', + 'PrecompiledScript*.Cache', + '.7z', + '.rar', + ]) { + expect( + l10n.importFailed, + contains(token), + reason: '${locale.toLanguageTag()} must explain $token', + ); + } + } + }, + ); + + test( + 'default file picker allows native to classify every extension', + () async { + late List acceptedTypeGroups; + final picker = FileSelectorImportSourcePicker( + fileOpener: (groups) async { + acceptedTypeGroups = groups; + return XFile('D:/picked/unsupported.7z'); + }, + ); + + expect( + await picker.pickFile(dialogLabel: 'Import file'), + 'D:/picked/unsupported.7z', + ); + expect(acceptedTypeGroups, hasLength(1)); + expect(acceptedTypeGroups.single.label, 'Import file'); + expect(acceptedTypeGroups.single.allowsAny, isTrue); + }, + ); + + testWidgets('long localized guidance remains usable at compact 200% scale', ( + tester, + ) async { + tester.view.physicalSize = const Size(700, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + const locale = Locale('de'); + await tester.pumpWidget(_feedbackHarness(locale)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('show-import-failure'))); + await tester.pumpAndSettle(); + + final l10n = await AppLocalizations.delegate.load(locale); + expect(find.text(l10n.importFailed), findsOneWidget); + final messageScroll = find.byKey( + const ValueKey('import-feedback-message-scroll'), + ); + final scrollable = find.descendant( + of: messageScroll, + matching: find.byType(Scrollable), + ); + expect(scrollable, findsOneWidget); + final position = tester.state(scrollable).position; + expect(position.maxScrollExtent, greaterThan(0)); + expect(_snackLiveRegions(), findsOneWidget); + _expectFeedbackDoesNotOwnFocus(); + final toggle = find.byKey(const ValueKey('import-feedback-details-toggle')); + expect(toggle.hitTestable(), findsOneWidget); + expect(tester.getSize(toggle).height, greaterThanOrEqualTo(44)); + expect( + tester.getSemantics(toggle), + matchesSemantics( + label: l10n.coreTechnicalDetails, + isButton: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + hasExpandedState: true, + ), + ); + var snackRect = tester.getRect(find.byType(SnackBar)); + expect(snackRect.left, greaterThanOrEqualTo(0)); + expect(snackRect.right, lessThanOrEqualTo(700)); + expect(snackRect.top, greaterThanOrEqualTo(0)); + expect(snackRect.bottom, lessThanOrEqualTo(600)); + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect( + tester.getSemantics(toggle), + matchesSemantics( + label: l10n.coreTechnicalDetails, + isButton: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + hasExpandedState: true, + isExpanded: true, + ), + ); + expect( + find.byKey(const ValueKey('import-feedback-details')), + findsOneWidget, + ); + expect(_snackLiveRegions(), findsOneWidget); + snackRect = tester.getRect(find.byType(SnackBar)); + expect(snackRect.left, greaterThanOrEqualTo(0)); + expect(snackRect.right, lessThanOrEqualTo(700)); + expect(snackRect.top, greaterThanOrEqualTo(0)); + expect(snackRect.bottom, lessThanOrEqualTo(600)); + expect(tester.takeException(), isNull); + }); + + test('display names are rune-bounded and sanitize controls and bidi', () { + final name = 'Exact\nname\u202E${'x' * 300}'; + final shown = importDisplayName( + ModEntryMetaView(id: 'fallback', kind: 'foreign_pak', name: name), + ); + expect(shown, startsWith('Exact name')); + expect(shown, isNot(contains('\n'))); + expect(shown, isNot(contains('\u202E'))); + expect(shown, endsWith('…')); + expect(shown.runes.length, lessThanOrEqualTo(161)); + }); + + final outcomes = + <({String disposition, String matchedBy, String id, String name})>[ + ( + disposition: 'created', + matchedBy: 'none', + id: 'created-mod', + name: 'Authoritative Added', + ), + ( + disposition: 'updated', + matchedBy: 'source', + id: 'source-mod', + name: 'Authoritative Source Update', + ), + ( + disposition: 'updated', + matchedBy: 'content', + id: 'content-mod', + name: 'Authoritative Content Update', + ), + ( + disposition: 'unchanged', + matchedBy: 'entry_id', + id: 'id-mod', + name: 'Authoritative Existing', + ), + ]; + + for (final outcome in outcomes) { + testWidgets( + 'shows ${outcome.disposition}/${outcome.matchedBy} with authoritative name', + (tester) async { + const path = 'D:/picked/mod.zip'; + final core = _ImportCore( + initialMods: [ + if (outcome.disposition != 'created') + _entry(outcome.id, 'Old name'), + ], + scenarios: { + path: ( + response: _successWire( + id: outcome.id, + disposition: outcome.disposition, + matchedBy: outcome.matchedBy, + ), + publishedEntry: _entry(outcome.id, outcome.name), + ), + }, + ); + await tester.pumpWidget(_home(core, _Picker([path]))); + await tester.pumpAndSettle(); + + await _chooseFolder(tester); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + final dispositionText = switch (outcome.disposition) { + 'created' => l10n.importOutcomeCreated(outcome.name), + 'updated' => l10n.importOutcomeUpdated(outcome.name), + _ => l10n.importOutcomeUnchanged(outcome.name), + }; + expect( + find.text( + '$dispositionText ' + '${l10n.importOutcomeMatchedBy(outcome.matchedBy)}', + ), + findsOneWidget, + ); + expect(find.textContaining('Untrusted wire name'), findsNothing); + expect(_snackLiveRegions(), findsOneWidget); + _expectFeedbackDoesNotOwnFocus(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(HomePage)), + ); + expect(container.read(selectedModProvider), outcome.id); + expect( + container.read(libraryProvider).modById(outcome.id)?.name, + outcome.name, + ); + }, + ); + } + + testWidgets('picker cancellation changes no Manager or selection state', ( + tester, + ) async { + final core = _ImportCore(initialMods: [_entry('alpha', 'Alpha')]); + final picker = _Picker([null]); + await tester.pumpWidget(_home(core, picker)); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(HomePage)), + ); + container.read(selectedModProvider.notifier).state = 'alpha'; + final importButton = tester.widget( + find.byKey(const ValueKey('import-mod-action')), + ); + importButton.focusNode!.requestFocus(); + await tester.pump(); + expect(importButton.focusNode!.hasFocus, isTrue); + final callsBefore = core.calls.length; + + await _chooseFolder(tester); + + expect(picker.folderCalls, 1); + expect(core.calls, hasLength(callsBefore)); + expect(container.read(selectedModProvider), 'alpha'); + expect(container.read(libraryProvider).mods, hasLength(1)); + expect(find.byType(SnackBar), findsNothing); + expect(importButton.focusNode!.hasFocus, isTrue); + }); + + for (final source in const [ + (extension: '7z', path: 'D:/picked/unsupported.7z'), + (extension: 'rar', path: 'D:/picked/unsupported.rar'), + (extension: 'unknown', path: 'D:/picked/unsupported.unknown'), + ]) { + testWidgets( + '${source.extension} selection reaches native and shows honest guidance', + (tester) async { + final compact = source.extension == '7z'; + if (compact) { + tester.view.physicalSize = const Size(700, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + final core = _ImportCore( + scenarios: { + source.path: ( + response: { + 'ok': false, + 'error': { + 'code': 'IMPORT_FAILED', + 'message': 'native-only reason must stay collapsed', + }, + }, + publishedEntry: null, + ), + }, + ); + final picker = _Picker(const [], filePaths: [source.path]); + await tester.pumpWidget( + _home( + core, + picker, + textScaler: compact + ? const TextScaler.linear(2) + : TextScaler.noScaling, + ), + ); + await tester.pumpAndSettle(); + + await _chooseFile(tester); + + final importCall = core.calls + .where((call) => call.command == 'mgr_import') + .single; + expect(importCall.payload['path'], source.path); + expect(picker.fileCalls, 1); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(l10n.importFailed), findsOneWidget); + expect(find.textContaining('native-only reason'), findsNothing); + expect(_snackLiveRegions(), findsOneWidget); + _expectFeedbackDoesNotOwnFocus(); + if (compact) { + final snackRect = tester.getRect(find.byType(SnackBar)); + expect(snackRect.left, greaterThanOrEqualTo(0)); + expect(snackRect.right, lessThanOrEqualTo(700)); + expect(snackRect.top, greaterThanOrEqualTo(0)); + expect(snackRect.bottom, lessThanOrEqualTo(600)); + expect( + find.byKey(const ValueKey('import-feedback-message-scroll')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + } + }, + ); + } + + testWidgets('A to B to A selection race never auto-selects the import', ( + tester, + ) async { + const path = 'D:/picked/race.zip'; + final core = _ImportCore( + initialMods: [_entry('alpha', 'Alpha'), _entry('beta', 'Beta')], + scenarios: { + path: ( + response: _successWire( + id: 'created-mod', + disposition: 'created', + matchedBy: 'none', + ), + publishedEntry: _entry('created-mod', 'Created During Race'), + ), + }, + )..importRelease = Completer(); + await tester.pumpWidget(_home(core, _Picker([path]))); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(HomePage)), + ); + container.read(selectedModProvider.notifier).state = 'alpha'; + await tester.pump(); + + await _chooseFolder(tester, settle: false); + await core.importStarted.future; + container.read(selectedModProvider.notifier).state = 'beta'; + await tester.pump(); + container.read(selectedModProvider.notifier).state = 'alpha'; + await tester.pump(); + core.importRelease!.complete(); + await tester.pumpAndSettle(); + + expect(container.read(selectedModProvider), 'alpha'); + expect( + container.read(libraryProvider).modById('created-mod')?.name, + 'Created During Race', + ); + expect( + find.byKey(const ValueKey('import-feedback-message')), + findsOneWidget, + ); + expect( + tester + .widget(find.byKey(const ValueKey('import-feedback-message'))) + .data, + contains('Created During Race'), + ); + }); + + final refusals = <({String code, String friendly, Object details})>[ + ( + code: 'IMPORT_DUPLICATE_AMBIGUOUS', + friendly: 'duplicate', + details: { + 'candidate_ids': ['alpha', 'beta'], + }, + ), + ( + code: 'IMPORT_IDENTITY_CONFLICT', + friendly: 'identity', + details: { + 'candidates': [ + { + 'id': 'alpha', + 'matched_by': ['entry_id', 'source'], + }, + { + 'id': 'beta', + 'matched_by': ['content'], + }, + ], + }, + ), + ]; + + for (final refusal in refusals) { + testWidgets( + '${refusal.friendly} refusal is friendly with bounded expandable detail', + (tester) async { + tester.view.physicalSize = const Size(700, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + const path = 'D:/picked/refused.zip'; + final core = _ImportCore( + initialMods: [ + _entry('alpha', 'Alpha\nName\u202E'), + _entry('beta', 'Beta'), + ], + scenarios: { + path: ( + response: { + 'ok': false, + 'error': { + 'code': refusal.code, + 'message': 'native\nsecret\u202E${'x' * 1400}', + 'details': refusal.details, + }, + }, + publishedEntry: null, + ), + }, + ); + await tester.pumpWidget( + _home(core, _Picker([path]), textScaler: const TextScaler.linear(2)), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'compact startup'); + final container = ProviderScope.containerOf( + tester.element(find.byType(HomePage)), + ); + container.read(selectedModProvider.notifier).state = 'alpha'; + + await _chooseFolder(tester); + expect(tester.takeException(), isNull, reason: 'collapsed feedback'); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + final expected = refusal.code == 'IMPORT_DUPLICATE_AMBIGUOUS' + ? l10n.importRefusalDuplicateAmbiguous + : l10n.importRefusalIdentityConflict; + expect(find.text(expected), findsOneWidget); + expect(find.textContaining('native'), findsNothing); + expect(find.textContaining('secret'), findsNothing); + expect(container.read(selectedModProvider), 'alpha'); + expect(container.read(libraryProvider).error, isNull); + expect(_snackLiveRegions(), findsOneWidget); + _expectFeedbackDoesNotOwnFocus(); + + final toggle = find.byKey( + const ValueKey('import-feedback-details-toggle'), + ); + expect(toggle.hitTestable(), findsOneWidget); + expect(tester.getSize(toggle).height, greaterThanOrEqualTo(44)); + expect( + tester.getSemantics(toggle), + matchesSemantics( + label: l10n.coreTechnicalDetails, + isButton: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + hasExpandedState: true, + ), + ); + final snackBar = tester.widget(find.byType(SnackBar)); + expect(snackBar.persist, isTrue); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect( + tester.getSemantics(toggle), + matchesSemantics( + label: l10n.coreTechnicalDetails, + isButton: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + hasExpandedState: true, + isExpanded: true, + ), + ); + final details = tester.widget( + find.byKey(const ValueKey('import-feedback-details')), + ); + expect(details.data, contains('native secret')); + expect(details.data, contains('Alpha Name')); + expect(details.data, contains('Beta')); + expect(details.data, isNot(contains('\u202E'))); + expect(details.data, isNot(contains('\n'))); + expect(details.data!.runes.length, lessThanOrEqualTo(1025)); + expect(_snackLiveRegions(), findsOneWidget); + final snackRect = tester.getRect(find.byType(SnackBar)); + expect(snackRect.left, greaterThanOrEqualTo(0)); + expect(snackRect.right, lessThanOrEqualTo(700)); + expect(snackRect.top, greaterThanOrEqualTo(0)); + expect(snackRect.bottom, lessThanOrEqualTo(600)); + expect(tester.takeException(), isNull, reason: 'expanded feedback'); + }, + ); + } + + testWidgets('technical details toggle is keyboard operable', (tester) async { + const path = 'D:/picked/keyboard-refusal.zip'; + final core = _ImportCore( + scenarios: { + path: ( + response: { + 'ok': false, + 'error': { + 'code': 'IMPORT_DUPLICATE_AMBIGUOUS', + 'message': 'bounded diagnostic', + 'details': { + 'candidate_ids': ['alpha', 'beta'], + }, + }, + }, + publishedEntry: null, + ), + }, + ); + await tester.pumpWidget(_home(core, _Picker([path]))); + await tester.pumpAndSettle(); + + await _chooseFolder(tester); + final toggle = find.byKey(const ValueKey('import-feedback-details-toggle')); + final button = tester.widget(toggle); + button.focusNode!.requestFocus(); + await tester.pump(); + expect(button.focusNode!.hasFocus, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('import-feedback-details')), + findsOneWidget, + ); + }); + + testWidgets('structured refusal witnesses retain both maximum-bound ids', ( + tester, + ) async { + const path = 'D:/picked/bounded-refusal.zip'; + final firstId = 'a' * 256; + final secondId = 'b' * 256; + final core = _ImportCore( + initialMods: [ + _entry(firstId, 'First ${'x' * 300}'), + _entry(secondId, 'Second ${'y' * 300}'), + ], + scenarios: { + path: ( + response: { + 'ok': false, + 'error': { + 'code': 'IMPORT_IDENTITY_CONFLICT', + 'message': 'opaque ${'z' * 900}', + 'details': { + 'candidates': [ + { + 'id': firstId, + 'matched_by': ['entry_id', 'source', 'content'], + }, + { + 'id': secondId, + 'matched_by': ['entry_id', 'source', 'content'], + }, + ], + }, + }, + }, + publishedEntry: null, + ), + }, + ); + await tester.pumpWidget(_home(core, _Picker([path]))); + await tester.pumpAndSettle(); + + await _chooseFolder(tester); + await tester.tap( + find.byKey(const ValueKey('import-feedback-details-toggle')), + ); + await tester.pumpAndSettle(); + + final details = tester.widget( + find.byKey(const ValueKey('import-feedback-details')), + ); + expect(details.data, contains(firstId)); + expect(details.data, contains(secondId)); + expect(details.data!.runes.length, lessThanOrEqualTo(1025)); + }); + + testWidgets( + 'generic failure admits partial publication and refreshes truth', + (tester) async { + const path = 'D:/picked/partial.zip'; + final core = _ImportCore( + initialMods: [_entry('alpha', 'Alpha')], + scenarios: { + path: ( + response: { + 'ok': false, + 'error': { + 'code': 'IO', + 'message': 'loadout\nfollow-up failed\u202E', + }, + }, + publishedEntry: _entry('partial-mod', 'Published Before Error'), + ), + }, + ); + await tester.pumpWidget(_home(core, _Picker([path]))); + await tester.pumpAndSettle(); + + await _chooseFolder(tester); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(l10n.importFailed), findsOneWidget); + expect( + l10n.importFailed, + contains('may already have been added or updated'), + ); + expect( + l10n.importFailed.toLowerCase(), + isNot(contains('nothing changed')), + ); + expect( + find.textContaining('Added “Published Before Error”'), + findsNothing, + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(HomePage)), + ); + expect(container.read(libraryProvider).authoritative, isTrue); + expect( + container.read(libraryProvider).modById('partial-mod')?.name, + 'Published Before Error', + ); + expect(container.read(libraryProvider).error, isNull); + }, + ); + + testWidgets('failed authoritative reload produces no success outcome', ( + tester, + ) async { + const path = 'D:/picked/reload-fails.zip'; + final core = _ImportCore( + initialMods: [_entry('alpha', 'Alpha')], + scenarios: { + path: ( + response: _successWire( + id: 'created-mod', + disposition: 'created', + matchedBy: 'none', + ), + publishedEntry: _entry('created-mod', 'Must Not Toast'), + ), + }, + )..failReloadAfterImport = true; + await tester.pumpWidget(_home(core, _Picker([path]))); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(HomePage)), + ); + container.read(selectedModProvider.notifier).state = 'alpha'; + + await _chooseFolder(tester); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(l10n.importOutcomeUnknown), findsOneWidget); + expect(find.textContaining('Added “Must Not Toast”'), findsNothing); + expect(container.read(libraryProvider).authoritative, isFalse); + expect(container.read(libraryProvider).mods, isEmpty); + expect(container.read(selectedModProvider), 'alpha'); + expect( + container.read(libraryProvider).error, + contains('authoritative reload failed'), + ); + }); + + testWidgets('failed reload banner sanitizes and bounds native detail', ( + tester, + ) async { + const path = 'D:/picked/reload-detail.zip'; + final core = + _ImportCore( + scenarios: { + path: ( + response: _successWire( + id: 'created-mod', + disposition: 'created', + matchedBy: 'none', + ), + publishedEntry: _entry('created-mod', 'Must Not Toast'), + ), + }, + ) + ..failReloadAfterImport = true + ..reloadFailureMessage = 'unsafe\nreload\u202E${'x' * 900}'; + await tester.pumpWidget(_home(core, _Picker([path]))); + await tester.pumpAndSettle(); + + await _chooseFolder(tester); + + final banner = tester + .widgetList(find.byType(Text)) + .map((text) => text.data) + .whereType() + .firstWhere((text) => text.startsWith('mgr_library_list: unsafe')); + expect(banner, startsWith('mgr_library_list: unsafe reload')); + expect(banner, isNot(contains('\n'))); + expect(banner, isNot(contains('\u202E'))); + expect(banner, endsWith('…')); + expect(banner.runes.length, lessThanOrEqualTo(513)); + }); +} diff --git a/apps/mod-manager/test/library/library_notifier_test.dart b/apps/mod-manager/test/library/library_notifier_test.dart index 044e24870..b2dc2057c 100644 --- a/apps/mod-manager/test/library/library_notifier_test.dart +++ b/apps/mod-manager/test/library/library_notifier_test.dart @@ -77,7 +77,12 @@ class _BlockingRemoveCore implements GoreCoreFfiService { /// failed to exercise the fail-closed boundary. class _PartialImportCore implements GoreCoreFfiService { bool imported = false; + bool failImport = true; bool failReload = false; + bool omitImportedFromReload = false; + bool blockReload = false; + final reloadStarted = Completer(); + final releaseReload = Completer(); final List<({String command, Map payload})> calls = []; @override @@ -95,6 +100,19 @@ class _PartialImportCore implements GoreCoreFfiService { switch (command) { case 'mgr_import': imported = true; + if (!failImport) { + return { + 'ok': true, + 'entry': { + 'id': 'mod-new', + 'kind': 'foreign_pak', + 'name': 'Wire Name', + 'components': const [], + }, + 'disposition': 'created', + 'matched_by': 'none', + }; + } return { 'ok': false, 'error': {'code': 'IO', 'message': 'loadout follow-up failed'}, @@ -105,8 +123,12 @@ class _PartialImportCore implements GoreCoreFfiService { 'error': {'code': 'IO', 'message': 'reload failed'}, }; case 'mgr_library_list': + if (imported && blockReload) { + if (!reloadStarted.isCompleted) reloadStarted.complete(); + await releaseReload.future; + } return _libraryList( - loadout: imported + loadout: imported && !omitImportedFromReload ? [('mod-a', true), ('mod-new', false)] : [('mod-a', true)], ); @@ -312,7 +334,14 @@ void main() { responses: { 'mgr_import': { 'ok': true, - 'entry': {'id': 'mod-new', 'kind': 'foreign_pak', 'name': 'New'}, + 'entry': { + 'id': 'mod-new', + 'kind': 'foreign_pak', + 'name': 'Wire New', + 'components': const [], + }, + 'disposition': 'created', + 'matched_by': 'none', }, 'mgr_library_list': _libraryList( loadout: [('mod-a', true), ('mod-new', false)], @@ -324,13 +353,17 @@ void main() { final n = await _settled(fake); fake.calls.clear(); - await n.import('D:/downloads/new.pak'); + final outcome = await n.import('D:/downloads/new.pak'); expect(fake.calls[0].command, 'mgr_import'); expect(fake.calls[0].payload, {'path': 'D:/downloads/new.pak'}); // Followed by a refresh that surfaces the new mod, appended disabled. expect(fake.calls.any((c) => c.command == 'mgr_library_list'), isTrue); expect(n.state.mods.map((m) => m.id), contains('mod-new')); + expect(outcome, isNotNull); + expect(outcome!.entry.name, 'MOD-NEW'); + expect(outcome.disposition, MgrImportDisposition.created); + expect(outcome.matchedBy, MgrImportMatchedBy.none); final newEntry = n.state.loadout.entries.firstWhere( (e) => e.id == 'mod-new', ); @@ -344,11 +377,16 @@ void main() { final notifier = LibraryNotifier(MgrFfi(core)); await notifier.refresh(); - await notifier.import('D:/downloads/new.pak'); + await expectLater( + notifier.import('D:/downloads/new.pak'), + throwsA( + isA().having((error) => error.code, 'code', 'IO'), + ), + ); expect(notifier.state.authoritative, isTrue); expect(notifier.state.mods.map((mod) => mod.id), contains('mod-new')); - expect(notifier.state.error, contains('loadout follow-up failed')); + expect(notifier.state.error, isNull); expect( core.calls.where((call) => call.command == 'mgr_library_list'), hasLength(2), @@ -364,12 +402,23 @@ void main() { await notifier.refresh(); core.failReload = true; - await notifier.import('D:/downloads/new.pak'); + await expectLater( + notifier.import('D:/downloads/new.pak'), + throwsA( + isA() + .having((error) => error.code, 'code', 'IO') + .having( + (error) => error.message, + 'message', + contains('reload failed'), + ), + ), + ); expect(notifier.state.authoritative, isFalse); expect(notifier.state.mods, isEmpty); expect(notifier.state.loadout.entries, isEmpty); - expect(notifier.state.error, contains('loadout follow-up failed')); + expect(notifier.state.error, contains('reload failed')); final callsBeforeBlockedToggle = core.calls.length; await notifier.toggle('mod-a'); @@ -382,6 +431,83 @@ void main() { expect(notifier.state.error, isNull); }, ); + + test( + 'successful native import returns no outcome when reload fails', + () async { + final core = _PartialImportCore()..failImport = false; + final notifier = LibraryNotifier(MgrFfi(core)); + await notifier.refresh(); + core.failReload = true; + + await expectLater( + notifier.import('D:/downloads/new.pak'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('reload failed'), + ), + ), + ); + + expect(notifier.state.authoritative, isFalse); + expect(notifier.state.mods, isEmpty); + expect(notifier.state.error, contains('reload failed')); + }, + ); + + test('does not complete the outcome before authoritative reload', () async { + final core = _PartialImportCore() + ..failImport = false + ..blockReload = true; + final notifier = LibraryNotifier(MgrFfi(core)); + await notifier.refresh(); + + var completed = false; + final importing = notifier.import('D:/downloads/new.pak').then((outcome) { + completed = true; + return outcome; + }); + await core.reloadStarted.future; + await Future.delayed(Duration.zero); + expect(completed, isFalse); + expect(notifier.state.busy, isTrue); + + core.releaseReload.complete(); + final outcome = await importing; + expect(completed, isTrue); + expect(outcome?.entry.id, 'mod-new'); + expect(outcome?.entry.name, 'MOD-NEW'); + expect(notifier.state.authoritative, isTrue); + expect(notifier.state.busy, isFalse); + }); + + test( + 'missing imported id in the reloaded snapshot is not success', + () async { + final core = _PartialImportCore() + ..failImport = false + ..omitImportedFromReload = true; + final notifier = LibraryNotifier(MgrFfi(core)); + await notifier.refresh(); + + await expectLater( + notifier.import('D:/downloads/new.pak'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IMPORT_INVALID_RESPONSE', + ), + ), + ); + + expect(notifier.state.authoritative, isTrue); + expect(notifier.state.modById('mod-new'), isNull); + expect(notifier.state.error, isNull); + }, + ); }); group('LibraryNotifier single-flight', () { From c999d87d549d1fcce3ef0a478c46e65db790be2d Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 12 Aug 2026 20:24:17 +0200 Subject: [PATCH 2/2] fix(manager): distinguish picker failures --- apps/mod-manager/lib/l10n/app_de.arb | 1 + apps/mod-manager/lib/l10n/app_en.arb | 1 + apps/mod-manager/lib/l10n/app_es.arb | 1 + apps/mod-manager/lib/l10n/app_fr.arb | 1 + apps/mod-manager/lib/l10n/app_it.arb | 1 + apps/mod-manager/lib/l10n/app_ja.arb | 1 + .../lib/l10n/app_localizations.dart | 6 ++++ .../lib/l10n/app_localizations_de.dart | 4 +++ .../lib/l10n/app_localizations_en.dart | 4 +++ .../lib/l10n/app_localizations_es.dart | 4 +++ .../lib/l10n/app_localizations_fr.dart | 4 +++ .../lib/l10n/app_localizations_it.dart | 4 +++ .../lib/l10n/app_localizations_ja.dart | 4 +++ .../lib/l10n/app_localizations_pl.dart | 4 +++ .../lib/l10n/app_localizations_pt.dart | 8 ++++++ .../lib/l10n/app_localizations_ru.dart | 4 +++ .../lib/l10n/app_localizations_zh.dart | 6 ++++ apps/mod-manager/lib/l10n/app_pl.arb | 1 + apps/mod-manager/lib/l10n/app_pt.arb | 1 + apps/mod-manager/lib/l10n/app_pt_BR.arb | 1 + apps/mod-manager/lib/l10n/app_ru.arb | 1 + apps/mod-manager/lib/l10n/app_zh.arb | 1 + apps/mod-manager/lib/l10n/app_zh_Hans.arb | 1 + .../lib/library/ui/import_feedback.dart | 18 ++++++------ .../test/home_page_import_test.dart | 28 +++++++++++++++++++ 25 files changed, 102 insertions(+), 8 deletions(-) diff --git a/apps/mod-manager/lib/l10n/app_de.arb b/apps/mod-manager/lib/l10n/app_de.arb index 04c1eb1a3..eea734a19 100644 --- a/apps/mod-manager/lib/l10n/app_de.arb +++ b/apps/mod-manager/lib/l10n/app_de.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "Dieser Import stimmt mit mehreren Bibliothekseinträgen überein. Überprüfe oder entferne die Duplikate und versuche es erneut.", "importRefusalIdentityConflict": "Die Importquelle und der Inhalt stimmen mit unterschiedlichen Bibliothekseinträgen überein. Überprüfe oder entferne die betroffenen Einträge und versuche es erneut.", "importFailed": "Der Import konnte nicht abgeschlossen werden. Unterstützte Quellen: Ordner, ZIP, lose *_P.pak-Dateien, vollständige .utoc/.ucas-Dateisätze (optional .pak), .lcache, .bank und PrecompiledScript*.Cache. Entpacke .7z oder .rar zuerst und importiere dann den Ordner. Die Quelle wird möglicherweise nicht unterstützt, ist beschädigt oder unvollständig. Der Mod wurde möglicherweise bereits hinzugefügt oder aktualisiert; aktualisiere und überprüfe die Bibliothek, bevor du es erneut versuchst.", + "importPickerFailed": "Die Datei- oder Ordnerauswahl konnte nicht geöffnet werden. Es wurde kein Import gestartet. Versuche es erneut.", "importOutcomeUnknown": "Das Importergebnis konnte nicht überprüft werden. Wähle „Aktualisieren“, um die Bibliothek zu prüfen.", "applyTooltip": "Ladeliste auf das Spiel anwenden", "undeployAllAction": "Alle Bereitstellungen aufheben", diff --git a/apps/mod-manager/lib/l10n/app_en.arb b/apps/mod-manager/lib/l10n/app_en.arb index 59a90b94d..64a63e60c 100644 --- a/apps/mod-manager/lib/l10n/app_en.arb +++ b/apps/mod-manager/lib/l10n/app_en.arb @@ -132,6 +132,7 @@ "importRefusalDuplicateAmbiguous": "This import matches more than one library entry. Review or remove the duplicates, then try again.", "importRefusalIdentityConflict": "The import source and its content match different library entries. Review or remove the conflicting entries, then try again.", "importFailed": "The import could not be completed. Supported sources: folders, ZIP, loose *_P.pak files, complete .utoc/.ucas sets (optional .pak), .lcache, .bank, and PrecompiledScript*.Cache. Extract .7z or .rar first, then import the folder. The source may be unsupported, corrupt, or incomplete. The mod may already have been added or updated; refresh and check the library before trying again.", + "importPickerFailed": "The file or folder picker could not be opened. No import was started. Try again.", "importOutcomeUnknown": "The import result could not be verified. Choose Refresh to check the library.", "applyTooltip": "Apply the loadout to the game", "undeployAllAction": "Undeploy all", diff --git a/apps/mod-manager/lib/l10n/app_es.arb b/apps/mod-manager/lib/l10n/app_es.arb index 91743a9f9..a1f9e2d24 100644 --- a/apps/mod-manager/lib/l10n/app_es.arb +++ b/apps/mod-manager/lib/l10n/app_es.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "Esta importación coincide con varias entradas de la biblioteca. Revisa o elimina los duplicados y vuelve a intentarlo.", "importRefusalIdentityConflict": "La fuente de importación y su contenido coinciden con distintas entradas de la biblioteca. Revisa o elimina las entradas en conflicto y vuelve a intentarlo.", "importFailed": "No se pudo completar la importación. Fuentes compatibles: carpetas, ZIP, archivos *_P.pak sueltos, conjuntos .utoc/.ucas completos (con .pak opcional), .lcache, .bank y PrecompiledScript*.Cache. Extrae primero los archivos .7z o .rar y después importa la carpeta. La fuente puede no ser compatible, estar dañada o incompleta. Es posible que el mod ya se haya añadido o actualizado; actualiza y comprueba la biblioteca antes de volver a intentarlo.", + "importPickerFailed": "No se pudo abrir el selector de archivos o carpetas. No se inició ninguna importación. Inténtalo de nuevo.", "importOutcomeUnknown": "No se pudo verificar el resultado de la importación. Selecciona Actualizar para comprobar la biblioteca.", "applyTooltip": "Aplicar la configuración al juego", "undeployAllAction": "Retirar todo", diff --git a/apps/mod-manager/lib/l10n/app_fr.arb b/apps/mod-manager/lib/l10n/app_fr.arb index 0f8a4e8df..d4edddf39 100644 --- a/apps/mod-manager/lib/l10n/app_fr.arb +++ b/apps/mod-manager/lib/l10n/app_fr.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "Cette importation correspond à plusieurs entrées de la bibliothèque. Vérifiez ou supprimez les doublons, puis réessayez.", "importRefusalIdentityConflict": "La source d’importation et son contenu correspondent à des entrées différentes de la bibliothèque. Vérifiez ou supprimez les entrées en conflit, puis réessayez.", "importFailed": "L’importation n’a pas pu être terminée. Sources prises en charge : dossiers, ZIP, fichiers *_P.pak autonomes, ensembles .utoc/.ucas complets (.pak facultatif), .lcache, .bank et PrecompiledScript*.Cache. Extrayez d’abord les archives .7z ou .rar, puis importez le dossier. La source peut être non prise en charge, endommagée ou incomplète. Le mod a peut-être déjà été ajouté ou mis à jour ; actualisez et vérifiez la bibliothèque avant de réessayer.", + "importPickerFailed": "Le sélecteur de fichiers ou de dossiers n’a pas pu être ouvert. Aucune importation n’a été lancée. Réessayez.", "importOutcomeUnknown": "Le résultat de l’importation n’a pas pu être vérifié. Sélectionnez Actualiser pour vérifier la bibliothèque.", "applyTooltip": "Appliquer la configuration au jeu", "undeployAllAction": "Tout retirer", diff --git a/apps/mod-manager/lib/l10n/app_it.arb b/apps/mod-manager/lib/l10n/app_it.arb index 57c68e776..02f28a75a 100644 --- a/apps/mod-manager/lib/l10n/app_it.arb +++ b/apps/mod-manager/lib/l10n/app_it.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "Questa importazione corrisponde a più di una voce della libreria. Controlla o rimuovi i duplicati, quindi riprova.", "importRefusalIdentityConflict": "L’origine dell’importazione e il suo contenuto corrispondono a voci diverse della libreria. Controlla o rimuovi le voci in conflitto, quindi riprova.", "importFailed": "Non è stato possibile completare l’importazione. Sorgenti supportate: cartelle, ZIP, file *_P.pak autonomi, set .utoc/.ucas completi (.pak facoltativo), .lcache, .bank e PrecompiledScript*.Cache. Estrai prima gli archivi .7z o .rar, quindi importa la cartella. La sorgente potrebbe non essere supportata, essere danneggiata o incompleta. Il mod potrebbe essere già stato aggiunto o aggiornato; aggiorna e controlla la libreria prima di riprovare.", + "importPickerFailed": "Non è stato possibile aprire il selettore di file o cartelle. Non è stata avviata alcuna importazione. Riprova.", "importOutcomeUnknown": "Non è stato possibile verificare il risultato dell’importazione. Seleziona Aggiorna per controllare la libreria.", "applyTooltip": "Applica la configurazione al gioco", "undeployAllAction": "Ritira tutto", diff --git a/apps/mod-manager/lib/l10n/app_ja.arb b/apps/mod-manager/lib/l10n/app_ja.arb index feee6778c..2570547b5 100644 --- a/apps/mod-manager/lib/l10n/app_ja.arb +++ b/apps/mod-manager/lib/l10n/app_ja.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "このインポートは複数のライブラリエントリに一致します。重複を確認するか削除してから、もう一度お試しください。", "importRefusalIdentityConflict": "インポート元とそのコンテンツが、ライブラリ内の別々のエントリに一致しています。競合するエントリを確認するか削除してから、もう一度お試しください。", "importFailed": "インポートを完了できませんでした。対応するソースは、フォルダー、ZIP、単体の *_P.pak、完全な .utoc/.ucas セット(.pak は任意)、.lcache、.bank、PrecompiledScript*.Cache です。.7z または .rar は先に展開し、そのフォルダーをインポートしてください。ソースは未対応、破損、または不完全な可能性があります。Mod はすでに追加または更新されている可能性があります。ライブラリを更新して確認してから、もう一度お試しください。", + "importPickerFailed": "ファイルまたはフォルダーの選択画面を開けませんでした。インポートは開始されていません。もう一度お試しください。", "importOutcomeUnknown": "インポート結果を確認できませんでした。[更新]を選択してライブラリを確認してください。", "applyTooltip": "ロードアウトをゲームに適用", "undeployAllAction": "すべて解除", diff --git a/apps/mod-manager/lib/l10n/app_localizations.dart b/apps/mod-manager/lib/l10n/app_localizations.dart index 8823ae0ba..7ed884b98 100644 --- a/apps/mod-manager/lib/l10n/app_localizations.dart +++ b/apps/mod-manager/lib/l10n/app_localizations.dart @@ -584,6 +584,12 @@ abstract class AppLocalizations { /// **'The import could not be completed. Supported sources: folders, ZIP, loose *_P.pak files, complete .utoc/.ucas sets (optional .pak), .lcache, .bank, and PrecompiledScript*.Cache. Extract .7z or .rar first, then import the folder. The source may be unsupported, corrupt, or incomplete. The mod may already have been added or updated; refresh and check the library before trying again.'** String get importFailed; + /// No description provided for @importPickerFailed. + /// + /// In en, this message translates to: + /// **'The file or folder picker could not be opened. No import was started. Try again.'** + String get importPickerFailed; + /// No description provided for @importOutcomeUnknown. /// /// In en, this message translates to: diff --git a/apps/mod-manager/lib/l10n/app_localizations_de.dart b/apps/mod-manager/lib/l10n/app_localizations_de.dart index 9d2d96213..49a6b6eeb 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_de.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_de.dart @@ -293,6 +293,10 @@ class AppLocalizationsDe extends AppLocalizations { String get importFailed => 'Der Import konnte nicht abgeschlossen werden. Unterstützte Quellen: Ordner, ZIP, lose *_P.pak-Dateien, vollständige .utoc/.ucas-Dateisätze (optional .pak), .lcache, .bank und PrecompiledScript*.Cache. Entpacke .7z oder .rar zuerst und importiere dann den Ordner. Die Quelle wird möglicherweise nicht unterstützt, ist beschädigt oder unvollständig. Der Mod wurde möglicherweise bereits hinzugefügt oder aktualisiert; aktualisiere und überprüfe die Bibliothek, bevor du es erneut versuchst.'; + @override + String get importPickerFailed => + 'Die Datei- oder Ordnerauswahl konnte nicht geöffnet werden. Es wurde kein Import gestartet. Versuche es erneut.'; + @override String get importOutcomeUnknown => 'Das Importergebnis konnte nicht überprüft werden. Wähle „Aktualisieren“, um die Bibliothek zu prüfen.'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_en.dart b/apps/mod-manager/lib/l10n/app_localizations_en.dart index 42702afeb..fc0b156ec 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_en.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_en.dart @@ -288,6 +288,10 @@ class AppLocalizationsEn extends AppLocalizations { String get importFailed => 'The import could not be completed. Supported sources: folders, ZIP, loose *_P.pak files, complete .utoc/.ucas sets (optional .pak), .lcache, .bank, and PrecompiledScript*.Cache. Extract .7z or .rar first, then import the folder. The source may be unsupported, corrupt, or incomplete. The mod may already have been added or updated; refresh and check the library before trying again.'; + @override + String get importPickerFailed => + 'The file or folder picker could not be opened. No import was started. Try again.'; + @override String get importOutcomeUnknown => 'The import result could not be verified. Choose Refresh to check the library.'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_es.dart b/apps/mod-manager/lib/l10n/app_localizations_es.dart index 686a74768..44f04ee3d 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_es.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_es.dart @@ -292,6 +292,10 @@ class AppLocalizationsEs extends AppLocalizations { String get importFailed => 'No se pudo completar la importación. Fuentes compatibles: carpetas, ZIP, archivos *_P.pak sueltos, conjuntos .utoc/.ucas completos (con .pak opcional), .lcache, .bank y PrecompiledScript*.Cache. Extrae primero los archivos .7z o .rar y después importa la carpeta. La fuente puede no ser compatible, estar dañada o incompleta. Es posible que el mod ya se haya añadido o actualizado; actualiza y comprueba la biblioteca antes de volver a intentarlo.'; + @override + String get importPickerFailed => + 'No se pudo abrir el selector de archivos o carpetas. No se inició ninguna importación. Inténtalo de nuevo.'; + @override String get importOutcomeUnknown => 'No se pudo verificar el resultado de la importación. Selecciona Actualizar para comprobar la biblioteca.'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_fr.dart b/apps/mod-manager/lib/l10n/app_localizations_fr.dart index 38e7e931e..97fac88d6 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_fr.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_fr.dart @@ -294,6 +294,10 @@ class AppLocalizationsFr extends AppLocalizations { String get importFailed => 'L’importation n’a pas pu être terminée. Sources prises en charge : dossiers, ZIP, fichiers *_P.pak autonomes, ensembles .utoc/.ucas complets (.pak facultatif), .lcache, .bank et PrecompiledScript*.Cache. Extrayez d’abord les archives .7z ou .rar, puis importez le dossier. La source peut être non prise en charge, endommagée ou incomplète. Le mod a peut-être déjà été ajouté ou mis à jour ; actualisez et vérifiez la bibliothèque avant de réessayer.'; + @override + String get importPickerFailed => + 'Le sélecteur de fichiers ou de dossiers n’a pas pu être ouvert. Aucune importation n’a été lancée. Réessayez.'; + @override String get importOutcomeUnknown => 'Le résultat de l’importation n’a pas pu être vérifié. Sélectionnez Actualiser pour vérifier la bibliothèque.'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_it.dart b/apps/mod-manager/lib/l10n/app_localizations_it.dart index 1df7ce022..85fc63760 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_it.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_it.dart @@ -293,6 +293,10 @@ class AppLocalizationsIt extends AppLocalizations { String get importFailed => 'Non è stato possibile completare l’importazione. Sorgenti supportate: cartelle, ZIP, file *_P.pak autonomi, set .utoc/.ucas completi (.pak facoltativo), .lcache, .bank e PrecompiledScript*.Cache. Estrai prima gli archivi .7z o .rar, quindi importa la cartella. La sorgente potrebbe non essere supportata, essere danneggiata o incompleta. Il mod potrebbe essere già stato aggiunto o aggiornato; aggiorna e controlla la libreria prima di riprovare.'; + @override + String get importPickerFailed => + 'Non è stato possibile aprire il selettore di file o cartelle. Non è stata avviata alcuna importazione. Riprova.'; + @override String get importOutcomeUnknown => 'Non è stato possibile verificare il risultato dell’importazione. Seleziona Aggiorna per controllare la libreria.'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_ja.dart b/apps/mod-manager/lib/l10n/app_localizations_ja.dart index 073af9803..86e020a26 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_ja.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_ja.dart @@ -279,6 +279,10 @@ class AppLocalizationsJa extends AppLocalizations { String get importFailed => 'インポートを完了できませんでした。対応するソースは、フォルダー、ZIP、単体の *_P.pak、完全な .utoc/.ucas セット(.pak は任意)、.lcache、.bank、PrecompiledScript*.Cache です。.7z または .rar は先に展開し、そのフォルダーをインポートしてください。ソースは未対応、破損、または不完全な可能性があります。Mod はすでに追加または更新されている可能性があります。ライブラリを更新して確認してから、もう一度お試しください。'; + @override + String get importPickerFailed => + 'ファイルまたはフォルダーの選択画面を開けませんでした。インポートは開始されていません。もう一度お試しください。'; + @override String get importOutcomeUnknown => 'インポート結果を確認できませんでした。[更新]を選択してライブラリを確認してください。'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_pl.dart b/apps/mod-manager/lib/l10n/app_localizations_pl.dart index 8ff60a72e..951778e53 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_pl.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_pl.dart @@ -291,6 +291,10 @@ class AppLocalizationsPl extends AppLocalizations { String get importFailed => 'Nie udało się ukończyć importu. Obsługiwane źródła: foldery, ZIP, samodzielne pliki *_P.pak, kompletne zestawy .utoc/.ucas (opcjonalnie .pak), .lcache, .bank i PrecompiledScript*.Cache. Najpierw rozpakuj archiwum .7z lub .rar, a następnie zaimportuj folder. Źródło może być nieobsługiwane, uszkodzone lub niekompletne. Mod mógł już zostać dodany lub zaktualizowany; odśwież i sprawdź bibliotekę przed kolejną próbą.'; + @override + String get importPickerFailed => + 'Nie udało się otworzyć selektora pliku lub folderu. Import nie został rozpoczęty. Spróbuj ponownie.'; + @override String get importOutcomeUnknown => 'Nie udało się zweryfikować wyniku importu. Wybierz Odśwież, aby sprawdzić bibliotekę.'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_pt.dart b/apps/mod-manager/lib/l10n/app_localizations_pt.dart index 75e3ca1cf..f3d21a51c 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_pt.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_pt.dart @@ -296,6 +296,10 @@ class AppLocalizationsPt extends AppLocalizations { String get importFailed => 'Não foi possível concluir a importação. Fontes suportadas: pastas, ZIP, ficheiros *_P.pak autónomos, conjuntos .utoc/.ucas completos (.pak opcional), .lcache, .bank e PrecompiledScript*.Cache. Extraia primeiro os ficheiros .7z ou .rar e depois importe a pasta. A fonte poderá não ser suportada, estar danificada ou incompleta. O mod poderá já ter sido adicionado ou atualizado; atualize e verifique a biblioteca antes de tentar novamente.'; + @override + String get importPickerFailed => + 'Não foi possível abrir o seletor de ficheiros ou pastas. Nenhuma importação foi iniciada. Tente novamente.'; + @override String get importOutcomeUnknown => 'Não foi possível verificar o resultado da importação. Selecione Atualizar para verificar a biblioteca.'; @@ -812,6 +816,10 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { String get importFailed => 'Não foi possível concluir a importação. Fontes compatíveis: pastas, ZIP, arquivos *_P.pak avulsos, conjuntos .utoc/.ucas completos (.pak opcional), .lcache, .bank e PrecompiledScript*.Cache. Extraia primeiro os arquivos .7z ou .rar e depois importe a pasta. A fonte pode não ser compatível, estar corrompida ou incompleta. O mod talvez já tenha sido adicionado ou atualizado; atualize e verifique a biblioteca antes de tentar novamente.'; + @override + String get importPickerFailed => + 'Não foi possível abrir o seletor de arquivos ou pastas. Nenhuma importação foi iniciada. Tente novamente.'; + @override String get importOutcomeUnknown => 'Não foi possível verificar o resultado da importação. Selecione Atualizar para verificar a biblioteca.'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_ru.dart b/apps/mod-manager/lib/l10n/app_localizations_ru.dart index b5e87e08a..4471ed428 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_ru.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_ru.dart @@ -291,6 +291,10 @@ class AppLocalizationsRu extends AppLocalizations { String get importFailed => 'Не удалось завершить импорт. Поддерживаемые источники: папки, ZIP, отдельные файлы *_P.pak, полные наборы .utoc/.ucas (необязательный .pak), .lcache, .bank и PrecompiledScript*.Cache. Сначала распакуйте архив .7z или .rar, затем импортируйте папку. Источник может не поддерживаться, быть повреждённым или неполным. Возможно, мод уже добавлен или обновлён; обновите и проверьте библиотеку перед повторной попыткой.'; + @override + String get importPickerFailed => + 'Не удалось открыть окно выбора файла или папки. Импорт не был запущен. Повторите попытку.'; + @override String get importOutcomeUnknown => 'Не удалось проверить результат импорта. Нажмите «Обновить», чтобы проверить библиотеку.'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_zh.dart b/apps/mod-manager/lib/l10n/app_localizations_zh.dart index 472388939..31a4306b7 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_zh.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_zh.dart @@ -273,6 +273,9 @@ class AppLocalizationsZh extends AppLocalizations { String get importFailed => '无法完成导入。支持的来源:文件夹、ZIP、独立的 *_P.pak、完整的 .utoc/.ucas 组合(.pak 可选)、.lcache、.bank 和 PrecompiledScript*.Cache。请先解压 .7z 或 .rar,再导入文件夹。来源可能不受支持、已损坏或不完整。模组可能已被添加或更新;请刷新并检查库状态,然后重试。'; + @override + String get importPickerFailed => '无法打开文件或文件夹选择器。导入尚未开始。请重试。'; + @override String get importOutcomeUnknown => '无法验证导入结果。请选择“刷新”以检查库状态。'; @@ -754,6 +757,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get importFailed => '无法完成导入。支持的来源:文件夹、ZIP、独立的 *_P.pak、完整的 .utoc/.ucas 组合(.pak 可选)、.lcache、.bank 和 PrecompiledScript*.Cache。请先解压 .7z 或 .rar,再导入文件夹。来源可能不受支持、已损坏或不完整。模组可能已被添加或更新;请刷新并检查库状态,然后重试。'; + @override + String get importPickerFailed => '无法打开文件或文件夹选择器。导入尚未开始。请重试。'; + @override String get importOutcomeUnknown => '无法验证导入结果。请选择“刷新”以检查库状态。'; diff --git a/apps/mod-manager/lib/l10n/app_pl.arb b/apps/mod-manager/lib/l10n/app_pl.arb index 2ee0495e4..75f72e71e 100644 --- a/apps/mod-manager/lib/l10n/app_pl.arb +++ b/apps/mod-manager/lib/l10n/app_pl.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "Ten import pasuje do więcej niż jednego wpisu w bibliotece. Sprawdź lub usuń duplikaty, a następnie spróbuj ponownie.", "importRefusalIdentityConflict": "Źródło importu i jego zawartość pasują do różnych wpisów w bibliotece. Sprawdź lub usuń wpisy powodujące konflikt, a następnie spróbuj ponownie.", "importFailed": "Nie udało się ukończyć importu. Obsługiwane źródła: foldery, ZIP, samodzielne pliki *_P.pak, kompletne zestawy .utoc/.ucas (opcjonalnie .pak), .lcache, .bank i PrecompiledScript*.Cache. Najpierw rozpakuj archiwum .7z lub .rar, a następnie zaimportuj folder. Źródło może być nieobsługiwane, uszkodzone lub niekompletne. Mod mógł już zostać dodany lub zaktualizowany; odśwież i sprawdź bibliotekę przed kolejną próbą.", + "importPickerFailed": "Nie udało się otworzyć selektora pliku lub folderu. Import nie został rozpoczęty. Spróbuj ponownie.", "importOutcomeUnknown": "Nie udało się zweryfikować wyniku importu. Wybierz Odśwież, aby sprawdzić bibliotekę.", "applyTooltip": "Zastosuj zestaw modów do gry", "undeployAllAction": "Wycofaj wszystko", diff --git a/apps/mod-manager/lib/l10n/app_pt.arb b/apps/mod-manager/lib/l10n/app_pt.arb index 89d05590a..956d1ff69 100644 --- a/apps/mod-manager/lib/l10n/app_pt.arb +++ b/apps/mod-manager/lib/l10n/app_pt.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "Esta importação corresponde a mais do que uma entrada da biblioteca. Reveja ou remova as entradas duplicadas e tente novamente.", "importRefusalIdentityConflict": "A origem da importação e o respetivo conteúdo correspondem a entradas diferentes da biblioteca. Reveja ou remova as entradas em conflito e tente novamente.", "importFailed": "Não foi possível concluir a importação. Fontes suportadas: pastas, ZIP, ficheiros *_P.pak autónomos, conjuntos .utoc/.ucas completos (.pak opcional), .lcache, .bank e PrecompiledScript*.Cache. Extraia primeiro os ficheiros .7z ou .rar e depois importe a pasta. A fonte poderá não ser suportada, estar danificada ou incompleta. O mod poderá já ter sido adicionado ou atualizado; atualize e verifique a biblioteca antes de tentar novamente.", + "importPickerFailed": "Não foi possível abrir o seletor de ficheiros ou pastas. Nenhuma importação foi iniciada. Tente novamente.", "importOutcomeUnknown": "Não foi possível verificar o resultado da importação. Selecione Atualizar para verificar a biblioteca.", "applyTooltip": "Aplicar a configuração ao jogo", "undeployAllAction": "Remover tudo", diff --git a/apps/mod-manager/lib/l10n/app_pt_BR.arb b/apps/mod-manager/lib/l10n/app_pt_BR.arb index f15b792f2..8bef8da44 100644 --- a/apps/mod-manager/lib/l10n/app_pt_BR.arb +++ b/apps/mod-manager/lib/l10n/app_pt_BR.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "Esta importação corresponde a mais de uma entrada da biblioteca. Revise ou remova as entradas duplicadas e tente novamente.", "importRefusalIdentityConflict": "A origem da importação e o conteúdo correspondem a entradas diferentes da biblioteca. Revise ou remova as entradas em conflito e tente novamente.", "importFailed": "Não foi possível concluir a importação. Fontes compatíveis: pastas, ZIP, arquivos *_P.pak avulsos, conjuntos .utoc/.ucas completos (.pak opcional), .lcache, .bank e PrecompiledScript*.Cache. Extraia primeiro os arquivos .7z ou .rar e depois importe a pasta. A fonte pode não ser compatível, estar corrompida ou incompleta. O mod talvez já tenha sido adicionado ou atualizado; atualize e verifique a biblioteca antes de tentar novamente.", + "importPickerFailed": "Não foi possível abrir o seletor de arquivos ou pastas. Nenhuma importação foi iniciada. Tente novamente.", "importOutcomeUnknown": "Não foi possível verificar o resultado da importação. Selecione Atualizar para verificar a biblioteca.", "applyTooltip": "Aplicar a configuração ao jogo", "undeployAllAction": "Remover tudo", diff --git a/apps/mod-manager/lib/l10n/app_ru.arb b/apps/mod-manager/lib/l10n/app_ru.arb index 37e344ccc..3c6d305ee 100644 --- a/apps/mod-manager/lib/l10n/app_ru.arb +++ b/apps/mod-manager/lib/l10n/app_ru.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "Этот импорт соответствует нескольким записям библиотеки. Проверьте или удалите дубликаты и повторите попытку.", "importRefusalIdentityConflict": "Источник импорта и его содержимое соответствуют разным записям библиотеки. Проверьте или удалите конфликтующие записи и повторите попытку.", "importFailed": "Не удалось завершить импорт. Поддерживаемые источники: папки, ZIP, отдельные файлы *_P.pak, полные наборы .utoc/.ucas (необязательный .pak), .lcache, .bank и PrecompiledScript*.Cache. Сначала распакуйте архив .7z или .rar, затем импортируйте папку. Источник может не поддерживаться, быть повреждённым или неполным. Возможно, мод уже добавлен или обновлён; обновите и проверьте библиотеку перед повторной попыткой.", + "importPickerFailed": "Не удалось открыть окно выбора файла или папки. Импорт не был запущен. Повторите попытку.", "importOutcomeUnknown": "Не удалось проверить результат импорта. Нажмите «Обновить», чтобы проверить библиотеку.", "applyTooltip": "Применить набор модов к игре", "undeployAllAction": "Убрать всё из игры", diff --git a/apps/mod-manager/lib/l10n/app_zh.arb b/apps/mod-manager/lib/l10n/app_zh.arb index 3b3e95a78..d3a4bcfe7 100644 --- a/apps/mod-manager/lib/l10n/app_zh.arb +++ b/apps/mod-manager/lib/l10n/app_zh.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "此导入匹配多个库条目。请检查或移除重复条目,然后重试。", "importRefusalIdentityConflict": "导入来源及其内容匹配到不同的库条目。请检查或移除冲突条目,然后重试。", "importFailed": "无法完成导入。支持的来源:文件夹、ZIP、独立的 *_P.pak、完整的 .utoc/.ucas 组合(.pak 可选)、.lcache、.bank 和 PrecompiledScript*.Cache。请先解压 .7z 或 .rar,再导入文件夹。来源可能不受支持、已损坏或不完整。模组可能已被添加或更新;请刷新并检查库状态,然后重试。", + "importPickerFailed": "无法打开文件或文件夹选择器。导入尚未开始。请重试。", "importOutcomeUnknown": "无法验证导入结果。请选择“刷新”以检查库状态。", "applyTooltip": "将模组配置应用到游戏", "undeployAllAction": "撤销全部部署", diff --git a/apps/mod-manager/lib/l10n/app_zh_Hans.arb b/apps/mod-manager/lib/l10n/app_zh_Hans.arb index 29fa76b68..4f108e58a 100644 --- a/apps/mod-manager/lib/l10n/app_zh_Hans.arb +++ b/apps/mod-manager/lib/l10n/app_zh_Hans.arb @@ -78,6 +78,7 @@ "importRefusalDuplicateAmbiguous": "此导入匹配多个库条目。请检查或移除重复条目,然后重试。", "importRefusalIdentityConflict": "导入来源及其内容匹配到不同的库条目。请检查或移除冲突条目,然后重试。", "importFailed": "无法完成导入。支持的来源:文件夹、ZIP、独立的 *_P.pak、完整的 .utoc/.ucas 组合(.pak 可选)、.lcache、.bank 和 PrecompiledScript*.Cache。请先解压 .7z 或 .rar,再导入文件夹。来源可能不受支持、已损坏或不完整。模组可能已被添加或更新;请刷新并检查库状态,然后重试。", + "importPickerFailed": "无法打开文件或文件夹选择器。导入尚未开始。请重试。", "importOutcomeUnknown": "无法验证导入结果。请选择“刷新”以检查库状态。", "applyTooltip": "将模组配置应用到游戏", "undeployAllAction": "撤销全部部署", diff --git a/apps/mod-manager/lib/library/ui/import_feedback.dart b/apps/mod-manager/lib/library/ui/import_feedback.dart index 324c34732..a5e429239 100644 --- a/apps/mod-manager/lib/library/ui/import_feedback.dart +++ b/apps/mod-manager/lib/library/ui/import_feedback.dart @@ -47,14 +47,16 @@ void showImportFailureFeedback( LibraryState library, ) { final l10n = AppLocalizations.of(context); - final message = !library.authoritative - ? l10n.importOutcomeUnknown - : switch (error.code) { - 'IMPORT_INVALID_RESPONSE' => l10n.importOutcomeUnknown, - 'IMPORT_DUPLICATE_AMBIGUOUS' => l10n.importRefusalDuplicateAmbiguous, - 'IMPORT_IDENTITY_CONFLICT' => l10n.importRefusalIdentityConflict, - _ => l10n.importFailed, - }; + final message = switch (error.code) { + // Picker failures happen before Native receives an import request, so they + // must never imply a bad source or possible library publication. + 'IMPORT_PICKER_FAILED' => l10n.importPickerFailed, + _ when !library.authoritative => l10n.importOutcomeUnknown, + 'IMPORT_INVALID_RESPONSE' => l10n.importOutcomeUnknown, + 'IMPORT_DUPLICATE_AMBIGUOUS' => l10n.importRefusalDuplicateAmbiguous, + 'IMPORT_IDENTITY_CONFLICT' => l10n.importRefusalIdentityConflict, + _ => l10n.importFailed, + }; _showImportSnackBar( context, message: message, diff --git a/apps/mod-manager/test/home_page_import_test.dart b/apps/mod-manager/test/home_page_import_test.dart index c60c2ccf5..3682ddc0c 100644 --- a/apps/mod-manager/test/home_page_import_test.dart +++ b/apps/mod-manager/test/home_page_import_test.dart @@ -131,17 +131,20 @@ class _Picker implements ImportSourcePicker { _Picker( Iterable folderPaths, { Iterable filePaths = const [], + this.folderError, }) : _folderPaths = List.from(folderPaths), _filePaths = List.from(filePaths); final List _folderPaths; final List _filePaths; + final Object? folderError; int folderCalls = 0; int fileCalls = 0; @override Future pickFolder() async { folderCalls++; + if (folderError case final error?) throw error; return _folderPaths.isEmpty ? null : _folderPaths.removeAt(0); } @@ -276,6 +279,7 @@ void main() { l10n.importRefusalDuplicateAmbiguous, l10n.importRefusalIdentityConflict, l10n.importFailed, + l10n.importPickerFailed, l10n.importOutcomeUnknown, ]; expect(values.every((value) => value.trim().isNotEmpty), isTrue); @@ -523,6 +527,30 @@ void main() { expect(importButton.focusNode!.hasFocus, isTrue); }); + testWidgets( + 'picker failure says no import started and calls no Native import', + (tester) async { + final core = _ImportCore(initialMods: [_entry('alpha', 'Alpha')]); + final picker = _Picker( + const [], + folderError: StateError('platform picker unavailable'), + ); + await tester.pumpWidget(_home(core, picker)); + await tester.pumpAndSettle(); + + await _chooseFolder(tester); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(l10n.importPickerFailed), findsOneWidget); + expect(find.text(l10n.importFailed), findsNothing); + expect(core.calls.where((call) => call.command == 'mgr_import'), isEmpty); + expect(core.mods.single['id'], 'alpha'); + expect(find.textContaining('platform picker unavailable'), findsNothing); + expect(_snackLiveRegions(), findsOneWidget); + _expectFeedbackDoesNotOwnFocus(); + }, + ); + for (final source in const [ (extension: '7z', path: 'D:/picked/unsupported.7z'), (extension: 'rar', path: 'D:/picked/unsupported.rar'),