diff --git a/DVModManager/App.axaml.cs b/DVModManager/App.axaml.cs index b9cf2ca..90e5f13 100644 --- a/DVModManager/App.axaml.cs +++ b/DVModManager/App.axaml.cs @@ -24,6 +24,14 @@ public override void OnFrameworkInitializationCompleted() ConfigureServices(services); Services = services.BuildServiceProvider(); + // Initialize localization service with default language (English). + // If settings are loaded later, the language will be updated via MainWindowViewModel. + // For now, we just ensure the service is ready before any UI is created. + var localizationService = Services.GetRequiredService(); + // Expose as an Application-level resource so XAML can bind to it via {StaticResource Loc} + Resources["Loc"] = localizationService; + // Language will be applied after settings load in MainWindowViewModel.InitializeAsync() + // Catch unhandled exceptions on any thread and surface them in the status bar / console var logDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @@ -106,6 +114,9 @@ private static void ConfigureServices(IServiceCollection services) // HTTP services.AddHttpClient(); + // Localization (must be registered early, before UI/ViewModels) + services.AddSingleton(); + // Core infrastructure services.AddSingleton(); services.AddSingleton(); diff --git a/DVModManager/Converters/LocalizeExtension.cs b/DVModManager/Converters/LocalizeExtension.cs new file mode 100644 index 0000000..2040c3a --- /dev/null +++ b/DVModManager/Converters/LocalizeExtension.cs @@ -0,0 +1,73 @@ +using System.ComponentModel; +using Avalonia.Data; +using Avalonia.Markup.Xaml; +using Avalonia.Markup.Xaml.MarkupExtensions; +using DVModManager.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace DVModManager.Converters; + +/// +/// Markup extension for localizing text. +/// Usage in XAML: Text="{conv:Localize toolbar.save_profile}" +/// +public class LocalizeExtension : MarkupExtension +{ + public string Key { get; set; } = ""; + + public LocalizeExtension() { } + + public LocalizeExtension(string key) + { + Key = key; + } + + public override object ProvideValue(IServiceProvider serviceProvider) + { + if (string.IsNullOrWhiteSpace(Key)) + return string.Empty; + + var source = App.Services.GetRequiredService(); + + // Wrap each key in a dedicated observable so PropertyChanged("Value") fires + // on language change — Avalonia's ReflectionBindingExtension reliably handles + // named-property change notifications but NOT "Item[]" (WPF/Silverlight convention). + var localizedValue = new LocalizedValue(source, Key); + + var binding = new ReflectionBindingExtension(nameof(LocalizedValue.Value)) + { + Source = localizedValue, + Mode = BindingMode.OneWay, + FallbackValue = Key, + }; + + return binding.ProvideValue(serviceProvider); + } +} + +/// +/// Single-key observable wrapper over . +/// Raises PropertyChanged("Value") whenever the active language changes, +/// ensuring bound UI elements update immediately without relying on "Item[]". +/// +internal sealed class LocalizedValue : INotifyPropertyChanged +{ + private readonly ILocalizationService _service; + private readonly string _key; + + public string Value => _service[_key]; + + public event PropertyChangedEventHandler? PropertyChanged; + + public LocalizedValue(ILocalizationService service, string key) + { + _service = service; + _key = key; + service.LanguageChanged += OnLanguageChanged; + } + + private void OnLanguageChanged(object? sender, EventArgs e) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } +} diff --git a/DVModManager/Converters/StringKeyConverter.cs b/DVModManager/Converters/StringKeyConverter.cs new file mode 100644 index 0000000..95c65fe --- /dev/null +++ b/DVModManager/Converters/StringKeyConverter.cs @@ -0,0 +1,40 @@ +using Avalonia.Data.Converters; +using DVModManager.Services; + +namespace DVModManager.Converters; + +/// +/// Value converter that translates a localization key (from ViewModel) to a localized string. +/// Binding: Text="{Binding Some ObservableProperty, Converter={StaticResource LocalizeConverter}}" +/// where the property returns a localization key like "status.activated". +/// +public class StringKeyConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + if (value is not string key || string.IsNullOrEmpty(key)) + return ""; + + try + { + var localizationService = App.Services.GetService(typeof(ILocalizationService)) as ILocalizationService; + if (localizationService == null) + return key; // Fallback + + // If parameter contains format args, apply them + if (parameter is object[] args) + return localizationService.GetString(key, args); + + return localizationService.GetString(key); + } + catch + { + return key; + } + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/DVModManager/DVModManager.csproj b/DVModManager/DVModManager.csproj index 49a2a06..47fc79f 100644 --- a/DVModManager/DVModManager.csproj +++ b/DVModManager/DVModManager.csproj @@ -34,4 +34,8 @@ + + + + diff --git a/DVModManager/Models/AppSettings.cs b/DVModManager/Models/AppSettings.cs index 6bd6e63..4169a07 100644 --- a/DVModManager/Models/AppSettings.cs +++ b/DVModManager/Models/AppSettings.cs @@ -15,6 +15,9 @@ public class AppSettings /// "Dark" or "Light" public string ThemeVariant { get; set; } = "Dark"; + /// Language code: "en", "de", "fr", etc. + public string Language { get; set; } = "en"; + public bool AutoCheckUpdatesOnStartup { get; set; } = true; public bool BackupBeforeChanges { get; set; } = true; diff --git a/DVModManager/Resources/strings.csv b/DVModManager/Resources/strings.csv new file mode 100644 index 0000000..0d1ae2a --- /dev/null +++ b/DVModManager/Resources/strings.csv @@ -0,0 +1,164 @@ +key,en,de,fr,zh +lang.en,English,Englisch,Anglais, +lang.de,German,Deutsch,Allemand, +lang.fr,French,Français,Français, +lang.zh,Chinese,Chinesisch,Chinois, +window.title,DV Mod Manager,DV Mod Manager,Gestionnaire de mods DV, +toolbar.profile.label,Profile:,Profil:,Profil :, +toolbar.save_profile,Save,Speichern,Enregistrer, +toolbar.save_profile.tooltip,Save current mod selection as the active profile,Aktuelle Modauswahl als aktives Profil speichern,Enregistrer la sélection actuelle des mods comme profil actif, +toolbar.manage_profiles,Manage Profiles,Profile verwalten,Gérer les profils, +toolbar.manage_profiles.tooltip,"Load, import, export or delete profiles","Profile laden, importieren, exportieren oder löschen","Charger, importer, exporter ou supprimer des profils", +toolbar.refresh,⟳ Refresh,⟳ Aktualisieren,⟳ Actualiser, +toolbar.refresh.tooltip,Rescan the Mods folder,Mods-Ordner neu scannen,Rescanner le dossier Mods, +toolbar.unload_all,⏊ Unload All,⏊ Alle entladen,⏊ Décharger tout, +toolbar.unload_all.tooltip,Deactivate all currently active mods,Alle derzeit aktiven Mods deaktivieren,Désactiver tous les mods actuellement actifs, +toolbar.check_updates,↑ Check Updates,↑ Updates prüfen,↑ Vérifier les mises à jour, +toolbar.check_updates.tooltip,Check all mods for available updates,Alle Mods auf verfügbare Updates prüfen,Vérifier les mises à jour disponibles pour tous les mods, +toolbar.update_all,↑↑ Update All,↑↑ Alle aktualisieren,↑↑ Mettre à jour tout, +toolbar.update_all.tooltip,Download and install all available updates,Alle verfügbaren Updates herunterladen und installieren,Télécharger et installer toutes les mises à jour disponibles, +toolbar.settings,⚙,⚙,⚙, +toolbar.settings.tooltip,Open settings,Einstellungen öffnen,Ouvrir les paramètres, +panel.available,Available Mods,Verfügbare Mods,Mods disponibles, +panel.active,Active Mods,Aktive Mods,Mods actifs, +panel.details,Details,Details,Détails, +activate.button,▶,▶,▶, +activate.button.tooltip,Activate selected mod,Ausgewählten Mod aktivieren,Activer le mod sélectionné, +deactivate.button,◀,◀,◀, +deactivate.button.tooltip,Deactivate selected mod,Ausgewählten Mod deaktivieren,Désactiver le mod sélectionné, +install.button,⊕,⊕,⊕, +install.button.tooltip,Install mod or import modpack,Mod installieren oder Modpack importieren,Installer un mod ou importer un modpack, +statusbar.game_running,Game Running,Spiel läuft,Jeu en cours d'exécution, +statusbar.game_not_running,Game Not Running,Spiel läuft nicht,Jeu non en cours d'exécution, +statusbar.ready,Ready.,Bereit.,Prêt., +settings.title,Settings,Einstellungen,Paramètres, +settings.section.game_path,Derail Valley Installation,Derail Valley-Installation,Installation de Derail Valley, +settings.game_path.placeholder,Path to Derail Valley folder...,Pfad zum Derail Valley-Ordner...,Chemin d'accès au dossier Derail Valley..., +settings.game_path.browse,Browse,Durchsuchen,Parcourir, +settings.game_path.auto,Auto,Automatisch,Automatique, +settings.game_path.auto.tooltip,Auto-detect via Steam,Per Steam automatisch erkennen,Détection automatique via Steam, +settings.section.storage,Storage Folder,Speicherordner,Dossier de stockage, +settings.storage.description,"Where version archives, profiles and logs are kept.","Hier werden Versionsarchive, Profile und Protokolle gespeichert.","Où les archives de version, les profils et les journaux sont conservés.", +settings.storage.browse,Browse,Durchsuchen,Parcourir, +settings.section.nexus,Nexus Mods API Key,Nexus Mods API-Schlüssel,Clé API Nexus Mods, +settings.nexus.description,Required for Nexus Mods update checks. Leave blank to skip.,Erforderlich für Nexus Mods-Update-Überprüfungen. Leer lassen zum Überspringen.,Requis pour les vérifications de mise à jour de Nexus Mods. Laisser en blanc pour ignorer., +settings.nexus.placeholder,Your Nexus Mods API key...,Ihr Nexus Mods API-Schlüssel...,Votre clé API Nexus Mods..., +settings.section.github,GitHub Token (optional),GitHub-Token (optional),Jeton GitHub (facultatif), +settings.github.description,Increases the GitHub API rate limit from 60 to 5000 requests/hour.,Erhöht das GitHub API-Ratelimit von 60 auf 5000 Anfragen pro Stunde.,Augmente la limite de débit de l'API GitHub de 60 à 5000 requêtes par heure., +settings.github.placeholder,ghp_...,ghp_...,ghp_..., +settings.section.general,General,Allgemein,Général, +settings.auto_check_updates,Check for updates on startup,Bei Start auf Updates prüfen,Vérifier les mises à jour au démarrage, +settings.backup_before_changes,Backup mods before bulk changes,Mods vor Massenänderungen sichern,Sauvegarder les mods avant les modifications en bloc, +settings.backup_folder_button,Open Folder,Ordner öffnen,Ouvrir le dossier, +settings.max_cache_size,Max version cache size (GB),Max. Versionscachegröße (GB),Taille maximale du cache de version (Go), +settings.language,Language,Sprache,Langue, +settings.language.english,English,Englisch,Anglais, +settings.button.cancel,Cancel,Abbrechen,Annuler, +settings.button.save,Save,Speichern,Enregistrer, +dialog.button.ok,OK,OK,OK, +dialog.button.yes,Yes,Ja,Oui, +dialog.button.no,No,Nein,Non, +dialog.uninstall_title,Uninstall Mod,Mod deinstallieren,Désinstaller le mod, +dialog.uninstall_message,"Remove ''{0}''? The current version will be archived for rollback, then deleted.","„{0}"" entfernen? Die aktuelle Version wird zur Wiederherstellung archiviert und dann gelöscht.","Supprimer « {0} » ? La version actuelle sera archivée pour restauration, puis supprimée.", +dialog.update_all_title,Update All,Alle aktualisieren,Mettre à jour tout, +dialog.update_all_message,Update {0} mod(s)? Current versions will be archived.,{0} Mod(s) aktualisieren? Aktuelle Versionen werden archiviert.,Mettre à jour {0} mod(s) ? Les versions actuelles seront archivées., +dialog.unload_all_title,Unload All Mods,Alle Mods entfernen,Décharger tous les mods, +dialog.unload_all_message,Deactivate all {0} active mod(s)?,Alle {0} aktiven Mod(s) deaktivieren?,Désactiver tous les {0} mod(s) actif(s) ?, +dialog.download_missing_title,Download Missing Mods,Fehlende Mods herunterladen,Télécharger les mods manquants, +dialog.invalid_path_title,Invalid Path,Ungültiger Pfad,Chemin invalide, +dialog.invalid_path_message,The selected folder does not appear to be a Derail Valley installation.,Der ausgewählte Ordner scheint keine Derail Valley-Installation zu sein.,Le dossier sélectionné ne semble pas être une installation de Derail Valley., +dialog.game_not_found_title,Not Found,Nicht gefunden,Non trouvé, +dialog.game_not_found_message,Could not auto-detect Derail Valley. Please select the folder manually.,Derail Valley konnte nicht automatisch erkannt werden. Wählen Sie den Ordner manuell aus.,Impossible de détecter automatiquement Derail Valley. Veuillez sélectionner le dossier manuellement., +modstate.active,Active,Aktiv,Actif, +modstate.inactive,Inactive,Inaktiv,Inactif, +modstate.update_available,Update available → {0},Update verfügbar → {0},Mise à jour disponible → {0}, +modstate.missing_dependency,Missing dependency,Fehlende Abhängigkeit,Dépendance manquante, +modstate.no_metadata,No metadata,Keine Metadaten,Pas de métadonnées, +modstate.missing,Missing — not found on disk,Fehlend – nicht auf Datenträger gefunden,Manquant — non trouvé sur le disque, +status.detected_game,Detected game at: {0},Spiel erkannt unter: {0},Jeu détecté à : {0}, +status.game_not_found,Game not found. Please set the game path in Settings.,Spiel nicht gefunden. Bitte legen Sie den Spielpfad in den Einstellungen fest.,Jeu non trouvé. Veuillez définir le chemin du jeu dans les paramètres., +status.startup_error,Startup error: {0},Startfehler: {0},Erreur de démarrage : {0}, +status.no_mods_found,No mods found in {0},Keine Mods gefunden in {0},Aucun mod trouvé dans {0}, +status.mods_found,"Found {0} mod(s) — {1} active, {2} inactive.","{0} Mod(s) gefunden – {1} aktiv, {2} inaktiv.","{0} mod(s) trouvé(s) — {1} actif, {2} inactif.", +status.game_path_not_set,Game path not set. Open Settings to configure.,Spielpfad nicht gesetzt. Öffnen Sie Einstellungen zum Konfigurieren.,Chemin du jeu non défini. Ouvrez Paramètres pour configurer., +status.mods_folder_not_found,Mods folder not found: {0},Mods-Ordner nicht gefunden: {0},Dossier Mods non trouvé : {0}, +status.scan_error,Scan error: {0},Scan-Fehler: {0},Erreur de numérisation : {0}, +status.no_active_mods,No active mods.,Keine aktiven Mods.,Aucun mod actif., +status.missing_dependencies,Missing dependencies for {0}: {1},Fehlende Abhängigkeiten für {0}: {1},Dépendances manquantes pour {0} : {1}, +status.activated,Activated: {0},Aktiviert: {0},Activé : {0}, +status.activation_failed,Failed to activate: {0},Aktivierung fehlgeschlagen: {0},Impossible d'activer : {0}, +status.activated_count,Activated {0}/{1} mod(s) in group ''{2}''.,"{0}/{1} Mod(s) in Gruppe „{2}"" aktiviert.",{0}/{1} mod(s) activé(s) dans le groupe « {2} »., +status.activated_with_missing_deps,Activated {0}/{1} — missing deps: {1},Aktiviert {0}/{1} – fehlende Abhängigkeiten: {1},Activé {0}/{1} — dépendances manquantes : {1}, +status.deactivated,Deactivated: {0},Deaktiviert: {0},Désactivé : {0}, +status.deactivation_failed,Failed to deactivate: {0},Deaktivierung fehlgeschlagen: {0},Impossible de désactiver : {0}, +status.deactivated_count,Deactivated {0}/{1} mod(s) in group ''{2}''.,"{0}/{1} Mod(s) in Gruppe „{2}"" deaktiviert.",{0}/{1} mod(s) désactivé(s) dans le groupe « {2} »., +status.install_success,Installed: {0},Installiert: {0},Installé : {0}, +status.install_failed,"Install failed. Ensure the archive contains Info.json.","Installation fehlgeschlagen. Stellen Sie sicher, dass das Archiv Info.json enthält.","Échec de l'installation. Assurez-vous que l'archive contient Info.json.", +status.uninstalled,Uninstalled: {0},Deinstalliert: {0},Désinstallé : {0}, +status.checking_updates,Checking for updates...,Auf Updates wird überprüft...,Vérification des mises à jour…, +status.updates_available,{0} update(s) available.,{0} Update(s) verfügbar.,{0} mise(s) à jour disponible(s)., +status.all_up_to_date,All mods are up to date.,Alle Mods sind aktuell.,Tous les mods sont à jour., +status.update_opened,Opened mod page for {0} v{1},Modseite für {0} v{1} geöffnet,Page de mod ouverte pour {0} v{1}, +status.updating,Updating {0} to v{1}...,,Mise à jour de {0} vers v{1}…, +status.updated,Updated {0} to v{1},{0} auf v{1} aktualisiert,{0} mis à jour vers v{1}, +status.update_failed,Update failed for {0},Update fehlgeschlagen für {0},Échec de la mise à jour de {0}, +status.rolling_back,Rolling back {0} to v{1}...,Rollback von {0} zu v{1}…,Restauration de {0} à v{1}…, +status.rolled_back,Rolled back {0} to v{1},Rollback von {0} zu v{1} durchgeführt,{0} restauré à v{1}, +status.rollback_failed,Rollback failed for {0},Rollback fehlgeschlagen für {0},Échec du rollback pour {0}, +status.profile_saved,Profile ''{0}'' saved.,"Profil „{0}"" gespeichert.",Profil « {0} » enregistré., +status.profile_applied,Applied profile ''{0}''.,"Profil „{0}"" angewendet.",Profil « {0} » appliqué., +status.profile_already_applied,Profile is already applied.,Profil ist bereits angewendet.,Le profil est déjà appliqué., +status.no_downloadable_updates,No downloadable updates available.,Keine herunterladbaren Updates verfügbar.,Aucune mise à jour téléchargeable disponible., +status.downloading_mods,Downloading {0} mod(s)...,{0} Mod(s) werden heruntergeladen…,Téléchargement de {0} mod(s)…, +status.download_complete,Downloaded {0} mod(s). Nexus mods ({1}) opened in browser — install manually then re-apply.,{0} Mod(s) heruntergeladen. Nexus-Mods ({1}) im Browser geöffnet – manuell installieren und dann erneut anwenden.,{0} mod(s) téléchargé(s). Mods Nexus ({1}) ouverts dans le navigateur — installer manuellement puis appliquer à nouveau., +status.activating_mod,Activating {0}...,Aktiviere {0}…,Activation de {0}…, +status.deactivating_mod,Deactivating {0}...,Deaktiviere {0}…,Désactivation de {0}…, +status.activating_group,Activating {0} mod(s)...,Aktiviere {0} Mod(s)…,Activation de {0} mod(s)…, +status.deactivating_group,Deactivating {0} mod(s)...,Deaktiviere {0} Mod(s)…,Désactivation de {0} mod(s)…, +busy.installing_mod,Installing mod...,Mod wird installiert…,Installation du mod…, +busy.creating_backup,Creating backup...,Erstelle Backup…,Création d'une sauvegarde…, +busy.resolving_mod,Resolving {0}...,Löse {0} auf…,Résolution de {0}…, +busy.downloading_mod,Downloading {0}...,Lade {0} herunter…,Téléchargement de {0}…, +busy.download_progress,Downloading {0} {1:P0}...,Lade {0} {1:P0} herunter…,Téléchargement de {0} {1:P0}…, +busy.downloading_multiple,Downloading {0} ({1}/{2})...,Lade {0} herunter ({1}/{2})…,Téléchargement de {0} ({1}/{2})…, +busy.updating_mod,Updating {0}...,Aktualisiere {0}…,Mise à jour de {0}…, +busy.update_progress,Updating {0}… {1:P0},Aktualisiere {0}… {1:P0},Mise à jour de {0}… {1:P0}, +busy.update_multiple,Updating {0}… {1:P0} ({2}/{3})...,Aktualisiere {0}… {1:P0} ({2}/{3})…,Mise à jour de {0}… {1:P0} ({2}/{3})…, +menu.operations,Operations,Operationen,Opérations, +menu.version_history,Version History,Versionsverlauf,Historique des versions, +filter.search_mods,Search mods...,Mods durchsuchen...,Rechercher des mods..., +ui.select_mod_details,Select a mod to see details,Wählen Sie ein Mod aus um Details zu sehen,Sélectionnez un mod pour voir les détails, +button.add_group,+ Group,+ Gruppe,+ Groupe, +button.rename_group,Rename group,Gruppe umbenennen,Renommer le groupe, +button.delete_group,Delete group,Gruppe löschen,Supprimer le groupe, +badge.update,UPDATE,AKTUALISIERUNG,MISE À JOUR, +link.homepage,🌐 HomePage,🌐 Startseite,🌐 Page d'accueil, +link.repository,📦 Repository,📦 Repository,📦 Référentiel, +mod.missing_dependencies,Missing Dependencies,Fehlende Abhängigkeiten,Dépendances manquantes, +button.uninstall,✕ Uninstall,✕ Deinstallieren,✕ Désinstaller, +button.update,↑ Update,↑ Aktualisieren,↑ Mettre à jour, +menu.version_history,Version History,Versionsverlauf,Historique des versions, +button.rollback,↩ Rollback to Selected Version,↩ Zu ausgewählter Version zurücksetzen,↩ Revenir à la version sélectionnée, +profile.dialog_title,Manage Profiles,Profile verwalten,Gérer les profils, +profile.saved_profiles,Saved Profiles,Gespeicherte Profile,Profils enregistrés, +profile.help_text,Use the main toolbar to save the current mod selection as a new profile.,Verwenden Sie die Hauptsymbolleiste um die aktuelle Mod-Auswahl als neues Profil zu speichern.,Utilisez la barre d'outils principale pour enregistrer la sélection actuelle des mods comme nouveau profil., +profile.button_apply,✓ Apply,✓ Anwenden,✓ Appliquer, +profile.button_export,Export,Exportieren,Exporter, +profile.button_import,Import,Importieren,Importer, +profile.button_delete,✕ Delete,✕ Löschen,✕ Supprimer, +button.close,Close,Schließen,Fermer, +modversion.source.cached,Cached,Zwischengespeichert,Mis en cache, +modversion.source.github,GitHub,GitHub,GitHub, +modversion.source.nexus,Nexus,Nexus,Nexus, +modversion.source.manual,Manual,Manuell,Manuel, +export.choose_format.title,Export Profile,Profil exportieren,Exporter le profil, +export.button.json,Export as JSON,Als JSON exportieren,Exporter en JSON, +export.button.zip,Export as ZIP,Als ZIP exportieren,Exporter en ZIP, +export.zip_warning.title,Redistribution Warning,Weitergabe-Warnung,Avertissement de redistribution, +export.zip_warning.message,"Most mods do not allow redistribution on other sites. This tool takes no responsibility for unauthorized sharing.","Die meisten Mods erlauben keine Weitergabe auf anderen Seiten. Dieses Tool übernimmt keine Verantwortung für unerlaubtes Teilen.","La plupart des mods n'autorisent pas la redistribution sur d'autres sites. Cet outil décline toute responsabilité en cas de partage non autorisé.", +import.modpack.title,Import Modpack,Modpack importieren,Importer un modpack, +import.modpack.preamble,The following mods are included in this modpack:,Die folgenden Mods sind in diesem Modpack enthalten:,Les mods suivants sont inclus dans ce modpack :, +import.modpack.security_warning,"⚠ Mods not downloaded from their original source may have been modified and could be harmful. This tool takes no responsibility.","⚠ Mods die nicht von ihrer ursprünglichen Quelle stammen können modifiziert und schädlich sein. Dieses Tool übernimmt keine Verantwortung.","⚠ Les mods non téléchargés depuis leur source d'origine peuvent avoir été modifiés et être dangereux. Cet outil décline toute responsabilité.", +import.button.confirm,Import,Importieren,Importer, +status.modpack_imported,Modpack ''{0}'' imported.,Modpack „{0}" importiert.,Modpack « {0} » importé., +status.modpack_import_failed,Modpack import failed.,Modpack-Import fehlgeschlagen.,Échec de l'importation du modpack., diff --git a/DVModManager/Services/DialogService.cs b/DVModManager/Services/DialogService.cs index 7727bb0..1110bf9 100644 --- a/DVModManager/Services/DialogService.cs +++ b/DVModManager/Services/DialogService.cs @@ -1,11 +1,19 @@ using Avalonia.Controls; +using Avalonia.Media; using Avalonia.Platform.Storage; +using DVModManager.Models; namespace DVModManager.Services; public class DialogService : IDialogService { private Window? _owner; + private readonly ILocalizationService _loc; + + public DialogService(ILocalizationService loc) + { + _loc = loc; + } public void SetOwner(Window owner) => _owner = owner; @@ -76,7 +84,8 @@ public async Task ShowMessageAsync(string title, string message) { Title = title, Width = 420, - Height = 180, + MaxHeight = 600, + SizeToContent = SizeToContent.Height, CanResize = false, WindowStartupLocation = WindowStartupLocation.CenterOwner, Content = BuildMessageContent(message, null) @@ -95,10 +104,10 @@ public async Task ConfirmAsync(string title, string message) { Title = title, Width = 420, - Height = 180, + MaxHeight = 600, + SizeToContent = SizeToContent.Height, CanResize = false, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Content = BuildMessageContent(message, confirmed => { result = confirmed; }) + WindowStartupLocation = WindowStartupLocation.CenterOwner }; // We'll close the dialog from the button callbacks via a TaskCompletionSource @@ -125,7 +134,11 @@ private static Avalonia.Controls.Control BuildMessageContent(string message, Act Spacing = 16, Children = { - new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, + new ScrollViewer + { + MaxHeight = 280, + Content = new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap } + }, btn } }; @@ -153,9 +166,201 @@ private static Avalonia.Controls.Control BuildConfirmContent(string message, Act Spacing = 16, Children = { - new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, + new ScrollViewer + { + MaxHeight = 280, + Content = new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap } + }, + buttons + } + }; + } + + public async Task ShowExportOptionsAsync(string title) + { + if (_owner == null) return ExportOption.Cancel; + + var tcs = new TaskCompletionSource(); + + var jsonBtn = new Button { Content = _loc.GetString("export.button.json") }; + var zipBtn = new Button { Content = _loc.GetString("export.button.zip") }; + var cancelBtn = new Button { Content = _loc.GetString("settings.button.cancel") }; + + jsonBtn.Click += (_, _) => tcs.TrySetResult(ExportOption.Json); + zipBtn.Click += (_, _) => tcs.TrySetResult(ExportOption.Zip); + cancelBtn.Click += (_, _) => tcs.TrySetResult(ExportOption.Cancel); + + var buttons = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + Spacing = 12, + Children = { jsonBtn, zipBtn, cancelBtn } + }; + + var dialog = new Window + { + Title = title, + Width = 360, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = new StackPanel + { + Margin = new Avalonia.Thickness(20), + Spacing = 16, + Children = { buttons } + } + }; + + dialog.Closing += (_, _) => tcs.TrySetResult(ExportOption.Cancel); + _ = dialog.ShowDialog(_owner); + var result = await tcs.Task; + if (dialog.IsVisible) dialog.Close(); + return result; + } + + public async Task ShowModpackImportConfirmAsync(string title, ModProfile profile) + { + if (_owner == null) return false; + + var tcs = new TaskCompletionSource(); + + var sb = new System.Text.StringBuilder(); + foreach (var mod in profile.Mods) + sb.AppendLine($" \u2022 {mod.ModId} v{mod.Version}"); + + var preamble = new TextBlock + { + Text = _loc.GetString("import.modpack.preamble"), + TextWrapping = TextWrapping.Wrap + }; + var modList = new TextBlock + { + Text = sb.ToString().TrimEnd(), + FontFamily = new FontFamily("Consolas,Courier New,monospace"), + TextWrapping = TextWrapping.Wrap + }; + var warning = new TextBlock + { + Text = _loc.GetString("import.modpack.security_warning"), + TextWrapping = TextWrapping.Wrap, + Foreground = Brushes.OrangeRed + }; + + var importBtn = new Button { Content = _loc.GetString("import.button.confirm") }; + var cancelBtn = new Button { Content = _loc.GetString("settings.button.cancel") }; + + importBtn.Click += (_, _) => tcs.TrySetResult(true); + cancelBtn.Click += (_, _) => tcs.TrySetResult(false); + + var buttons = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + Spacing = 12, + Children = { importBtn, cancelBtn } + }; + + var content = new StackPanel + { + Margin = new Avalonia.Thickness(20), + Spacing = 16, + Children = + { + preamble, + new ScrollViewer { MaxHeight = 200, Content = modList }, + warning, + buttons + } + }; + + var dialog = new Window + { + Title = title, + Width = 460, + MaxHeight = 600, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = content + }; + + dialog.Closing += (_, _) => tcs.TrySetResult(false); + _ = dialog.ShowDialog(_owner); + var result = await tcs.Task; + if (dialog.IsVisible) dialog.Close(); + return result; + } + + public async Task ShowFailedDownloadsAsync(string title, IReadOnlyList<(string ModId, string? HomePageUrl)> failedMods) + { + if (_owner == null) return false; + + bool openNexus = false; + var tcs = new TaskCompletionSource(); + + var nexusBtn = new Button { Content = "Open on Nexus", Width = 130 }; + var cancelBtn = new Button { Content = "Cancel", Width = 80 }; + + nexusBtn.Click += (_, _) => { openNexus = true; tcs.TrySetResult(true); }; + cancelBtn.Click += (_, _) => { openNexus = false; tcs.TrySetResult(false); }; + + var buttons = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + Spacing = 12, + Children = { nexusBtn, cancelBtn } + }; + + int nexusCount = failedMods.Count(m => !string.IsNullOrEmpty(m.HomePageUrl)); + nexusBtn.Content = nexusCount > 0 ? $"Open on Nexus ({nexusCount})" : "Open on Nexus"; + nexusBtn.Width = double.NaN; // auto-width to fit content + + var lines = new System.Text.StringBuilder(); + lines.AppendLine("The following mods could not be downloaded from GitHub:"); + foreach (var (modId, homePageUrl) in failedMods) + { + bool hasLink = !string.IsNullOrEmpty(homePageUrl); + lines.AppendLine(hasLink ? $" \u2022 {modId}" : $" \u2022 {modId} (no link available)"); + } + if (nexusCount > 0) + lines.AppendLine($"\nClick \"Open on Nexus ({nexusCount})\" to open their pages in the browser."); + else + nexusBtn.IsEnabled = false; + + var content = new StackPanel + { + Margin = new Avalonia.Thickness(20), + Spacing = 16, + Children = + { + new ScrollViewer + { + MaxHeight = 280, + Content = new TextBlock { Text = lines.ToString().TrimEnd(), TextWrapping = Avalonia.Media.TextWrapping.Wrap } + }, buttons } }; + + var dialog = new Window + { + Title = title, + Width = 460, + MaxHeight = 600, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = content + }; + + dialog.Closing += (_, _) => tcs.TrySetResult(false); + + _ = dialog.ShowDialog(_owner); + openNexus = await tcs.Task; + if (dialog.IsVisible) dialog.Close(); + return openNexus; } } diff --git a/DVModManager/Services/GitHubModsService.cs b/DVModManager/Services/GitHubModsService.cs index d19fc5e..1382166 100644 --- a/DVModManager/Services/GitHubModsService.cs +++ b/DVModManager/Services/GitHubModsService.cs @@ -101,6 +101,7 @@ private GitHubClient GetClient() try { using var http = new HttpClient(); + http.Timeout = TimeSpan.FromSeconds(15); http.DefaultRequestHeaders.UserAgent.ParseAdd("DVModManager/1.0"); var json = await http.GetStringAsync(mod.Repository, ct); using var doc = System.Text.Json.JsonDocument.Parse(json); @@ -150,30 +151,39 @@ public async Task DownloadReleaseAssetAsync( string downloadUrl, string destinationPath, IProgress? progress = null, CancellationToken ct = default) { - // GitHub release asset downloads are plain HTTPS — use HttpClient - using var http = new HttpClient(); - http.DefaultRequestHeaders.UserAgent.ParseAdd("DVModManager/1.0"); + try + { + // GitHub release asset downloads are plain HTTPS — use HttpClient + using var http = new HttpClient(); + http.Timeout = TimeSpan.FromSeconds(60); + http.DefaultRequestHeaders.UserAgent.ParseAdd("DVModManager/1.0"); + + using var response = await http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); - using var response = await http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, ct); - response.EnsureSuccessStatusCode(); + var totalBytes = response.Content.Headers.ContentLength; + await using var stream = await response.Content.ReadAsStreamAsync(ct); + await using var file = File.Create(destinationPath); - var totalBytes = response.Content.Headers.ContentLength; - await using var stream = await response.Content.ReadAsStreamAsync(ct); - await using var file = File.Create(destinationPath); + var buffer = new byte[81920]; + long downloaded = 0; + int read; - var buffer = new byte[81920]; - long downloaded = 0; - int read; + while ((read = await stream.ReadAsync(buffer, ct)) > 0) + { + await file.WriteAsync(buffer.AsMemory(0, read), ct); + downloaded += read; + if (totalBytes.HasValue) + progress?.Report((double)downloaded / totalBytes.Value); + } - while ((read = await stream.ReadAsync(buffer, ct)) > 0) + return destinationPath; + } + catch (Exception ex) { - await file.WriteAsync(buffer.AsMemory(0, read), ct); - downloaded += read; - if (totalBytes.HasValue) - progress?.Report((double)downloaded / totalBytes.Value); + _logger.LogError(ex, "Error downloading release asset from {Url}", downloadUrl); + throw; } - - return destinationPath; } // ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/DVModManager/Services/IDialogService.cs b/DVModManager/Services/IDialogService.cs index 5fb24e5..ae6b777 100644 --- a/DVModManager/Services/IDialogService.cs +++ b/DVModManager/Services/IDialogService.cs @@ -1,7 +1,10 @@ using Avalonia.Controls; +using DVModManager.Models; namespace DVModManager.Services; +public enum ExportOption { Json, Zip, Cancel } + public interface IDialogService { void SetOwner(Window owner); @@ -10,4 +13,10 @@ public interface IDialogService Task SaveFileAsync(string title, string filterName, string[] extensions, string defaultName); Task ShowMessageAsync(string title, string message); Task ConfirmAsync(string title, string message); + /// Shows a list of failed downloads with "Open on Nexus" and "Cancel" buttons. Returns true if the user chose "Open on Nexus". + Task ShowFailedDownloadsAsync(string title, IReadOnlyList<(string ModId, string? HomePageUrl)> failedMods); + /// Shows export format choice: JSON, ZIP, or Cancel. + Task ShowExportOptionsAsync(string title); + /// Shows the mod list from a profile with a security warning. Returns true if the user confirmed the import. + Task ShowModpackImportConfirmAsync(string title, ModProfile profile); } diff --git a/DVModManager/Services/ILocalizationService.cs b/DVModManager/Services/ILocalizationService.cs new file mode 100644 index 0000000..ba2e00b --- /dev/null +++ b/DVModManager/Services/ILocalizationService.cs @@ -0,0 +1,48 @@ +namespace DVModManager.Services; + +/// +/// Provides localization/translation services with key-based lookup, live language switching, +/// and fallback to English for missing translations. +/// +public interface ILocalizationService +{ + /// Gets the currently active language code (e.g., "en", "de", "fr"). + string CurrentLanguage { get; } + + /// Gets a list of supported language codes. + IReadOnlyList SupportedLanguages { get; } + + /// + /// Gets the localized string for the given key in the current language, + /// falling back to English if unavailable, or returning the key itself if not found anywhere. + /// + /// The translation key (e.g., "btn.save", "status.game_detected"). + /// The translated string, English fallback, or the key name if missing entirely. + string GetString(string key); + + /// + /// Indexer shorthand for XAML binding paths like [toolbar.save_profile]. + /// + string this[string key] { get; } + + /// + /// Gets the localized string with format arguments applied (like string.Format). + /// Falls back to English if unavailable, or returns the key if not found. + /// + /// The translation key. + /// Format arguments. + /// The formatted translated string, English fallback, or key if missing. + string GetString(string key, params object?[] args); + + /// + /// Switches the active language and notifies all subscribers. + /// Language must be in SupportedLanguages or falls back to "en". + /// + /// The language code to switch to. + void SetLanguage(string languageCode); + + /// + /// Raised when the active language changes, allowing UI elements to refresh. + /// + event EventHandler? LanguageChanged; +} diff --git a/DVModManager/Services/IModInstallService.cs b/DVModManager/Services/IModInstallService.cs index 8993a3c..0e2985e 100644 --- a/DVModManager/Services/IModInstallService.cs +++ b/DVModManager/Services/IModInstallService.cs @@ -7,6 +7,7 @@ public interface IModInstallService Task ActivateModAsync(ModInfo mod, string gamePath, CancellationToken ct = default); Task DeactivateModAsync(ModInfo mod, string gamePath, CancellationToken ct = default); Task InstallFromArchiveAsync(string archivePath, string gamePath, string storagePath, bool activate = true, CancellationToken ct = default); + Task InstallFromFolderAsync(string modFolderPath, string gamePath, string storagePath, bool activate = false, CancellationToken ct = default); Task UninstallModAsync(ModInfo mod, string gamePath, string storagePath, bool hardDelete = false, CancellationToken ct = default); Task RollbackToVersionAsync(string modId, string version, string gamePath, string storagePath, CancellationToken ct = default); Task UpdateModAsync(ModInfo mod, ModUpdateInfo update, string gamePath, string storagePath, IProgress? progress = null, CancellationToken ct = default); diff --git a/DVModManager/Services/IProfileService.cs b/DVModManager/Services/IProfileService.cs index dd7028e..b4a3fab 100644 --- a/DVModManager/Services/IProfileService.cs +++ b/DVModManager/Services/IProfileService.cs @@ -10,10 +10,9 @@ public interface IProfileService Task DeleteProfileAsync(string name, string profilesPath); Task ExportProfileAsync(ModProfile profile, string destinationFilePath); Task ImportProfileAsync(string sourceFilePath); - - /// - /// Returns a list describing what operations would occur if the profile were applied. - /// + Task ExportProfileAsZipAsync(ModProfile profile, string gamePath, string zipPath); + /// Returns a profile name that doesn't collide with existing files, appending (2), (3) etc. as needed. + Task GetUniqueProfileNameAsync(string name, string profilesPath); ProfileDiff ComputeDiff(ModProfile profile, IReadOnlyList currentMods); } diff --git a/DVModManager/Services/LocalizationService.cs b/DVModManager/Services/LocalizationService.cs new file mode 100644 index 0000000..b749e8c --- /dev/null +++ b/DVModManager/Services/LocalizationService.cs @@ -0,0 +1,271 @@ +using System.Globalization; +using System.ComponentModel; + +namespace DVModManager.Services; + +/// +/// Loads translations from an embedded CSV resource (key,en,de,fr,... format) +/// and provides runtime key lookup with fallback to English. +/// Implements INotifyPropertyChanged to notify UI when language changes. +/// +public class LocalizationService : ILocalizationService, INotifyPropertyChanged +{ + private readonly Dictionary> _translations = []; + private readonly List _languages = []; + private string _currentLanguage = "en"; + + public string CurrentLanguage + { + get => _currentLanguage; + private set + { + if (_currentLanguage != value) + { + _currentLanguage = value; + OnPropertyChanged(nameof(CurrentLanguage)); + } + } + } + + public IReadOnlyList SupportedLanguages => _languages.AsReadOnly(); + + public event EventHandler? LanguageChanged; + public event PropertyChangedEventHandler? PropertyChanged; + + public LocalizationService() + { + LoadTranslationsFromEmbeddedCsv(); + } + + /// + /// Loads translations from the embedded CSV resource "DVModManager.Resources.strings.csv". + /// Expected format: key,en,de,fr,... + /// Handles BOM, empty cells, and missing language columns gracefully. + /// + private void LoadTranslationsFromEmbeddedCsv() + { + _translations.Clear(); + _languages.Clear(); + _languages.Add("en"); // English always supported as fallback + + try + { + var assembly = typeof(LocalizationService).Assembly; + const string expectedResourceName = "DVModManager.Resources.strings.csv"; + var resourceName = expectedResourceName; + + // Resolve resource name defensively in case namespace/publish settings change. + // This avoids silent failures where the exact expected name cannot be found. + var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + resourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith("Resources.strings.csv", StringComparison.OrdinalIgnoreCase) + || n.EndsWith("strings.csv", StringComparison.OrdinalIgnoreCase)) + ?? expectedResourceName; + stream = assembly.GetManifestResourceStream(resourceName); + } + + using (stream) + { + if (stream == null) + { + var names = string.Join(", ", assembly.GetManifestResourceNames()); + Console.Error.WriteLine($"Warning: Embedded localization CSV not found. Expected '{expectedResourceName}'. Available resources: {names}"); + return; + } + + using (var reader = new StreamReader(stream)) + { + // Parse header row to identify language columns + var headerLine = reader.ReadLine(); + if (headerLine == null) return; + + // Be defensive about BOM and zero-width marks without dropping regular characters. + headerLine = headerLine.TrimStart('\uFEFF'); + + var headers = ParseCsvLine(headerLine).Select(h => h.Trim()).ToArray(); + if (headers.Length == 0) + { + Console.Error.WriteLine("CSV header is empty. Localization unavailable."); + return; + } + + var keyHeader = headers[0] + .Trim('\uFEFF', '\0', ' ', '\t', '\r', '\n'); + if (!keyHeader.Equals("key", StringComparison.OrdinalIgnoreCase)) + { + Console.Error.WriteLine($"Warning: CSV header first column is '{headers[0]}' instead of 'key'. Proceeding anyway."); + } + + // Extract language codes from header (skip "key" column) + var languages = headers.Skip(1).ToList(); + _languages.Clear(); + _languages.Add("en"); // Always available + foreach (var lang in languages) + { + if (!string.IsNullOrWhiteSpace(lang) && lang != "en" && !_languages.Contains(lang)) + _languages.Add(lang); + } + + // Parse data rows + string? line; + while ((line = reader.ReadLine()) != null) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + // Be defensive about stray BOM on row starts. + line = line.TrimStart('\uFEFF'); + + var parts = ParseCsvLine(line); + if (parts.Length < 1) continue; + + var key = parts[0].Trim(); + if (string.IsNullOrWhiteSpace(key)) continue; + + // Initialize entry for this key + if (!_translations.ContainsKey(key)) + _translations[key] = []; + + // Populate each language column (index 0 is key, so language i is at parts[i+1]) + for (int i = 0; i < languages.Count && i + 1 < parts.Length; i++) + { + var langCode = languages[i].Trim(); + var value = parts[i + 1].Trim(); + + if (!string.IsNullOrWhiteSpace(langCode) && !string.IsNullOrWhiteSpace(value)) + { + _translations[key][langCode] = UnescapeCsvValue(value); + } + } + } + + Console.Error.WriteLine($"Localization loaded from '{resourceName}'. Languages: {string.Join(", ", _languages)}. Keys: {_translations.Count}."); + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error loading embedded localization CSV: {ex.Message}"); + } + } + + /// + /// Parses one CSV row and supports quoted cells with commas and escaped quotes. + /// + private static string[] ParseCsvLine(string line) + { + var result = new List(); + var current = new System.Text.StringBuilder(); + bool inQuotes = false; + + for (int i = 0; i < line.Length; i++) + { + var ch = line[i]; + if (ch == '"') + { + if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') + { + // Escaped quote inside a quoted field. + current.Append('"'); + i++; + } + else + { + inQuotes = !inQuotes; + } + continue; + } + + if (ch == ',' && !inQuotes) + { + result.Add(current.ToString()); + current.Clear(); + continue; + } + + current.Append(ch); + } + + result.Add(current.ToString()); + return result.ToArray(); + } + + /// + /// Unescapes CSV-quoted values (handles quoted newlines, commas, escaped quotes, etc.). + /// For now, assumes values are not quoted unless we detect quotes during parsing. + /// + private static string UnescapeCsvValue(string value) + { + // Basic unescaping: if value was quoted, remove outer quotes and unescape inner quotes + if (value.StartsWith("\"") && value.EndsWith("\"")) + { + value = value[1..^1]; + value = value.Replace("\"\"", "\""); + } + return value; + } + + public string GetString(string key) + { + if (string.IsNullOrEmpty(key)) return ""; + + // Try current language + if (_translations.TryGetValue(key, out var langDict)) + { + if (langDict.TryGetValue(_currentLanguage, out var translation)) + return translation; + + // Fall back to English if current language not found + if (langDict.TryGetValue("en", out var englishTranslation)) + return englishTranslation; + } + + // Return key name as last resort + return key; + } + + /// + /// Indexer for XAML binding: {Binding [key], Source={StaticResource Loc}} + /// When language changes, "Item[]" PropertyChanged fires to refresh all indexer bindings. + /// + public string this[string key] => GetString(key); + + public string GetString(string key, params object?[] args) + { + var template = GetString(key); + try + { + return string.Format(CultureInfo.CurrentCulture, template, args); + } + catch + { + // If format fails, return template as-is + return template; + } + } + + public void SetLanguage(string languageCode) + { + if (string.IsNullOrWhiteSpace(languageCode)) + languageCode = "en"; + + // Normalize to supported language or fall back to English + if (!_languages.Contains(languageCode)) + languageCode = "en"; + + if (languageCode == _currentLanguage) + return; // No change + + CurrentLanguage = languageCode; // Use property setter to trigger PropertyChanged + // Notify all indexer bindings ([key]) that every key's value has changed + // "Item[]" is the standard PropertyChanged name for indexers (defined in Binding.IndexerName in System.Windows.Data) + OnPropertyChanged("Item[]"); + LanguageChanged?.Invoke(this, EventArgs.Empty); + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/DVModManager/Services/ModInstallService.cs b/DVModManager/Services/ModInstallService.cs index 6d97a3c..9cbec19 100644 --- a/DVModManager/Services/ModInstallService.cs +++ b/DVModManager/Services/ModInstallService.cs @@ -199,6 +199,67 @@ await Task.Run(() => } } + // ── Install from folder ───────────────────────────────────────────────────────────── + + public async Task InstallFromFolderAsync( + string modFolderPath, string gamePath, string storagePath, + bool activate = false, CancellationToken ct = default) + { + try + { + var infoPath = Path.Combine(modFolderPath, "Info.json"); + if (!File.Exists(infoPath)) + { + _logger.LogError("No Info.json found in folder {Folder}", modFolderPath); + return null; + } + + var json = await File.ReadAllTextAsync(infoPath, ct); + var modInfo = JsonSerializer.Deserialize(json, JsonOptions); + if (modInfo == null) return null; + + var targetDir = activate + ? Path.Combine(gamePath, "Mods", modInfo.Id) + : Path.Combine(gamePath, "Mods.inactive", modInfo.Id); + + Directory.CreateDirectory(Path.GetDirectoryName(targetDir)!); + + if (Directory.Exists(targetDir)) + { + var existingInfoPath = Path.Combine(targetDir, "Info.json"); + if (File.Exists(existingInfoPath)) + { + try + { + var existingMod = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(existingInfoPath, ct), JsonOptions); + if (existingMod != null) + { + existingMod.FolderPath = targetDir; + await _versionCache.ArchiveCurrentVersionAsync(existingMod, storagePath); + } + } + catch { /* archive failure is non-fatal */ } + } + Directory.Delete(targetDir, true); + } + + await Task.Run(() => CopyDirectoryRecursive(modFolderPath, targetDir), ct); + + modInfo.FolderPath = targetDir; + modInfo.IsActive = activate; + modInfo.State = activate ? ModState.Active : ModState.Inactive; + + _logger.LogInformation("Installed mod {Id} v{Version} from folder", modInfo.Id, modInfo.Version); + return modInfo; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to install from folder {Folder}", modFolderPath); + return null; + } + } + // ── Uninstall ───────────────────────────────────────────────────────────── public async Task UninstallModAsync(ModInfo mod, string gamePath, string storagePath, bool hardDelete = false, CancellationToken ct = default) @@ -369,6 +430,7 @@ public async Task UpdateModAsync( var downloadPath = Path.Combine(downloadDir, $"{Guid.NewGuid()}.zip"); using var http = new HttpClient(); + http.Timeout = TimeSpan.FromSeconds(60); http.DefaultRequestHeaders.UserAgent.ParseAdd("DVModManager/1.0"); using var response = await http.GetAsync( downloadUrl, HttpCompletionOption.ResponseHeadersRead, ct); diff --git a/DVModManager/Services/ProfileService.cs b/DVModManager/Services/ProfileService.cs index ba53c8a..8233a00 100644 --- a/DVModManager/Services/ProfileService.cs +++ b/DVModManager/Services/ProfileService.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using System.Text.Json; using DVModManager.Models; @@ -68,6 +69,55 @@ public async Task ImportProfileAsync(string sourceFilePath) ?? throw new InvalidDataException("File is not a valid profile."); } + public async Task ExportProfileAsZipAsync(ModProfile profile, string gamePath, string zipPath) + { + await Task.Run(() => + { + using var archive = System.IO.Compression.ZipFile.Open(zipPath, System.IO.Compression.ZipArchiveMode.Create); + + // Add profile.json at root + var profileEntry = archive.CreateEntry("profile.json"); + using (var entryStream = profileEntry.Open()) + { + var json = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(profile, JsonOptions); + entryStream.Write(json, 0, json.Length); + } + + // Add mod files under mods// + foreach (var mod in profile.Mods) + { + var activePath = Path.Combine(gamePath, "Mods", mod.ModId); + var inactivePath = Path.Combine(gamePath, "Mods.inactive", mod.ModId); + var modFolder = Directory.Exists(activePath) ? activePath + : Directory.Exists(inactivePath) ? inactivePath + : null; + if (modFolder == null) continue; + + foreach (var file in Directory.EnumerateFiles(modFolder, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(modFolder, file) + .Replace('\\', '/'); + var entryName = $"mods/{mod.ModId}/{relative}"; + archive.CreateEntryFromFile(file, entryName, + System.IO.Compression.CompressionLevel.Fastest); + } + } + }); + return zipPath; + } + + public Task GetUniqueProfileNameAsync(string name, string profilesPath) + { + var safeName = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); + var candidate = safeName; + var counter = 2; + while (File.Exists(Path.Combine(profilesPath, candidate + ".json"))) + { + candidate = $"{safeName} ({counter++})"; + } + return Task.FromResult(candidate); + } + public ProfileDiff ComputeDiff(ModProfile profile, IReadOnlyList currentMods) { // Use last-write-wins to tolerate duplicate mod IDs (e.g. same mod in both diff --git a/DVModManager/Services/VersionCacheService.cs b/DVModManager/Services/VersionCacheService.cs index b671eaa..5a31c32 100644 --- a/DVModManager/Services/VersionCacheService.cs +++ b/DVModManager/Services/VersionCacheService.cs @@ -32,7 +32,7 @@ public async Task ArchiveCurrentVersionAsync(ModInfo mod, string storagePath) ModId = mod.Id, Version = mod.Version, ArchivePath = archivePath, - Source = "cached", + Source = "modversion.source.cached", ArchivedAt = DateTime.UtcNow, ArchiveSizeBytes = new FileInfo(archivePath).Length }); diff --git a/DVModManager/ViewModels/MainWindowViewModel.cs b/DVModManager/ViewModels/MainWindowViewModel.cs index c278e34..c6df416 100644 --- a/DVModManager/ViewModels/MainWindowViewModel.cs +++ b/DVModManager/ViewModels/MainWindowViewModel.cs @@ -20,6 +20,7 @@ public partial class MainWindowViewModel : ViewModelBase private readonly IProfileService _profileService; private readonly IUpdateService _updateService; private readonly IDialogService _dialogService; + private readonly ILocalizationService _localization; // ── Observable state ────────────────────────────────────────────────────── [ObservableProperty] private ObservableCollection _availableMods = []; @@ -32,6 +33,9 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private string _busyMessage = ""; [ObservableProperty] private string _selectedProfileName = "Default"; [ObservableProperty] private ObservableCollection _profileNames = []; + [ObservableProperty] private string _windowTitle = "DV Mod Manager"; + [ObservableProperty] private string _panelAvailableHeader = "Available Mods"; + [ObservableProperty] private string _panelActiveHeader = "Active Mods"; // ── Version history for selected mod ────────────────────────────────────── [ObservableProperty] private IReadOnlyList _selectedModVersionHistory = []; @@ -62,7 +66,8 @@ public MainWindowViewModel( IVersionCacheService versionCache, IProfileService profileService, IUpdateService updateService, - IDialogService dialogService) + IDialogService dialogService, + ILocalizationService localization) { _settings = settings; _gameDetection = gameDetection; @@ -72,11 +77,14 @@ public MainWindowViewModel( _profileService = profileService; _updateService = updateService; _dialogService = dialogService; + _localization = localization; _gameDetection.GameRunningChanged += OnGameRunningChanged; _modDiscovery.ModsChanged += OnModsChangedExternally; + _localization.LanguageChanged += (_, _) => UpdateLocalizedStrings(); IsGameRunning = _gameDetection.IsGameRunning(); + UpdateLocalizedStrings(); } partial void OnAvailableFilterChanged(string value) => ApplyGroupedFilters(); @@ -262,6 +270,25 @@ public async Task AssignModToGroupAsync(string modId, string? groupId) ApplyGroupedFilters(); } + public async Task AssignModsToGroupAsync(IEnumerable modIds, string? groupId) + { + foreach (var modId in modIds) + { + foreach (var g in _settings.Settings.ModGroups) + g.ModIds.Remove(modId); + + if (groupId != null) + { + var target = _settings.Settings.ModGroups.FirstOrDefault(g => g.Id == groupId); + if (target != null && !target.ModIds.Contains(modId)) + target.ModIds.Add(modId); + } + } + + await _settings.SaveAsync(); + ApplyGroupedFilters(); + } + public async Task RemoveMissingModFromGroupAsync(string modId) { foreach (var g in _settings.Settings.ModGroups) @@ -277,19 +304,25 @@ public async Task InitializeAsync() try { await _settings.LoadAsync(); + + // Apply language from persisted settings + if (!string.IsNullOrEmpty(_settings.Settings.Language)) + _localization.SetLanguage(_settings.Settings.Language); + SelectedProfileName = _settings.Settings.ActiveProfileName; + UpdateLocalizedStrings(); if (string.IsNullOrEmpty(_settings.Settings.GamePath)) { _settings.Settings.GamePath = _gameDetection.DetectGamePath(); if (_settings.Settings.GamePath != null) { - StatusMessage = $"Detected game at: {_settings.Settings.GamePath}"; + StatusMessage = _localization.GetString("status.detected_game", _settings.Settings.GamePath); await _settings.SaveAsync(); } else { - StatusMessage = "Game not found. Please set the game path in Settings."; + StatusMessage = _localization.GetString("status.game_path_not_set"); await PromptGamePathAsync(); return; } @@ -305,7 +338,7 @@ public async Task InitializeAsync() } catch (Exception ex) { - StatusMessage = $"Startup error: {ex.Message}"; + StatusMessage = _localization.GetString("status.startup_error", ex.Message); } } @@ -316,6 +349,33 @@ private async Task ActivateSelectedModAsync() { if (_settings.Settings.GamePath == null) return; + // Multiple mods checked — activate all checked available mods + var checkedTargets = AvailableMods.Where(m => m.IsChecked).ToList(); + if (checkedTargets.Count >= 2) + { + SetBusy($"Activating {checkedTargets.Count} mod(s)..."); + int activated = 0; + var missingDeps = new List(); + foreach (var target in checkedTargets) + { + var missing = await AutoActivateDependenciesAsync(target); + if (missing.Count > 0) + { + target.State = ModState.MissingDependency; + target.HasMissingDependency = true; + missingDeps.Add($"{target.DisplayName} (missing: {string.Join(", ", missing)})"); + continue; + } + var success = await _modInstall.ActivateModAsync(target.ModInfo, _settings.Settings.GamePath); + if (success) { target.SyncFromModel(); MoveToActive(target); activated++; } + } + ClearBusy(); + StatusMessage = missingDeps.Count > 0 + ? $"Activated {activated}/{checkedTargets.Count} — missing deps: {string.Join("; ", missingDeps)}" + : $"Activated {activated}/{checkedTargets.Count} mod(s)."; + return; + } + // Group selected — activate all available mods in the group if (SelectedGroup != null) { @@ -370,11 +430,11 @@ private async Task ActivateSelectedModAsync() { mod.SyncFromModel(); MoveToActive(mod); - StatusMessage = $"Activated: {displayName}"; + StatusMessage = _localization.GetString("status.activated", displayName); } else { - StatusMessage = $"Failed to activate: {displayName}"; + StatusMessage = _localization.GetString("status.activation_failed", displayName); } } @@ -383,6 +443,22 @@ private async Task DeactivateSelectedModAsync() { if (_settings.Settings.GamePath == null) return; + // Multiple mods checked — deactivate all checked active mods + var checkedTargets = ActiveMods.Where(m => m.IsChecked).ToList(); + if (checkedTargets.Count >= 2) + { + SetBusy($"Deactivating {checkedTargets.Count} mod(s)..."); + int deactivated = 0; + foreach (var target in checkedTargets) + { + var success = await _modInstall.DeactivateModAsync(target.ModInfo, _settings.Settings.GamePath); + if (success) { target.SyncFromModel(); MoveToInactive(target); deactivated++; } + } + ClearBusy(); + StatusMessage = $"Deactivated {deactivated}/{checkedTargets.Count} mod(s)."; + return; + } + // Group selected — deactivate all active mods in the group if (SelectedGroup != null) { @@ -398,7 +474,7 @@ private async Task DeactivateSelectedModAsync() if (success) { target.SyncFromModel(); MoveToInactive(target); deactivated++; } } ClearBusy(); - StatusMessage = $"Deactivated {deactivated}/{targets.Count} mod(s) in group '{group.Name}'."; + StatusMessage = _localization.GetString("status.deactivated_count", deactivated, targets.Count, group.Name); return; } @@ -413,20 +489,111 @@ private async Task DeactivateSelectedModAsync() { SelectedMod.SyncFromModel(); MoveToInactive(SelectedMod); - StatusMessage = $"Deactivated: {dn}"; + StatusMessage = _localization.GetString("status.deactivated", dn); } else { - StatusMessage = $"Failed to deactivate: {dn}"; + StatusMessage = _localization.GetString("status.deactivation_failed", dn); } } [RelayCommand(CanExecute = nameof(CanModify))] private async Task InstallModFromFileAsync() { - var path = await _dialogService.OpenFileAsync("Install Mod Archive", "Mod Archives", ["zip"]); + var path = await _dialogService.OpenFileAsync( + _localization.GetString("install.button.tooltip"), + "Mod Archives & Profiles", ["zip", "json"]); if (path == null || _settings.Settings.GamePath == null) return; + var ext = Path.GetExtension(path).ToLowerInvariant(); + + // ── JSON profile import ──────────────────────────────────────────── + if (ext == ".json") + { + try + { + var profile = await _profileService.ImportProfileAsync(path); + var uniqueName = await _profileService.GetUniqueProfileNameAsync( + profile.Name, _settings.Settings.ProfilesPath); + profile.Name = uniqueName; + await _profileService.SaveProfileAsync(profile, _settings.Settings.ProfilesPath); + await RefreshProfileListAsync(); + StatusMessage = _localization.GetString("status.modpack_imported", uniqueName); + } + catch + { + StatusMessage = _localization.GetString("status.modpack_import_failed"); + } + return; + } + + // ── ZIP: check for profile.json inside → modpack import ─────────── + ModProfile? embeddedProfile = null; + try + { + using var zip = System.IO.Compression.ZipFile.OpenRead(path); + var profileEntry = zip.Entries.FirstOrDefault(e => + string.Equals(e.FullName, "profile.json", StringComparison.OrdinalIgnoreCase)); + if (profileEntry != null) + { + using var sr = new System.IO.StreamReader(profileEntry.Open()); + var json = await sr.ReadToEndAsync(); + embeddedProfile = System.Text.Json.JsonSerializer.Deserialize(json); + } + } + catch { /* not a valid zip or no profile — fall through to normal install */ } + + if (embeddedProfile != null) + { + var confirmed = await _dialogService.ShowModpackImportConfirmAsync( + _localization.GetString("import.modpack.title"), embeddedProfile); + if (!confirmed) return; + + SetBusy(_localization.GetString("import.modpack.title") + "…"); + try + { + var tempDir = Path.Combine(Path.GetTempPath(), "dvmm_modpack_" + Guid.NewGuid()); + try + { + await Task.Run(() => + System.IO.Compression.ZipFile.ExtractToDirectory(path, tempDir, overwriteFiles: true)); + + var modsDir = Path.Combine(tempDir, "mods"); + if (Directory.Exists(modsDir)) + { + foreach (var modFolder in Directory.GetDirectories(modsDir)) + { + await _modInstall.InstallFromFolderAsync( + modFolder, + _settings.Settings.GamePath, + _settings.Settings.StoragePath, + activate: false); + } + } + + var uniqueName = await _profileService.GetUniqueProfileNameAsync( + embeddedProfile.Name, _settings.Settings.ProfilesPath); + embeddedProfile.Name = uniqueName; + await _profileService.SaveProfileAsync(embeddedProfile, _settings.Settings.ProfilesPath); + await RefreshModsAsync(); + await RefreshProfileListAsync(); + StatusMessage = _localization.GetString("status.modpack_imported", uniqueName); + } + finally + { + try { Directory.Delete(tempDir, true); } catch { } + } + } + catch + { + ClearBusy(); + StatusMessage = _localization.GetString("status.modpack_import_failed"); + } + ClearBusy(); + return; + } + + // ── Regular mod archive install ──────────────────────────────────── SetBusy("Installing mod..."); var mod = await _modInstall.InstallFromArchiveAsync( path, _settings.Settings.GamePath, _settings.Settings.StoragePath); @@ -435,11 +602,11 @@ private async Task InstallModFromFileAsync() if (mod != null) { await RefreshModsAsync(); - StatusMessage = $"Installed: {mod.EffectiveDisplayName}"; + StatusMessage = _localization.GetString("status.install_success", mod.EffectiveDisplayName); } else { - StatusMessage = "Install failed. Ensure the archive contains Info.json."; + StatusMessage = _localization.GetString("status.install_failed"); } } @@ -463,7 +630,7 @@ private async Task UninstallSelectedModAsync() if (success) { await RefreshModsAsync(); - StatusMessage = $"Uninstalled: {displayName}"; + StatusMessage = _localization.GetString("status.uninstalled", displayName); } } @@ -486,8 +653,8 @@ private async Task CheckUpdatesAsync() } StatusMessage = updates.Count > 0 - ? $"{updates.Count} update(s) available." - : "All mods are up to date."; + ? _localization.GetString("status.updates_available", updates.Count) + : _localization.GetString("status.all_up_to_date"); } [RelayCommand(CanExecute = nameof(CanModify))] @@ -505,13 +672,13 @@ private async Task UpdateSelectedModAsync() var url = update.ChangelogUrl ?? update.DownloadUrl; if (!string.IsNullOrEmpty(url)) Helpers.PlatformHelper.Open(url); - StatusMessage = $"Opened mod page for {displayName} v{update.LatestVersion}"; + StatusMessage = _localization.GetString("status.update_opened", displayName, update.LatestVersion); return; } SetBusy($"Updating {displayName} to v{update.LatestVersion}..."); var progress = new Progress(p => - BusyMessage = $"Updating {displayName}… {p:P0}"); + BusyMessage = _localization.GetString("busy.update_progress", displayName, p)); var success = await _modInstall.UpdateModAsync( SelectedMod.ModInfo, update, _settings.Settings.GamePath, _settings.Settings.StoragePath, progress); ClearBusy(); @@ -519,11 +686,11 @@ private async Task UpdateSelectedModAsync() if (success) { await RefreshModsAsync(); - StatusMessage = $"Updated {displayName} to v{update.LatestVersion}"; + StatusMessage = _localization.GetString("status.updated", displayName, update.LatestVersion); } else { - StatusMessage = $"Update failed for {displayName}"; + StatusMessage = _localization.GetString("status.update_failed", displayName); } } @@ -538,7 +705,7 @@ private async Task UpdateAllModsAsync() if (modsWithUpdates.Count == 0) { - StatusMessage = "No downloadable updates available."; + StatusMessage = _localization.GetString("status.no_downloadable_updates"); return; } @@ -618,7 +785,7 @@ private async Task SaveCurrentProfileAsync() _settings.Settings.ActiveProfileName = SelectedProfileName; await _settings.SaveAsync(); await RefreshProfileListAsync(); - StatusMessage = $"Profile '{SelectedProfileName}' saved."; + StatusMessage = _localization.GetString("status.profile_saved", SelectedProfileName); } [RelayCommand(CanExecute = nameof(CanModify))] @@ -626,7 +793,7 @@ private async Task DeactivateAllModsAsync() { if (_settings.Settings.GamePath == null) return; var active = ActiveMods.ToList(); - if (active.Count == 0) { StatusMessage = "No active mods."; return; } + if (active.Count == 0) { StatusMessage = _localization.GetString("status.no_active_mods"); return; } var confirmed = await _dialogService.ConfirmAsync("Unload All Mods", $"Deactivate all {active.Count} active mod(s)?"); @@ -661,7 +828,7 @@ private async Task ApplyProfileAsync(string profileName) if (!hasWork) { - StatusMessage = "Profile is already applied."; + StatusMessage = _localization.GetString("status.profile_already_applied"); return; } @@ -690,6 +857,7 @@ private async Task ApplyProfileAsync(string profileName) if (confirmed) { int downloaded = 0; + var failedEntries = new List<(string ModId, string? HomePageUrl)>(); SetBusy($"Downloading missing mods (0/{githubEntries.Count})..."); foreach (var entry in githubEntries) @@ -703,25 +871,36 @@ private async Task ApplyProfileAsync(string profileName) Version = "0.0.0", Repository = entry.RepositoryUrl! }; - var updateInfo = await _updateService.CheckUpdateAsync(stub); - if (updateInfo?.DownloadUrl != null) + try { - BusyMessage = $"Downloading {entry.ModId} ({downloaded + 1}/{githubEntries.Count})..."; - var progress = new Progress(p => - BusyMessage = $"Downloading {entry.ModId} {p:P0} ({downloaded + 1}/{githubEntries.Count})..."); - var result = await _modInstall.DownloadAndInstallFromUrlAsync( - updateInfo.DownloadUrl, _settings.Settings.GamePath, _settings.Settings.StoragePath, progress); - if (result != null) downloaded++; - else StatusMessage = $"Download failed for {entry.ModId}."; + using var resolveCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var updateInfo = await _updateService.CheckUpdateAsync(stub, resolveCts.Token); + if (updateInfo?.DownloadUrl != null) + { + BusyMessage = $"Downloading {entry.ModId} ({downloaded + 1}/{githubEntries.Count})..."; + var progress = new Progress(p => + BusyMessage = $"Downloading {entry.ModId} {p:P0} ({downloaded + 1}/{githubEntries.Count})..."); + using var downloadCts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + var result = await _modInstall.DownloadAndInstallFromUrlAsync( + updateInfo.DownloadUrl, _settings.Settings.GamePath, _settings.Settings.StoragePath, + progress, downloadCts.Token); + if (result != null) downloaded++; + else failedEntries.Add((entry.ModId, entry.HomePageUrl)); + } + else + { + failedEntries.Add((entry.ModId, entry.HomePageUrl)); + } } - else + catch (OperationCanceledException) { - StatusMessage = $"Could not resolve download URL for {entry.ModId}."; + failedEntries.Add((entry.ModId, entry.HomePageUrl)); } } + // Collect nexus-only entries into the failed list so they appear in the dialog foreach (var entry in nexusEntries) - Helpers.PlatformHelper.Open(entry.HomePageUrl!); + failedEntries.Add((entry.ModId, entry.HomePageUrl)); // Refresh so newly installed mods appear in the diff for activation await RefreshModsAsync(); @@ -729,8 +908,17 @@ private async Task ApplyProfileAsync(string profileName) diff = _profileService.ComputeDiff(profile, allMods); ClearBusy(); - if (nexusEntries.Count > 0) - StatusMessage = $"Downloaded {downloaded} mod(s). Nexus mods ({nexusEntries.Count}) opened in browser — install manually then re-apply."; + if (failedEntries.Count > 0) + { + var openNexus = await _dialogService.ShowFailedDownloadsAsync( + "Download Failed", failedEntries); + if (openNexus) + { + foreach (var (_, homePageUrl) in failedEntries.Where(f => !string.IsNullOrEmpty(f.HomePageUrl))) + Helpers.PlatformHelper.Open(homePageUrl!); + } + StatusMessage = $"Downloaded {downloaded} mod(s). {failedEntries.Count} could not be auto-downloaded."; + } } } @@ -740,7 +928,7 @@ private async Task ApplyProfileAsync(string profileName) _settings.Settings.ActiveProfileName = profileName; SelectedProfileName = profileName; await _settings.SaveAsync(); - StatusMessage = $"Applied profile '{profileName}'."; + StatusMessage = _localization.GetString("status.profile_applied", profileName); return; } @@ -779,7 +967,7 @@ await _modInstall.RollbackToVersionAsync( SelectedProfileName = profileName; await _settings.SaveAsync(); await RefreshModsAsync(); - StatusMessage = $"Applied profile '{profileName}'."; + StatusMessage = _localization.GetString("status.profile_applied", profileName); } // ── Refresh ─────────────────────────────────────────────────────────────── @@ -837,8 +1025,8 @@ await Dispatcher.UIThread.InvokeAsync(() => var active = mods.Count(m => m.IsActive); var inactive = mods.Count(m => !m.IsActive); StatusMessage = mods.Count == 0 - ? $"No mods found in {modsDir}" - : $"Found {mods.Count} mod(s) — {active} active, {inactive} inactive."; + ? _localization.GetString("status.no_mods_found", modsDir) + : _localization.GetString("status.mods_found", mods.Count, active, inactive); }); } @@ -878,8 +1066,16 @@ private async Task OpenSettingsAsync() if (vm.Saved) { var previousGamePath = _settings.Settings.GamePath; + var previousLanguage = _settings.Settings.Language; vm.Apply(_settings.Settings); // copy UI values → AppSettings await _settings.SaveAsync(); + + // Apply language change if it changed + if (_settings.Settings.Language != previousLanguage) + { + _localization.SetLanguage(_settings.Settings.Language); + } + await RefreshModsAsync(); // Restart file watchers if the game path changed @@ -941,6 +1137,13 @@ private void OnModsChangedExternally(object? sender, EventArgs e) Dispatcher.UIThread.Post(async () => await RefreshModsAsync()); } + private void UpdateLocalizedStrings() + { + WindowTitle = _localization.GetString("window.title"); + PanelAvailableHeader = _localization.GetString("panel.available"); + PanelActiveHeader = _localization.GetString("panel.active"); + } + private void MoveToActive(ModItemViewModel vm) { AvailableMods.Remove(vm); diff --git a/DVModManager/ViewModels/ModItemViewModel.cs b/DVModManager/ViewModels/ModItemViewModel.cs index 8609343..01dafaf 100644 --- a/DVModManager/ViewModels/ModItemViewModel.cs +++ b/DVModManager/ViewModels/ModItemViewModel.cs @@ -1,17 +1,32 @@ using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; using DVModManager.Models; +using DVModManager.Services; +using Microsoft.Extensions.DependencyInjection; namespace DVModManager.ViewModels; public partial class ModItemViewModel : ViewModelBase { private readonly ModInfo _modInfo; + private readonly ILocalizationService? _localization; /// Normal constructor from a scanned ModInfo. public ModItemViewModel(ModInfo modInfo) { _modInfo = modInfo; + + try + { + _localization = App.Services.GetService(typeof(ILocalizationService)) as ILocalizationService; + if (_localization != null) + _localization.LanguageChanged += (_, _) => OnPropertyChanged(nameof(StatusLabel)); + } + catch + { + _localization = null; + } + SyncFromModel(); } @@ -24,6 +39,7 @@ public static ModItemViewModel CreateMissing(string modId) => [ObservableProperty] private string _author = ""; [ObservableProperty] private string _version = ""; [ObservableProperty] private bool _isActive; + [ObservableProperty] private bool _isChecked; [ObservableProperty] private ModState _state; [ObservableProperty] private bool _hasUpdate; [ObservableProperty] private string? _updateVersion; @@ -45,14 +61,26 @@ public static ModItemViewModel CreateMissing(string modId) => public ModInfo ModInfo => _modInfo; + private string LocalizeStatus(string key, string fallback, params object?[] args) + { + if (_localization == null) + return args.Length == 0 ? fallback : string.Format(fallback, args); + + var localized = _localization.GetString(key, args); + if (localized == key) + return args.Length == 0 ? fallback : string.Format(fallback, args); + + return localized; + } + public string StatusLabel => State switch { - ModState.UpdateAvailable => $"Update available \u2192 {UpdateVersion}", - ModState.MissingDependency => "Missing dependency", - ModState.NoMetadata => "No metadata", - ModState.Missing => "Missing \u2014 not found on disk", - ModState.Active => "Active", - _ => "Inactive" + ModState.UpdateAvailable => LocalizeStatus("modstate.update_available", "Update available → {0}", UpdateVersion), + ModState.MissingDependency => LocalizeStatus("modstate.missing_dependency", "Missing dependency"), + ModState.NoMetadata => LocalizeStatus("modstate.no_metadata", "No metadata"), + ModState.Missing => LocalizeStatus("modstate.missing", "Missing — not found on disk"), + ModState.Active => LocalizeStatus("modstate.active", "Active"), + _ => LocalizeStatus("modstate.inactive", "Inactive") }; public IBrush StatusBrush => State switch diff --git a/DVModManager/ViewModels/ProfileViewModel.cs b/DVModManager/ViewModels/ProfileViewModel.cs index c348690..b8914f9 100644 --- a/DVModManager/ViewModels/ProfileViewModel.cs +++ b/DVModManager/ViewModels/ProfileViewModel.cs @@ -10,6 +10,8 @@ public partial class ProfileViewModel : ViewModelBase { private readonly IProfileService _profileService; private readonly IDialogService _dialogService; + private readonly ISettingsService _settings; + private readonly ILocalizationService _loc; private string _profilesPath = ""; [ObservableProperty] private ObservableCollection _profiles = []; @@ -21,10 +23,13 @@ public partial class ProfileViewModel : ViewModelBase public event EventHandler? ProfileApplyRequested; - public ProfileViewModel(IProfileService profileService, IDialogService dialogService) + public ProfileViewModel(IProfileService profileService, IDialogService dialogService, + ISettingsService settings, ILocalizationService loc) { _profileService = profileService; _dialogService = dialogService; + _settings = settings; + _loc = loc; } public async Task LoadProfilesAsync(string profilesPath) @@ -65,11 +70,35 @@ private async Task DeleteSelectedAsync() private async Task ExportSelectedAsync() { if (SelectedProfile == null) return; - var path = await _dialogService.SaveFileAsync("Export Profile", - "JSON Profile", ["json"], SelectedProfile.Name + ".json"); - if (path == null) return; - await _profileService.ExportProfileAsync(SelectedProfile, path); + var exportTitle = _loc.GetString("export.choose_format.title"); + var choice = await _dialogService.ShowExportOptionsAsync(exportTitle); + + if (choice == ExportOption.Cancel) return; + + if (choice == ExportOption.Json) + { + var path = await _dialogService.SaveFileAsync(exportTitle, + "JSON Profile", ["json"], SelectedProfile.Name + ".json"); + if (path == null) return; + await _profileService.ExportProfileAsync(SelectedProfile, path); + return; + } + + // ZIP + var confirmed = await _dialogService.ConfirmAsync( + _loc.GetString("export.zip_warning.title"), + _loc.GetString("export.zip_warning.message")); + if (!confirmed) return; + + var gamePath = _settings.Settings.GamePath; + if (string.IsNullOrEmpty(gamePath)) return; + + var zipPath = await _dialogService.SaveFileAsync(exportTitle, + "ZIP Modpack", ["zip"], SelectedProfile.Name + ".zip"); + if (zipPath == null) return; + + await _profileService.ExportProfileAsZipAsync(SelectedProfile, gamePath, zipPath); } [RelayCommand] diff --git a/DVModManager/ViewModels/SettingsViewModel.cs b/DVModManager/ViewModels/SettingsViewModel.cs index 6aeefd8..e196405 100644 --- a/DVModManager/ViewModels/SettingsViewModel.cs +++ b/DVModManager/ViewModels/SettingsViewModel.cs @@ -9,6 +9,7 @@ public partial class SettingsViewModel : ViewModelBase { private readonly IDialogService _dialogService; private readonly IGameDetectionService _gameDetection; + private readonly ILocalizationService? _localization; public bool Saved { get; private set; } @@ -20,11 +21,31 @@ public partial class SettingsViewModel : ViewModelBase [ObservableProperty] private bool _backupBeforeChanges = true; [ObservableProperty] private int _maxCacheGb = 5; [ObservableProperty] private string _themeVariant = "Dark"; + [ObservableProperty] private string _language = "en"; + + // Available language options for the UI + public List AvailableLanguages { get; } = ["en", "de", "fr", "zh"]; + + // Display names for languages (for the dropdown) + public Dictionary LanguageDisplayNames => new() + { + { "en", "English" }, + { "de", "Deutsch" }, + { "fr", "Français" }, + { "zh", "中文" } + }; public SettingsViewModel(IDialogService dialogService, IGameDetectionService gameDetection) { _dialogService = dialogService; _gameDetection = gameDetection; + + // Try to get localization service if available + try + { + _localization = App.Services.GetService(typeof(ILocalizationService)) as ILocalizationService; + } + catch { } } public void Load(AppSettings settings) @@ -37,6 +58,7 @@ public void Load(AppSettings settings) BackupBeforeChanges = settings.BackupBeforeChanges; MaxCacheGb = (int)(settings.MaxCacheSizeBytes / (1024 * 1024 * 1024)); ThemeVariant = settings.ThemeVariant; + Language = settings.Language; Saved = false; } @@ -50,6 +72,7 @@ public void Apply(AppSettings settings) settings.BackupBeforeChanges = BackupBeforeChanges; settings.MaxCacheSizeBytes = (long)MaxCacheGb * 1024 * 1024 * 1024; settings.ThemeVariant = ThemeVariant; + settings.Language = Language; } [RelayCommand] diff --git a/DVModManager/Views/MainWindow.axaml b/DVModManager/Views/MainWindow.axaml index 3baa350..3e4c19d 100644 --- a/DVModManager/Views/MainWindow.axaml +++ b/DVModManager/Views/MainWindow.axaml @@ -5,7 +5,7 @@ xmlns:conv="using:DVModManager.Converters" x:Class="DVModManager.Views.MainWindow" x:DataType="vm:MainWindowViewModel" - Title="DV Mod Manager" + Title="{Binding WindowTitle}" Width="1280" Height="800" MinWidth="900" MinHeight="600" WindowStartupLocation="CenterScreen" @@ -15,6 +15,7 @@ + @@ -32,7 +33,7 @@ Orientation="Horizontal" Spacing="6"> - @@ -44,16 +45,16 @@ Background="#313244" BorderBrush="#45475a" /> - + VerticalAlignment="Center" /> @@ -135,14 +135,14 @@ Foreground="#89b4fa" FontSize="11" Padding="3,1" - ToolTip.Tip="Rename group">✏ + ToolTip.Tip="{conv:Localize button.rename_group}">✏ + ToolTip.Tip="{conv:Localize button.delete_group}">🗑 @@ -153,7 +153,7 @@ - + - + + + diff --git a/DVModManager/Views/ModListView.axaml.cs b/DVModManager/Views/ModListView.axaml.cs index bc0c22d..e86b945 100644 --- a/DVModManager/Views/ModListView.axaml.cs +++ b/DVModManager/Views/ModListView.axaml.cs @@ -14,9 +14,6 @@ public partial class ModListView : UserControl // Token written to DataTransfer so the drop handler can verify the source private const string DragToken = "dvmm-mod-drag"; - // Static payload: safe because only one drag can be in-flight at a time - private static ModItemViewModel? s_dragPayload; - // ── Styled properties ───────────────────────────────────────────────────── public static readonly StyledProperty HeaderProperty = @@ -59,6 +56,11 @@ public string Filter private Point? _dragOrigin; private PointerPressedEventArgs? _pressedArgs; // Avalonia 12: DoDragDropAsync needs the original pressed args + private ModItemViewModel? _draggedMod; // The mod the pointer actually pressed on + private ModItemViewModel? _pendingSingleSelect; // Deferred clear-and-select (cancelled if drag starts) + + // Static payload holds all mods being dragged — safe because only one drag can be in-flight at a time + private static List? s_dragPayload; public ModListView() { @@ -72,8 +74,9 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) if (lb == null) return; lb.SelectionChanged += OnListSelectionChanged; - lb.AddHandler(PointerPressedEvent, OnListPointerPressed, RoutingStrategies.Tunnel); - lb.AddHandler(PointerMovedEvent, OnListPointerMoved, RoutingStrategies.Tunnel); + lb.AddHandler(PointerPressedEvent, OnListPointerPressed, RoutingStrategies.Tunnel); + lb.AddHandler(PointerMovedEvent, OnListPointerMoved, RoutingStrategies.Tunnel); + lb.AddHandler(PointerReleasedEvent, OnListPointerReleased, RoutingStrategies.Tunnel); lb.AddHandler(DragDrop.DragOverEvent, OnListDragOver); lb.AddHandler(DragDrop.DropEvent, OnListDrop); } @@ -85,8 +88,9 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e if (lb == null) return; lb.SelectionChanged -= OnListSelectionChanged; - lb.RemoveHandler(PointerPressedEvent, OnListPointerPressed); - lb.RemoveHandler(PointerMovedEvent, OnListPointerMoved); + lb.RemoveHandler(PointerPressedEvent, OnListPointerPressed); + lb.RemoveHandler(PointerMovedEvent, OnListPointerMoved); + lb.RemoveHandler(PointerReleasedEvent, OnListPointerReleased); lb.RemoveHandler(DragDrop.DragOverEvent, OnListDragOver); lb.RemoveHandler(DragDrop.DropEvent, OnListDrop); } @@ -111,27 +115,94 @@ private void OnListSelectionChanged(object? sender, SelectionChangedEventArgs e) private void OnListPointerPressed(object? sender, PointerPressedEventArgs e) { - _pressedArgs = null; - _dragOrigin = null; - s_dragPayload = null; + _pressedArgs = null; + _dragOrigin = null; + _draggedMod = null; + _pendingSingleSelect = null; + s_dragPayload = null; if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; var lbItem = (e.Source as Visual)?.FindAncestorOfType(includeSelf: true); - if (lbItem?.DataContext is ModItemViewModel mvm) + + // ── Group header clicked ────────────────────────────────────────────── + if (lbItem?.DataContext is ModGroupHeaderViewModel gvm) + { + if (DataContext is MainWindowViewModel gVm) + { + var groupMods = gVm.AvailableMods.Concat(gVm.ActiveMods) + .Where(m => m.GroupId == gvm.GroupId) + .ToList(); + + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + // CTRL+click group: append/toggle all mods in the group + bool anyUnchecked = groupMods.Any(m => !m.IsChecked); + foreach (var m in groupMods) + m.IsChecked = anyUnchecked; + } + else + { + // Normal click group: clear all, then check all mods in the group + foreach (var m in gVm.AvailableMods.Concat(gVm.ActiveMods)) + m.IsChecked = false; + foreach (var m in groupMods) + m.IsChecked = true; + } + } + return; + } + + // ── Mod item clicked ────────────────────────────────────────────────── + if (lbItem?.DataContext is not ModItemViewModel mvm) return; + + _pressedArgs = e; + _dragOrigin = e.GetPosition(this); + _draggedMod = mvm; + + // CheckBox click: let binding handle the toggle, just set up drag potential + if ((e.Source as Visual)?.FindAncestorOfType(includeSelf: true) != null) return; + + if (DataContext is MainWindowViewModel vm) { - _pressedArgs = e; - _dragOrigin = e.GetPosition(this); - s_dragPayload = mvm; + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + // CTRL+click: toggle this mod without clearing others + mvm.IsChecked = !mvm.IsChecked; + e.Handled = true; + } + else + { + // Normal click: defer clear-and-select-one to PointerReleased + // so an in-progress drag doesn't lose the multi-selection + _pendingSingleSelect = mvm; + } + } + } + + private void OnListPointerReleased(object? sender, PointerReleasedEventArgs e) + { + var pending = _pendingSingleSelect; + _pendingSingleSelect = null; + + // Only apply if no drag was initiated + if (pending == null || s_dragPayload != null) return; + + if (DataContext is MainWindowViewModel vm) + { + foreach (var m in vm.AvailableMods.Concat(vm.ActiveMods)) + m.IsChecked = false; + pending.IsChecked = true; } } private async void OnListPointerMoved(object? sender, PointerEventArgs e) { - if (s_dragPayload == null || _dragOrigin == null || _pressedArgs == null) return; + if (_draggedMod == null || _dragOrigin == null || _pressedArgs == null) return; if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { s_dragPayload = null; + _draggedMod = null; _dragOrigin = null; _pressedArgs = null; return; @@ -140,10 +211,25 @@ private async void OnListPointerMoved(object? sender, PointerEventArgs e) var delta = e.GetPosition(this) - _dragOrigin.Value; if (Math.Abs(delta.X) < 8 && Math.Abs(delta.Y) < 8) return; - // Threshold exceeded — kick off system DnD + // Threshold exceeded — resolve payload: all checked mods if the dragged mod is checked + _pendingSingleSelect = null; // drag started, cancel deferred single-select + if (DataContext is MainWindowViewModel vm) + { + var checkedMods = vm.AvailableMods.Concat(vm.ActiveMods).Where(m => m.IsChecked).ToList(); + s_dragPayload = checkedMods.Contains(_draggedMod) && checkedMods.Count > 0 + ? checkedMods + : [_draggedMod]; + } + else + { + s_dragPayload = [_draggedMod]; + } + + // Kick off system DnD var pressedArgs = _pressedArgs; _pressedArgs = null; _dragOrigin = null; + _draggedMod = null; // s_dragPayload stays set until DoDragDropAsync completes var item = DataTransferItem.CreateText(DragToken); @@ -169,16 +255,16 @@ private void OnListDragOver(object? sender, DragEventArgs e) private void OnListDrop(object? sender, DragEventArgs e) { if (e.DataTransfer.TryGetText() != DragToken) return; - var mod = s_dragPayload; + var payload = s_dragPayload; s_dragPayload = null; - if (mod == null) return; + if (payload == null || payload.Count == 0) return; // Walk up from the drop source to find a ListBoxItem var lbItem = (e.Source as Visual)?.FindAncestorOfType(includeSelf: true); string? groupId = lbItem?.DataContext is ModGroupHeaderViewModel hvm ? hvm.GroupId : null; if (DataContext is MainWindowViewModel vm) - _ = vm.AssignModToGroupAsync(mod.Id, groupId); + _ = vm.AssignModsToGroupAsync(payload.Select(m => m.Id), groupId); e.Handled = true; } diff --git a/DVModManager/Views/ProfileDialog.axaml b/DVModManager/Views/ProfileDialog.axaml index 07d6003..cdf76c4 100644 --- a/DVModManager/Views/ProfileDialog.axaml +++ b/DVModManager/Views/ProfileDialog.axaml @@ -5,7 +5,7 @@ xmlns:conv="using:DVModManager.Converters" x:Class="DVModManager.Views.ProfileDialog" x:DataType="vm:ProfileViewModel" - Title="Manage Profiles" + Title="{conv:Localize profile.dialog_title}" Width="500" Height="480" WindowStartupLocation="CenterOwner" CanResize="False" @@ -23,7 +23,7 @@ - @@ -72,7 +72,7 @@ Spacing="8" Margin="0,30,0,0"> -