diff --git a/Form1.cs b/Form1.cs index 62918e9..947c49d 100644 --- a/Form1.cs +++ b/Form1.cs @@ -76,7 +76,7 @@ public Form1() private void Settings_Click(object? sender, EventArgs e) { - using (var dlg = new SettingsForm()) + using (var dlg = new SettingsForm(backupManager, () => { BeginInvoke(() => CheckForMediaAndIdentify()); })) { if (dlg.ShowDialog(this) == DialogResult.OK) { @@ -84,60 +84,7 @@ private void Settings_Click(object? sender, EventArgs e) } } - // Additionally offer per-media assignment dialog - ShowMediaAssignmentDialog(); - } - - private void ShowMediaAssignmentDialog() - { - try - { - var drives = DriveInfo.GetDrives().Where(d => d.IsReady && d.DriveType == DriveType.Removable).ToList(); - if (drives.Count == 0) - { - MessageBox.Show("No removable drives detected to assign.", "Assign Media", MessageBoxButtons.OK, MessageBoxIcon.Information); - return; - } - - using (var frm = new Form { Text = "Assign Media to System", Width = 480, Height = 200, StartPosition = FormStartPosition.CenterParent }) - { - var comboDrive = new ComboBox { Left = 10, Top = 10, Width = 420, DropDownStyle = ComboBoxStyle.DropDownList }; - foreach (var d in drives) comboDrive.Items.Add(d.RootDirectory.FullName); - comboDrive.SelectedIndex = 0; - frm.Controls.Add(comboDrive); - - var comboSystem = new ComboBox { Left = 10, Top = 45, Width = 300, DropDownStyle = ComboBoxStyle.DropDownList }; - var systems = new[] { "NintendoDS", "GameBoyAdvance", "GameBoyColor", "GameBoy", "Unknown" }; - foreach (var s in systems) comboSystem.Items.Add(s); - comboSystem.SelectedIndex = 0; - frm.Controls.Add(comboSystem); - - var btnAssign = new Button { Text = "Assign", Left = 320, Top = 45, Width = 110 }; - btnAssign.Click += (s, e) => - { - var root = comboDrive.SelectedItem as string; - var system = comboSystem.SelectedItem as string; - if (!string.IsNullOrEmpty(root)) - { - if (string.IsNullOrEmpty(system) || system == "Unknown") - SettingsManager.SetAssignedSystemForRoot(root, null); - else - SettingsManager.SetAssignedSystemForRoot(root, system); - - MessageBox.Show($"Assigned {root} => {system}", "Assigned", MessageBoxButtons.OK, MessageBoxIcon.Information); - frm.DialogResult = DialogResult.OK; - frm.Close(); - } - }; - frm.Controls.Add(btnAssign); - - frm.ShowDialog(this); - } - } - catch (Exception ex) - { - MessageBox.Show(ex.Message, "Assign Media Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - } + // Previously opened a separate assignment dialog here; assignments are now managed inside SettingsForm. } private void UpdateLogoVisibility() @@ -287,32 +234,78 @@ private void CheckForMediaAndIdentify() if (unknownDrives.Count > 0) { - // Show dialog to identify the media - using (var dlg = new SystemIdentificationDialog()) + var drive = unknownDrives[0]; + var rootPath = drive.RootDirectory.FullName; + + // 1) check explicit assignments + var assigned = SettingsManager.GetAssignedSystemForRoot(rootPath); + if (!string.IsNullOrEmpty(assigned)) { - // If previously assigned, pre-select the system and skip dialog - var assigned = SettingsManager.GetAssignedSystemForRoot(unknownDrives[0].RootDirectory.FullName); - if (!string.IsNullOrEmpty(assigned)) + lastDetectedUnknownSource = new GenericMediaSource(rootPath, assigned, assigned); + _lastCartColor = null; + RefreshStatus(); + btnBackupNow.Enabled = true; + RefreshLogView(); + UpdateLogoVisibility(); + return; + } + + // 2) auto-assign by volume label + try + { + if (SettingsManager.GetAutoAssignByVolumeLabel()) { - lastDetectedUnknownSource = new GenericMediaSource( - unknownDrives[0].RootDirectory.FullName, - assigned, - assigned - ); + var vol = drive.VolumeLabel ?? string.Empty; + var rules = SettingsManager.GetVolumeLabelRules(); + foreach (var kvp in rules) + { + if (!string.IsNullOrEmpty(kvp.Key) && vol.IndexOf(kvp.Key, StringComparison.OrdinalIgnoreCase) >= 0) + { + lastDetectedUnknownSource = new GenericMediaSource(rootPath, kvp.Value, kvp.Value); + SettingsManager.SetAssignedSystemForRoot(rootPath, kvp.Value); + RefreshStatus(); + btnBackupNow.Enabled = true; + RefreshLogView(); + UpdateLogoVisibility(); + return; + } + } + } + } + catch { } - _lastCartColor = null; - RefreshStatus(); - btnBackupNow.Enabled = true; - RefreshLogView(); - UpdateLogoVisibility(); - return; + // 3) auto-assign by marker files + try + { + if (SettingsManager.GetAutoAssignByMarkers()) + { + var markers = SettingsManager.GetMarkerRules(); + foreach (var kvp in markers) + { + var markerPath = Path.Combine(rootPath, kvp.Key); + if (File.Exists(markerPath)) + { + lastDetectedUnknownSource = new GenericMediaSource(rootPath, kvp.Value, kvp.Value); + SettingsManager.SetAssignedSystemForRoot(rootPath, kvp.Value); + RefreshStatus(); + btnBackupNow.Enabled = true; + RefreshLogView(); + UpdateLogoVisibility(); + return; + } + } } + } + catch { } + // 4) fallback to manual identification dialog + using (var dlg = new SystemIdentificationDialog()) + { if (dlg.ShowDialog(this) == DialogResult.OK) { // Create GenericMediaSource with system type information lastDetectedUnknownSource = new GenericMediaSource( - unknownDrives[0].RootDirectory.FullName, + rootPath, dlg.SelectedSystemType, // Pass system type dlg.SelectedSystemType // Use as volume label for display ); @@ -328,7 +321,7 @@ private void CheckForMediaAndIdentify() StoreMediaMetadata(dlg.SelectedSystemType, dlg.CartColor, dlg.CartNickname); // Persist assignment for this media - SettingsManager.SetAssignedSystemForRoot(unknownDrives[0].RootDirectory.FullName, dlg.SelectedSystemType); + SettingsManager.SetAssignedSystemForRoot(rootPath, dlg.SelectedSystemType); RefreshLogView(); UpdateLogoVisibility(); diff --git a/GBACartBackup.csproj b/GBACartBackup.csproj index b5eccf6..c8e44db 100644 --- a/GBACartBackup.csproj +++ b/GBACartBackup.csproj @@ -13,4 +13,8 @@ + + + + \ No newline at end of file diff --git a/GBACartBackup/GenericMediaSource.cs b/GBACartBackup/GenericMediaSource.cs index b5efc2f..3b318c9 100644 --- a/GBACartBackup/GenericMediaSource.cs +++ b/GBACartBackup/GenericMediaSource.cs @@ -8,7 +8,7 @@ internal class GenericMediaSource : IMediaSource { private readonly string? root; private readonly string? label; - private readonly SaveFileLocator.SystemType systemType; + private SaveFileLocator.SystemType systemType; public GenericMediaSource() { @@ -65,7 +65,7 @@ public string VolumeLabel // Prefer a specific drive letter (E:) when present, as many users' cards mount there var preferredLetter = "E:"; - var preferred = drives.FirstOrDefault(d => string.Equals(Path.GetPathRoot(d.RootDirectory.FullName)?.TrimEnd('\\'), preferredLetter, StringComparison.OrdinalIgnoreCase)); + var preferred = drives.FirstOrDefault(d => string.Equals(Path.GetPathRoot(d.RootDirectory.FullName)?.TrimEnd(Path.DirectorySeparatorChar), preferredLetter, StringComparison.OrdinalIgnoreCase)); if (preferred != null) { try { return preferred.RootDirectory.FullName; } catch { } @@ -102,6 +102,22 @@ public string[] GetSaveFiles() return SaveFileLocator.FindSaveFiles(r, systemType); } + // Try to determine from persisted assignments if available + try + { + var assigned = SettingsManager.GetAssignedSystemForRoot(r); + if (!string.IsNullOrEmpty(assigned)) + { + var parsed = SaveFileLocator.ParseSystemType(assigned); + if (parsed != SaveFileLocator.SystemType.Unknown) + { + systemType = parsed; + return SaveFileLocator.FindSaveFiles(r, systemType); + } + } + } + catch { } + // Fallback: return top-level files return Directory.GetFiles(r, "*.*", SearchOption.TopDirectoryOnly); } diff --git a/GBACartBackup/SaveFileLocator.cs b/GBACartBackup/SaveFileLocator.cs index 9e97622..62c08dd 100644 --- a/GBACartBackup/SaveFileLocator.cs +++ b/GBACartBackup/SaveFileLocator.cs @@ -31,16 +31,28 @@ public enum SystemType /// public static SystemType ParseSystemType(string? systemTypeString) { - return systemTypeString?.ToLower() switch + if (string.IsNullOrWhiteSpace(systemTypeString)) + return SystemType.Unknown; + + // Normalize: lower-case, remove spaces, hyphens and underscores + var normalized = new string(systemTypeString.ToLower().Where(c => !char.IsWhiteSpace(c) && c != '-' && c != '_').ToArray()); + + return normalized switch { - "game boy advance" => SystemType.GameBoyAdvance, - "nintendo ds" => SystemType.NintendoDS, - "game boy color" => SystemType.GameBoyColor, - "game boy" => SystemType.GameBoy, - "nintendo 64" => SystemType.Nintendo64, + "gameboyadvance" => SystemType.GameBoyAdvance, + "gba" => SystemType.GameBoyAdvance, + "nintendods" => SystemType.NintendoDS, + "nds" => SystemType.NintendoDS, + "nintendodsi" => SystemType.NintendoDS, + "dsi" => SystemType.NintendoDS, + "gameboycolor" => SystemType.GameBoyColor, + "gbc" => SystemType.GameBoyColor, + "gameboy" => SystemType.GameBoy, + "nintendo64" => SystemType.Nintendo64, + "n64" => SystemType.Nintendo64, "snes" => SystemType.SNES, "nes" => SystemType.NES, - "ps vita" => SystemType.PSVita, + "psvita" => SystemType.PSVita, "psp" => SystemType.PSP, "switch" => SystemType.Switch, _ => SystemType.Unknown @@ -78,10 +90,61 @@ public static string[] FindSaveFiles(string rootPath, SystemType system) } } + private static string? FindConfiguredDirectory(string rootPath, string relPath) + { + try + { + if (string.IsNullOrWhiteSpace(relPath)) return null; + + // Try direct combine first + var cfgPath = Path.Combine(rootPath, relPath); + if (Directory.Exists(cfgPath)) return cfgPath; + + // Normalize separators and relative path + var relNormalized = relPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) + .TrimEnd(Path.DirectorySeparatorChar); + + // Search all directories and find one whose relative path ends with the configured relative path (case-insensitive) + foreach (var dir in Directory.EnumerateDirectories(rootPath, "*", SearchOption.AllDirectories)) + { + try + { + var rel = Path.GetRelativePath(rootPath, dir) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) + .TrimEnd(Path.DirectorySeparatorChar); + + if (rel.EndsWith(relNormalized, StringComparison.OrdinalIgnoreCase)) + return dir; + } + catch { } + } + } + catch { } + + return null; + } + + private static IEnumerable SearchConfiguredRelativeRecursive(string rootPath, string relPath, string[] patterns) + { + var results = new List(); + try + { + var cfgDir = FindConfiguredDirectory(rootPath, relPath); + if (string.IsNullOrEmpty(cfgDir)) return results; + foreach (var p in patterns) + { + results.AddRange(Directory.GetFiles(cfgDir, p, SearchOption.AllDirectories)); + } + } + catch { } + return results; + } + /// /// Nintendo DS saves - typically .sav files in root or /Saves folder /// Also checks for flashcart structures (TTMENU.DAT, etc) - /// This version also consults user-configured default save paths from settings. + /// This version also consults user-configured default save paths from settings and searches recursively. /// private static string[] FindDSSaves(string rootPath) { @@ -89,19 +152,15 @@ private static string[] FindDSSaves(string rootPath) try { - // Check user-configured default path first (relative to root) + // Prefer user-configured default path if it yields results try { var configured = SettingsManager.GetDefaultSavePathFor("NintendoDS"); if (!string.IsNullOrWhiteSpace(configured)) { - var cfgPath = Path.Combine(rootPath, configured); - if (Directory.Exists(cfgPath)) - { - results.AddRange(Directory.GetFiles(cfgPath, "*.sav", SearchOption.TopDirectoryOnly)); - results.AddRange(Directory.GetFiles(cfgPath, "*.dsv", SearchOption.TopDirectoryOnly)); - results.AddRange(Directory.GetFiles(cfgPath, "*.nds", SearchOption.TopDirectoryOnly)); - } + var cfgResults = SearchConfiguredRelativeRecursive(rootPath, configured, new[] { "*.sav", "*.dsv", "*.nds" }).ToList(); + if (cfgResults.Count > 0) + return cfgResults.Distinct().ToArray(); } } catch { } @@ -111,22 +170,25 @@ private static string[] FindDSSaves(string rootPath) results.AddRange(Directory.GetFiles(rootPath, "*.dsv", SearchOption.TopDirectoryOnly)); results.AddRange(Directory.GetFiles(rootPath, "*.nds", SearchOption.TopDirectoryOnly)); - // Check for common DS flashcart folders + // Check for common DS flashcart folders (top-level) var savesDirs = new[] { "Saves", "saves", "SAVES", "roms", "ROMS", "games", "GAMES" }; foreach (var dir in savesDirs) { var path = Path.Combine(rootPath, dir); if (Directory.Exists(path)) { - results.AddRange(Directory.GetFiles(path, "*.sav", SearchOption.TopDirectoryOnly)); - results.AddRange(Directory.GetFiles(path, "*.dsv", SearchOption.TopDirectoryOnly)); + results.AddRange(Directory.GetFiles(path, "*.sav", SearchOption.AllDirectories)); + results.AddRange(Directory.GetFiles(path, "*.dsv", SearchOption.AllDirectories)); } } - // Check for TTMENU.DAT and related flashcart files + // Check for TTMENU.DAT and related flashcart file presence var ttmenuPath = Path.Combine(rootPath, "TTMENU.DAT"); if (File.Exists(ttmenuPath)) results.Add(ttmenuPath); + + // Fallback: recursive search for .sav + results.AddRange(Directory.GetFiles(rootPath, "*.sav", SearchOption.AllDirectories)); } catch { } @@ -135,6 +197,7 @@ private static string[] FindDSSaves(string rootPath) /// /// Game Boy Advance saves - typically .sav files in /SAVEGAME or root + /// This version will also check configured default path recursively. /// private static string[] FindGBASaves(string rootPath) { @@ -142,16 +205,28 @@ private static string[] FindGBASaves(string rootPath) try { - // Check SAVEGAME folder first - var savegameDir = Path.Combine(rootPath, "SAVEGAME"); - if (Directory.Exists(savegameDir)) + // Prefer configured path + try { - results.AddRange(Directory.GetFiles(savegameDir, "*.sav", SearchOption.TopDirectoryOnly)); + var configured = SettingsManager.GetDefaultSavePathFor("GameBoyAdvance"); + if (!string.IsNullOrWhiteSpace(configured)) + { + var cfg = SearchConfiguredRelativeRecursive(rootPath, configured, new[] { "*.sav", "*.srm" }); + if (cfg.Any()) return cfg.Distinct().ToArray(); + } } + catch { } + + // Check common SAVEGAME folder + var savegameDir = Path.Combine(rootPath, "SAVEGAME"); + if (Directory.Exists(savegameDir)) results.AddRange(Directory.GetFiles(savegameDir, "*.sav", SearchOption.TopDirectoryOnly)); - // Check root + // Root results.AddRange(Directory.GetFiles(rootPath, "*.sav", SearchOption.TopDirectoryOnly)); results.AddRange(Directory.GetFiles(rootPath, "*.srm", SearchOption.TopDirectoryOnly)); + + // Fallback recursive + results.AddRange(Directory.GetFiles(rootPath, "*.sav", SearchOption.AllDirectories)); } catch { } @@ -167,7 +242,6 @@ private static string[] FindPSVitaSaves(string rootPath) try { - // PS Vita AppData location var appDataDirs = new[] { "ux0:app", "ux0:user", "ur0:app", "app", "user", "APP" }; foreach (var dir in appDataDirs) { @@ -179,7 +253,6 @@ private static string[] FindPSVitaSaves(string rootPath) } } - // Check for VPK files (game packages) results.AddRange(Directory.GetFiles(rootPath, "*.vpk", SearchOption.AllDirectories)); } catch { } @@ -200,14 +273,9 @@ private static string[] FindPSPSaves(string rootPath) foreach (var dir in savedataDirs) { var path = Path.Combine(rootPath, dir); - if (Directory.Exists(path)) - { - results.AddRange(Directory.GetFiles(path, "*.*", SearchOption.AllDirectories) - .Where(f => IsSaveFile(f))); - } + if (Directory.Exists(path)) results.AddRange(Directory.GetFiles(path, "*.*", SearchOption.AllDirectories).Where(f => IsSaveFile(f))); } - // Direct .sav, .psv files results.AddRange(Directory.GetFiles(rootPath, "*.sav", SearchOption.TopDirectoryOnly)); results.AddRange(Directory.GetFiles(rootPath, "*.psv", SearchOption.TopDirectoryOnly)); } diff --git a/GBACartBackup/SettingsForm.cs b/GBACartBackup/SettingsForm.cs index e05f786..19da932 100644 --- a/GBACartBackup/SettingsForm.cs +++ b/GBACartBackup/SettingsForm.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Windows.Forms; +using System.IO; +using System.Diagnostics; namespace GBACartBackup { @@ -9,30 +11,68 @@ public class SettingsForm : Form { private readonly Dictionary textBoxes = new(); - public SettingsForm() + public SettingsForm(BackupManager? manager, Action? rescanAction = null) { - this.Text = "Settings"; - this.Width = 500; - this.Height = 300; - this.StartPosition = FormStartPosition.CenterParent; + Text = "Settings"; + Width = 760; + Height = 540; + StartPosition = FormStartPosition.CenterParent; - var panel = new Panel { Dock = DockStyle.Fill, AutoScroll = true, Padding = new Padding(10) }; - this.Controls.Add(panel); + var tabs = new TabControl { Dock = DockStyle.Fill }; + Controls.Add(tabs); - var systems = new[] { "NintendoDS", "GameBoyAdvance", "GameBoyColor", "GameBoy" }; + // ---- General tab ---- + var tabGeneral = new TabPage("General"); + tabs.TabPages.Add(tabGeneral); + + var lblAuto = new Label { Text = "Auto-assignment", Left = 10, Top = 10, AutoSize = true }; + tabGeneral.Controls.Add(lblAuto); + + var chkAutoVolume = new CheckBox { Text = "Auto-assign by volume label", Left = 10, Top = 36, Width = 300 }; + chkAutoVolume.Checked = SettingsManager.GetAutoAssignByVolumeLabel(); + tabGeneral.Controls.Add(chkAutoVolume); + + var chkAutoMarker = new CheckBox { Text = "Auto-assign by marker files", Left = 10, Top = 64, Width = 300 }; + chkAutoMarker.Checked = SettingsManager.GetAutoAssignByMarkers(); + tabGeneral.Controls.Add(chkAutoMarker); + + var btnOpenBackups = new Button { Text = "Open Backups Folder", Left = 10, Top = 100, Width = 160 }; + btnOpenBackups.Click += (s, e) => + { + try + { + if (manager != null) + { + var path = manager.GetDestination(); + if (!Directory.Exists(path)) Directory.CreateDirectory(path); + Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true }); + } + } + catch { } + }; + tabGeneral.Controls.Add(btnOpenBackups); + + var btnRescan = new Button { Text = "Rescan Now", Left = 180, Top = 100, Width = 120 }; + btnRescan.Click += (s, e) => { try { rescanAction?.Invoke(); } catch { } }; + tabGeneral.Controls.Add(btnRescan); - int y = 0; + // Default save paths area + var grpPaths = new GroupBox { Text = "Default save paths (relative to media root)", Left = 10, Top = 140, Width = 710, Height = 260 }; + tabGeneral.Controls.Add(grpPaths); + + var systems = new[] { "NintendoDS", "GameBoyAdvance", "GameBoyColor", "GameBoy" }; + int y = 20; foreach (var sys in systems) { - var lbl = new Label { Text = sys + " default save path (relative to media root):", AutoSize = true, Left = 10, Top = y + 6 }; - panel.Controls.Add(lbl); + var lbl = new Label { Text = sys + ":", Left = 10, Top = y + 6, AutoSize = true }; + grpPaths.Controls.Add(lbl); - var txt = new TextBox { Left = 10, Top = y + 28, Width = 420 }; + var txt = new TextBox { Left = 120, Top = y, Width = 460 }; txt.Text = SettingsManager.GetDefaultSavePathFor(sys); - panel.Controls.Add(txt); + grpPaths.Controls.Add(txt); textBoxes[sys] = txt; - var btnBrowse = new Button { Text = "Browse...", Left = 440, Top = y + 26, Width = 60 }; + var btnBrowse = new Button { Text = "Browse...", Left = 590, Top = y - 2, Width = 100 }; btnBrowse.Click += (s, e) => { using (var dlg = new FolderBrowserDialog()) @@ -40,47 +80,282 @@ public SettingsForm() dlg.Description = "Select folder relative to the media root (select a folder on a sample mounted drive to capture path)."; if (dlg.ShowDialog(this) == DialogResult.OK) { - // store the selected absolute path; convert to relative by trimming drive root if possible var path = dlg.SelectedPath; - // simple heuristic: if path contains a drive root like C:\, take the subpath after the drive root try { - var root = System.IO.Path.GetPathRoot(path); + var root = Path.GetPathRoot(path); if (!string.IsNullOrEmpty(root) && path.StartsWith(root, StringComparison.OrdinalIgnoreCase)) { - var rel = path.Substring(root.Length).TrimStart(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); - textBoxes[sys].Text = rel.Replace(System.IO.Path.DirectorySeparatorChar, '\\'); + var rel = path.Substring(root.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + txt.Text = rel.Replace(Path.DirectorySeparatorChar, Path.DirectorySeparatorChar); } else { - textBoxes[sys].Text = path; + txt.Text = path; } } - catch { textBoxes[sys].Text = path; } + catch { txt.Text = path; } } } }; - panel.Controls.Add(btnBrowse); + grpPaths.Controls.Add(btnBrowse); + + y += 40; + } + + // ---- Assignments tab ---- + var tabAssign = new TabPage("Assignments"); + tabs.TabPages.Add(tabAssign); + + var assignList = new ListView { Left = 10, Top = 10, Width = 620, Height = 300, View = View.Details, FullRowSelect = true }; + assignList.Columns.Add("Media Root", 320); + assignList.Columns.Add("Assigned System", 280); + tabAssign.Controls.Add(assignList); - y += 70; + var btnRemoveAssign = new Button { Text = "Remove", Left = 640, Top = 10, Width = 90 }; + btnRemoveAssign.Click += (s, e) => + { + if (assignList.SelectedItems.Count > 0) + { + var root = assignList.SelectedItems[0].Text; + SettingsManager.SetAssignedSystemForRoot(root, null); + assignList.Items.Remove(assignList.SelectedItems[0]); + } + }; + tabAssign.Controls.Add(btnRemoveAssign); + + // Add assignment controls + var comboDrive = new ComboBox { Left = 10, Top = 320, Width = 320, DropDownStyle = ComboBoxStyle.DropDownList }; + try + { + foreach (var d in DriveInfo.GetDrives().Where(d => d.IsReady && d.DriveType == DriveType.Removable)) + comboDrive.Items.Add(d.RootDirectory.FullName); + if (comboDrive.Items.Count > 0) comboDrive.SelectedIndex = 0; } + catch { } + tabAssign.Controls.Add(comboDrive); + + var comboSystem = new ComboBox { Left = 340, Top = 320, Width = 200, DropDownStyle = ComboBoxStyle.DropDownList }; + var systemsList = new[] { "NintendoDS", "GameBoyAdvance", "GameBoyColor", "GameBoy", "Unknown" }; + foreach (var s in systemsList) comboSystem.Items.Add(s); + comboSystem.SelectedIndex = 0; + tabAssign.Controls.Add(comboSystem); + + var btnAddAssign = new Button { Text = "Add Assignment", Left = 560, Top = 320, Width = 120 }; + btnAddAssign.Click += (s, e) => + { + var root = comboDrive.SelectedItem as string; + var sys = comboSystem.SelectedItem as string; + if (string.IsNullOrWhiteSpace(root) || string.IsNullOrWhiteSpace(sys)) + { + MessageBox.Show("Select a drive and a system to assign.", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + SettingsManager.SetAssignedSystemForRoot(root!, sys == "Unknown" ? null : sys); + var existing = assignList.Items.Cast().FirstOrDefault(i => string.Equals(i.Text, root, StringComparison.OrdinalIgnoreCase)); + if (existing != null) existing.SubItems[1].Text = sys!; else assignList.Items.Add(new ListViewItem(new[] { root!, sys! })); + }; + tabAssign.Controls.Add(btnAddAssign); + + // populate assignments + try + { + var assigns = SettingsManager.Load().MediaAssignments ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in assigns) + assignList.Items.Add(new ListViewItem(new[] { kvp.Key, kvp.Value })); + } + catch { } + + // ---- Rules tab ---- + var tabRules = new TabPage("Rules"); + tabs.TabPages.Add(tabRules); + + var lblMarkers = new Label { Text = "Marker rules (filename => system):", Left = 10, Top = 10, AutoSize = true }; + tabRules.Controls.Add(lblMarkers); + + var markerList = new ListView { Left = 10, Top = 34, Width = 620, Height = 160, View = View.Details, FullRowSelect = true }; + markerList.Columns.Add("Marker File", 320); + markerList.Columns.Add("System", 300); + tabRules.Controls.Add(markerList); + + var btnAddMarker = new Button { Text = "Add", Left = 640, Top = 34, Width = 80 }; + btnAddMarker.Click += (s, e) => + { + using (var dlg = new AddRuleDialog("Add Marker Rule", "Marker filename (e.g. TTMENU.DAT)", "System key (e.g. NintendoDS)")) + { + if (dlg.ShowDialog(this) == DialogResult.OK) + { + if (string.IsNullOrWhiteSpace(dlg.ValueA) || string.IsNullOrWhiteSpace(dlg.ValueB)) MessageBox.Show("Marker and system cannot be empty."); + else if (markerList.Items.Cast().Any(i => string.Equals(i.SubItems[0].Text, dlg.ValueA, StringComparison.OrdinalIgnoreCase))) MessageBox.Show("Marker already exists."); + else markerList.Items.Add(new ListViewItem(new[] { dlg.ValueA, dlg.ValueB })); + } + } + }; + tabRules.Controls.Add(btnAddMarker); + + var btnRemoveMarker = new Button { Text = "Remove", Left = 640, Top = 74, Width = 80 }; + btnRemoveMarker.Click += (s, e) => { if (markerList.SelectedItems.Count > 0) markerList.Items.Remove(markerList.SelectedItems[0]); }; + tabRules.Controls.Add(btnRemoveMarker); + + var lblVolumes = new Label { Text = "Volume label rules (substring => system):", Left = 10, Top = 210, AutoSize = true }; + tabRules.Controls.Add(lblVolumes); + + var volList = new ListView { Left = 10, Top = 236, Width = 620, Height = 160, View = View.Details, FullRowSelect = true }; + volList.Columns.Add("Label Substring", 320); + volList.Columns.Add("System", 300); + tabRules.Controls.Add(volList); - var btnSave = new Button { Text = "Save", Left = 320, Width = 80, Top = y + 10 }; + var btnAddVol = new Button { Text = "Add", Left = 640, Top = 236, Width = 80 }; + btnAddVol.Click += (s, e) => + { + using (var dlg = new AddRuleDialog("Add Volume Label Rule", "Label substring (e.g. NDS)", "System key (e.g. NintendoDS)")) + { + if (dlg.ShowDialog(this) == DialogResult.OK) + { + if (string.IsNullOrWhiteSpace(dlg.ValueA) || string.IsNullOrWhiteSpace(dlg.ValueB)) MessageBox.Show("Label and system cannot be empty."); + else if (volList.Items.Cast().Any(i => string.Equals(i.SubItems[0].Text, dlg.ValueA, StringComparison.OrdinalIgnoreCase))) MessageBox.Show("Label rule already exists."); + else volList.Items.Add(new ListViewItem(new[] { dlg.ValueA, dlg.ValueB })); + } + } + }; + tabRules.Controls.Add(btnAddVol); + + var btnRemoveVol = new Button { Text = "Remove", Left = 640, Top = 276, Width = 80 }; + btnRemoveVol.Click += (s, e) => { if (volList.SelectedItems.Count > 0) volList.Items.Remove(volList.SelectedItems[0]); }; + tabRules.Controls.Add(btnRemoveVol); + + // populate rules + try + { + var markers = SettingsManager.GetMarkerRules(); + foreach (var kvp in markers) markerList.Items.Add(new ListViewItem(new[] { kvp.Key, kvp.Value })); + + var vols = SettingsManager.GetVolumeLabelRules(); + foreach (var kvp in vols) volList.Items.Add(new ListViewItem(new[] { kvp.Key, kvp.Value })); + } + catch { } + + // ---- Backups tab ---- + var tabBackups = new TabPage("Backups"); + tabs.TabPages.Add(tabBackups); + + var backedList = new ListView { Left = 10, Top = 10, Width = 720, Height = 420, View = View.Details, FullRowSelect = true }; + backedList.Columns.Add("System", 360); + backedList.Columns.Add("Last Backup", 340); + tabBackups.Controls.Add(backedList); + + if (manager != null) + { + try + { + var dest = manager.GetDestination(); + var root = Path.Combine(dest, "handheldsaves"); + if (Directory.Exists(root)) + { + var dirs = Directory.GetDirectories(root); + foreach (var dir in dirs) + { + var systemName = Path.GetFileName(dir); + DateTime last = DateTime.MinValue; + try + { + var files = Directory.GetFiles(dir, "*", SearchOption.AllDirectories); + if (files.Length > 0) last = files.Select(f => File.GetLastWriteTime(f)).Max(); else last = Directory.GetLastWriteTime(dir); + } + catch { last = Directory.GetLastWriteTime(dir); } + + var lvi = new ListViewItem(systemName); + lvi.SubItems.Add(last == DateTime.MinValue ? "-" : last.ToString("yyyy-MM-dd HH:mm")); + backedList.Items.Add(lvi); + } + } + } + catch { } + } + + // Save / Cancel buttons at bottom-right + var btnSave = new Button { Text = "Save", Width = 100, Left = ClientSize.Width - 220, Top = ClientSize.Height - 50, Anchor = AnchorStyles.Bottom | AnchorStyles.Right }; btnSave.Click += (s, e) => { - foreach (var kvp in textBoxes) + // Validate marker rules and volume rules + var markerKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (ListViewItem it in markerList.Items) + { + var key = it.SubItems[0].Text.Trim(); + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(it.SubItems[1].Text.Trim())) { MessageBox.Show("Marker rules must have both filename and system."); return; } + if (!markerKeys.Add(key)) { MessageBox.Show($"Duplicate marker: {key}"); return; } + } + var volKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (ListViewItem it in volList.Items) { - SettingsManager.SetDefaultSavePathFor(kvp.Key, kvp.Value.Text ?? string.Empty); + var key = it.SubItems[0].Text.Trim(); + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(it.SubItems[1].Text.Trim())) { MessageBox.Show("Volume label rules must have both label and system."); return; } + if (!volKeys.Add(key)) { MessageBox.Show($"Duplicate volume label rule: {key}"); return; } } - this.DialogResult = DialogResult.OK; - this.Close(); + + // save auto-assign toggles + SettingsManager.SetAutoAssignByVolumeLabel(chkAutoVolume.Checked); + SettingsManager.SetAutoAssignByMarkers(chkAutoMarker.Checked); + + // save marker rules + var markerRules = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (ListViewItem it in markerList.Items) markerRules[it.SubItems[0].Text] = it.SubItems[1].Text; + SettingsManager.SetMarkerRules(markerRules); + + // save volume rules + var volRules = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (ListViewItem it in volList.Items) volRules[it.SubItems[0].Text] = it.SubItems[1].Text; + SettingsManager.SetVolumeLabelRules(volRules); + + // save default paths + foreach (var kvp in textBoxes) SettingsManager.SetDefaultSavePathFor(kvp.Key, kvp.Value.Text ?? string.Empty); + + DialogResult = DialogResult.OK; + Close(); }; + Controls.Add(btnSave); + + var btnCancel = new Button { Text = "Cancel", Width = 100, Left = ClientSize.Width - 110, Top = ClientSize.Height - 50, Anchor = AnchorStyles.Bottom | AnchorStyles.Right }; + btnCancel.Click += (s, e) => { DialogResult = DialogResult.Cancel; Close(); }; + Controls.Add(btnCancel); + + // Adjust layout of buttons after control creation + this.Load += (s, e) => + { + btnSave.Left = ClientSize.Width - 220; + btnSave.Top = ClientSize.Height - 50; + btnCancel.Left = ClientSize.Width - 110; + btnCancel.Top = ClientSize.Height - 50; + }; + } + } + + // Small helper dialog to add simple key/value rules + public class AddRuleDialog : Form + { + public string ValueA { get; private set; } = string.Empty; + public string ValueB { get; private set; } = string.Empty; + + public AddRuleDialog(string title, string promptA, string promptB) + { + Text = title; + Width = 420; + Height = 180; + StartPosition = FormStartPosition.CenterParent; + + var lblA = new Label { Text = promptA, Left = 10, Top = 10, Width = 380 }; + var txtA = new TextBox { Left = 10, Top = 32, Width = 380 }; + var lblB = new Label { Text = promptB, Left = 10, Top = 60, Width = 380 }; + var txtB = new TextBox { Left = 10, Top = 82, Width = 380 }; + + var btnOk = new Button { Text = "OK", Left = 220, Top = 112, Width = 80 }; + btnOk.Click += (s, e) => { ValueA = txtA.Text ?? string.Empty; ValueB = txtB.Text ?? string.Empty; DialogResult = DialogResult.OK; Close(); }; - var btnCancel = new Button { Text = "Cancel", Left = 410, Width = 80, Top = y + 10 }; - btnCancel.Click += (s, e) => { this.DialogResult = DialogResult.Cancel; this.Close(); }; + var btnCancel = new Button { Text = "Cancel", Left = 310, Top = 112, Width = 80 }; + btnCancel.Click += (s, e) => { DialogResult = DialogResult.Cancel; Close(); }; - panel.Controls.Add(btnSave); - panel.Controls.Add(btnCancel); + Controls.AddRange(new Control[] { lblA, txtA, lblB, txtB, btnOk, btnCancel }); } } } diff --git a/README.md b/README.md index 329087a..a1b0564 100644 --- a/README.md +++ b/README.md @@ -1 +1,49 @@ -# GBACartBackup \ No newline at end of file +# GBACartBackup + +**NOTE: THIS PROJECT WAS "VIBE-CODED"** + +This repository contains a personal utility written quickly to solve a specific need. The code was developed for convenience and rapid iteration rather than to be a polished, production-ready library. Expect rough edges, pragmatic shortcuts, and decisions made to fit a personal workflow. + +Overview +-------- +This app was made to help manage and protect save game data from all my flashcarts and modded devices. Nothing is worse than a corrupted SD card when all your eggs are in one basket. I started this project just for backing up my SuperFW cart, but it expanded to support many of my flashcarts. The application scans removable media, identifies the platform (via rules or manual assignment), and copies detected save files into an organized destination. Backups are archived and logged to help recover from media failures. + +Planned features +---------------- +- Add support for dumping and backing up additional platforms (PS2, GameCube and more). +- Improve detection heuristics and add more robust logging and UI polish. + +How it works +------------ +- Settings are persisted in `%LOCALAPPDATA%\GBACartBackup\settings.json` using `SettingsManager`. +- Backups are stored in a configured destination (default `~/HandheldSaves`) and include a `backupLog.json` for recent backup timestamps. +- `SaveFileLocator` contains system-specific logic and supports user-configured relative save paths (e.g. `roms\\nds\\saves`). +- The Settings UI exposes auto-assign rules (volume label and marker files), assignments, and default paths. + +Building +-------- +Requires: .NET 10 SDK + +From repository root: + +```bash +dotnet build +``` + +Running +------- +Run from your IDE or: + +```bash +dotnet run --project GBACartBackup.csproj +``` + +Usage notes +----------- +- Configure default save locations and assignment rules in Settings. +- Insert removable media and allow the app to detect and/or assign the system. The Backup button will enable when saves are found. +- Use "Open Backups Folder" in Settings to open the destination folder. + +License & contribution +---------------------- +This was developed as a personal tool. Contributions are welcome but expect code to favor pragmatic fixes. Review code and tests before reuse in critical scenarios. \ No newline at end of file diff --git a/SettingsManager.cs b/SettingsManager.cs index 58548d3..99c0b50 100644 --- a/SettingsManager.cs +++ b/SettingsManager.cs @@ -10,8 +10,18 @@ public class AppSettings // Map of system identifier -> relative default save path on media public Dictionary? DefaultSavePaths { get; set; } - // Map of media root (e.g., "E:\\") -> system key (e.g., "NintendoDS") + // Map of media root (e.g., "E:\") -> system key (e.g., "NintendoDS") public Dictionary? MediaAssignments { get; set; } + + // Auto-assignment options + public bool AutoAssignByVolumeLabel { get; set; } + public bool AutoAssignByMarkers { get; set; } + + // Marker filename -> system key + public Dictionary? MarkerRules { get; set; } + + // Volume label substring -> system key + public Dictionary? VolumeLabelRules { get; set; } } public static class SettingsManager @@ -58,6 +68,23 @@ private static AppSettings CreateDefaultSettings() { "GameBoy", "" } }; s.MediaAssignments = new Dictionary(StringComparer.OrdinalIgnoreCase); + + s.AutoAssignByVolumeLabel = true; + s.AutoAssignByMarkers = true; + + s.MarkerRules = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "TTMENU.DAT", "NintendoDS" } + }; + + s.VolumeLabelRules = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "NDS", "NintendoDS" }, + { "DS", "NintendoDS" }, + { "DSI", "NintendoDS" }, + { "GBA", "GameBoyAdvance" } + }; + return s; } @@ -89,13 +116,31 @@ public static void SetDefaultSavePathFor(string systemKey, string path) catch { } } - // Get assigned system for a specific media root (e.g., "E:\\") + private static string NormalizeRoot(string mediaRoot) + { + try + { + if (string.IsNullOrWhiteSpace(mediaRoot)) return mediaRoot ?? string.Empty; + var root = Path.GetPathRoot(mediaRoot); + return string.IsNullOrEmpty(root) ? mediaRoot : root; + } + catch { return mediaRoot ?? string.Empty; } + } + + // Get assigned system for a specific media root (e.g., "E:\") public static string? GetAssignedSystemForRoot(string mediaRoot) { try { var s = Load(); - if (s.MediaAssignments != null && s.MediaAssignments.TryGetValue(mediaRoot, out var sys)) + if (s.MediaAssignments == null) return null; + var key = NormalizeRoot(mediaRoot); + if (s.MediaAssignments.TryGetValue(key, out var sys)) + return string.IsNullOrWhiteSpace(sys) ? null : sys; + + // Also try a trimmed key without trailing separators + var altKey = key.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (s.MediaAssignments.TryGetValue(altKey, out sys)) return string.IsNullOrWhiteSpace(sys) ? null : sys; } catch { } @@ -111,19 +156,66 @@ public static void SetAssignedSystemForRoot(string mediaRoot, string? systemKey) if (s.MediaAssignments == null) s.MediaAssignments = new Dictionary(StringComparer.OrdinalIgnoreCase); + var key = NormalizeRoot(mediaRoot); + if (string.IsNullOrWhiteSpace(systemKey)) { // remove assignment - if (s.MediaAssignments.ContainsKey(mediaRoot)) - s.MediaAssignments.Remove(mediaRoot); + if (s.MediaAssignments.ContainsKey(key)) + s.MediaAssignments.Remove(key); + + // also remove alt key form + var altKey = key.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (s.MediaAssignments.ContainsKey(altKey)) + s.MediaAssignments.Remove(altKey); } else { - s.MediaAssignments[mediaRoot] = systemKey!; + s.MediaAssignments[key] = systemKey!; } Save(s); } catch { } } + + public static bool GetAutoAssignByVolumeLabel() + { + try { return Load().AutoAssignByVolumeLabel; } catch { return true; } + } + + public static void SetAutoAssignByVolumeLabel(bool v) + { + try { var s = Load(); s.AutoAssignByVolumeLabel = v; Save(s); } catch { } + } + + public static bool GetAutoAssignByMarkers() + { + try { return Load().AutoAssignByMarkers; } catch { return true; } + } + + public static void SetAutoAssignByMarkers(bool v) + { + try { var s = Load(); s.AutoAssignByMarkers = v; Save(s); } catch { } + } + + public static Dictionary GetMarkerRules() + { + try { var s = Load(); return s.MarkerRules ?? new Dictionary(StringComparer.OrdinalIgnoreCase); } catch { return new Dictionary(StringComparer.OrdinalIgnoreCase); } + } + + public static void SetMarkerRules(Dictionary rules) + { + try { var s = Load(); s.MarkerRules = new Dictionary(rules, StringComparer.OrdinalIgnoreCase); Save(s); } catch { } + } + + public static Dictionary GetVolumeLabelRules() + { + try { var s = Load(); return s.VolumeLabelRules ?? new Dictionary(StringComparer.OrdinalIgnoreCase); } catch { return new Dictionary(StringComparer.OrdinalIgnoreCase); } + } + + public static void SetVolumeLabelRules(Dictionary rules) + { + try { var s = Load(); s.VolumeLabelRules = new Dictionary(rules, StringComparer.OrdinalIgnoreCase); Save(s); } catch { } + } }