diff --git a/App/App.config b/App/App.config
new file mode 100644
index 0000000..5f548ab
--- /dev/null
+++ b/App/App.config
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+ lib;locale
+
+
+ 0.0.8
+
+
+
+
\ No newline at end of file
diff --git a/App/App.xaml b/App/App.xaml
new file mode 100644
index 0000000..65c582c
--- /dev/null
+++ b/App/App.xaml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/App/App.xaml.cs b/App/App.xaml.cs
new file mode 100644
index 0000000..4f5c28c
--- /dev/null
+++ b/App/App.xaml.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Runtime.Loader;
+using System.Windows;
+
+namespace DevModManager
+{
+ ///
+ /// Interaction logic for App.xaml
+ ///
+ public partial class App : Application
+ {
+ public App()
+ {
+ AssemblyLoadContext.Default.Resolving += OnAssemblyResolve;
+ }
+
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+ Debug.WriteLine("Application_Startup called");
+
+ try
+ {
+ // Set the probing paths before any other DLLs are loaded
+ string probingPaths = DevModManager.Properties.Settings.Default.ProbingPaths;
+ AppDomain.CurrentDomain.SetData("PROBING_DIRECTORIES", probingPaths);
+
+ // Initialize the database
+ Debug.WriteLine("Initializing database...");
+ DbManager.Instance.Initialize();
+ Debug.WriteLine("Database initialized.");
+
+ // Initialize the configuration
+ Debug.WriteLine("Initializing configuration...");
+ Config.Initialize();
+ Debug.WriteLine("Configuration initialized.");
+
+ // Check if the database is correctly initialized
+ if (DbManager.Instance.IsDatabaseInitialized())
+ {
+ // Open MainWindow if the database is initialized
+ Debug.WriteLine("Database initialized. Opening MainWindow.");
+ var mainWindow = new MainWindow();
+ mainWindow.Show();
+ }
+ else
+ {
+ // The DbManager should handle showing the SettingsWindow if needed
+ Debug.WriteLine("Database not initialized. DbManager will handle SettingsWindow.");
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Exception during startup: {ex.Message}");
+ _ = MessageBox.Show($"An error occurred during startup: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ Shutdown();
+ }
+ }
+
+ private void RestartApplication()
+ {
+ var exePath = Process.GetCurrentProcess().MainModule?.FileName;
+ if (exePath != null)
+ {
+ _ = Process.Start(exePath);
+ Shutdown();
+ }
+ }
+
+ private static Assembly? OnAssemblyResolve(AssemblyLoadContext context, AssemblyName assemblyName)
+ {
+ string probingPaths = DevModManager.Properties.Settings.Default.ProbingPaths;
+ string[] paths = probingPaths.Split(';');
+
+ foreach (string path in paths)
+ {
+ string assemblyPath = Path.Combine(AppContext.BaseDirectory, path, $"{assemblyName.Name}.dll");
+
+ if (File.Exists(assemblyPath))
+ {
+ return context.LoadFromAssemblyPath(assemblyPath);
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/App/AssemblyInfo.cs b/App/AssemblyInfo.cs
new file mode 100644
index 0000000..b0ec827
--- /dev/null
+++ b/App/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly: ThemeInfo(
+ ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
+ //(used if a resource is not found in the page,
+ // or application resource dictionaries)
+ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
+ //(used if a resource is not found in the page,
+ // app, or any theme specific resource dictionaries)
+)]
diff --git a/App/Config.cs b/App/Config.cs
new file mode 100644
index 0000000..b6e6397
--- /dev/null
+++ b/App/Config.cs
@@ -0,0 +1,362 @@
+using System.Data.SQLite;
+using System.IO;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace DevModManager
+{
+
+ ///
+ /// Configuration class for DevModManager.
+ ///
+ public class Config
+ {
+ private static Config? _instance;
+ private static readonly object _lock = new object();
+
+ ///
+ /// Gets the singleton instance of the Config class.
+ ///
+ public static Config Instance
+ {
+ get
+ {
+ if (_instance == null)
+ {
+ lock (_lock)
+ {
+ if (_instance == null)
+ {
+ _instance = new Config();
+ }
+ }
+ }
+ return _instance;
+ }
+ }
+
+ public string? RepoFolder { get; set; }
+ public bool UseGit { get; set; }
+ public string? GitHubRepo { get; set; }
+ public bool UseModManager { get; set; }
+ public string? ModStagingFolder { get; set; }
+ public string? GameFolder { get; set; }
+ public string? ModManagerExecutable { get; set; }
+ public string? ModManagerParameters { get; set; }
+ public string? IdeExecutable { get; set; }
+ public string[]? ModStages { get; set; }
+ public bool LimitFiletypes { get; set; }
+ public string[]? PromoteIncludeFiletypes { get; set; }
+ public string[]? PackageExcludeFiletypes { get; set; }
+ public string? TimestampFormat { get; set; }
+ public string? ArchiveFormat { get; set; }
+ public string? MyNameSpace { get; set; }
+ public string? MyResourcePrefix { get; set; }
+ public bool ShowSaveMessage { get; set; }
+ public bool ShowOverwriteMessage { get; set; }
+ public string? NexusAPIKey { get; set; }
+
+ public static readonly string configFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config", "config.yaml");
+ public static readonly string dbFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "DevModManager.db");
+
+ ///
+ /// Initializes the configuration.
+ ///
+ public static void Initialize()
+ {
+ if (_instance == null)
+ {
+ lock (_lock)
+ {
+ if (_instance == null)
+ {
+ if (File.Exists(dbFilePath))
+ {
+ _ = LoadFromDatabase();
+ }
+ else
+ {
+ _ = LoadFromYaml();
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Loads the configuration from a YAML file.
+ ///
+ /// The loaded configuration.
+ public static Config LoadFromYaml()
+ {
+ return LoadFromYaml(configFilePath);
+ }
+
+ ///
+ /// Saves the configuration to a YAML file.
+ ///
+ public static void SaveToYaml()
+ {
+ SaveToYaml(configFilePath);
+ }
+
+ ///
+ /// Loads the configuration from a specified YAML file.
+ ///
+ /// The path to the YAML file.
+ /// The loaded configuration.
+ public static Config LoadFromYaml(string filePath)
+ {
+ if (!File.Exists(filePath))
+ {
+ throw new FileNotFoundException("Configuration file not found", filePath);
+ }
+
+ var deserializer = new DeserializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .Build();
+
+ using var reader = new StreamReader(filePath);
+ var config = deserializer.Deserialize(reader);
+
+ lock (_lock)
+ {
+ _instance = config;
+ }
+
+ return _instance;
+ }
+
+ ///
+ /// Saves the configuration to a specified YAML file.
+ ///
+ /// The path to the YAML file.
+ public static void SaveToYaml(string filePath)
+ {
+ var serializer = new SerializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .Build();
+
+ var yaml = serializer.Serialize(Instance);
+
+ File.WriteAllText(filePath, yaml);
+ }
+
+ ///
+ /// Loads the configuration from the database.
+ ///
+ /// The loaded configuration.
+ public static Config? LoadFromDatabase()
+ {
+ using (var connection = DbManager.Instance.GetConnection())
+ {
+ connection.Open();
+ using var command = new SQLiteCommand("SELECT * FROM vwConfig", connection);
+ using var reader = command.ExecuteReader();
+ if (reader.Read())
+ {
+ _instance = new Config
+ {
+ RepoFolder = reader["RepoFolder"]?.ToString(),
+ UseGit = Convert.ToBoolean(reader["UseGit"]),
+ GitHubRepo = reader["GitHubRepo"]?.ToString(),
+ UseModManager = Convert.ToBoolean(reader["UseModManager"]),
+ GameFolder = reader["GameFolder"]?.ToString(),
+ ModStagingFolder = reader["ModStagingFolder"]?.ToString(),
+ ModManagerExecutable = reader["ModManagerExecutable"]?.ToString(),
+ ModManagerParameters = reader["ModManagerParameters"]?.ToString(),
+ IdeExecutable = reader["IDEExecutable"]?.ToString(),
+ LimitFiletypes = Convert.ToBoolean(reader["LimitFileTypes"]),
+ PromoteIncludeFiletypes = reader["PromoteIncludeFiletypes"]?.ToString()?.Split(',') ?? Array.Empty(),
+ PackageExcludeFiletypes = reader["PackageExcludeFiletypes"]?.ToString()?.Split(',') ?? Array.Empty(),
+ TimestampFormat = reader["TimestampFormat"]?.ToString(),
+ MyNameSpace = reader["MyNameSpace"]?.ToString(),
+ MyResourcePrefix = reader["MyResourcePrefix"]?.ToString(),
+ ShowSaveMessage = Convert.ToBoolean(reader["ShowSaveMessage"]),
+ ShowOverwriteMessage = Convert.ToBoolean(reader["ShowOverwriteMessage"]),
+ NexusAPIKey = reader["NexusAPIKey"]?.ToString(),
+ ModStages = reader["ModStages"]?.ToString()?.Split(',') ?? Array.Empty(),
+ ArchiveFormat = reader["ArchiveFormat"]?.ToString()
+ };
+ }
+ }
+ return _instance;
+ }
+
+ ///
+ /// Asynchronously initializes the configuration.
+ ///
+ public static async Task InitializeAsync()
+ {
+ if (_instance == null)
+ {
+ if (File.Exists(dbFilePath))
+ {
+ _instance = await LoadFromDatabaseAsync();
+ }
+ else
+ {
+ _instance = await LoadFromYamlAsync(configFilePath);
+ }
+ }
+ }
+
+ ///
+ /// Asynchronously loads the configuration from a specified YAML file.
+ ///
+ /// The path to the YAML file.
+ /// The loaded configuration.
+ public static async Task LoadFromYamlAsync(string filePath)
+ {
+ if (!File.Exists(filePath))
+ {
+ throw new FileNotFoundException("Configuration file not found", filePath);
+ }
+
+ var deserializer = new DeserializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .Build();
+
+ using var reader = new StreamReader(filePath);
+ var config = deserializer.Deserialize(await reader.ReadToEndAsync());
+
+ lock (_lock)
+ {
+ _instance = config;
+ }
+
+ return _instance;
+ }
+
+ ///
+ /// Asynchronously loads the configuration from the database.
+ ///
+ /// The loaded configuration.
+ public static async Task LoadFromDatabaseAsync()
+ {
+ using (var connection = DbManager.Instance.GetConnection())
+ {
+ await connection.OpenAsync();
+ using var command = new SQLiteCommand("SELECT * FROM vwConfig", connection);
+ using var reader = await command.ExecuteReaderAsync();
+ if (await reader.ReadAsync())
+ {
+ _instance = new Config
+ {
+ // Populate properties from reader
+ };
+ }
+ }
+ return _instance;
+ }
+
+ ///
+ /// Saves the configuration to the database.
+ ///
+ public static void SaveToDatabase()
+ {
+ var config = Instance;
+
+ using var connection = DbManager.Instance.GetConnection();
+ connection.Open();
+ using var transaction = connection.BeginTransaction();
+ using (var command = new SQLiteCommand(connection))
+ {
+ command.CommandText = "DELETE FROM Config";
+ _ = command.ExecuteNonQuery();
+
+ command.CommandText = @"
+ INSERT INTO Config (
+ RepoFolder,
+ UseGit,
+ GitHubRepo,
+ UseModManager,
+ GameFolder,
+ ModStagingFolder,
+ ModManagerExecutable,
+ ModManagerParameters,
+ IDEExecutable,
+ LimitFileTypes,
+ PromoteIncludeFiletypes,
+ PackageExcludeFiletypes,
+ TimestampFormat,
+ MyNameSpace,
+ MyResourcePrefix,
+ ShowSaveMessage,
+ ShowOverwriteMessage,
+ NexusAPIKey,
+ ArchiveFormatID
+ ) VALUES (
+ @RepoFolder,
+ @UseGit,
+ @GitHubRepo,
+ @UseModManager,
+ @GameFolder,
+ @ModStagingFolder,
+ @ModManagerExecutable,
+ @ModManagerParameters,
+ @IdeExecutable,
+ @LimitFiletypes,
+ @PromoteIncludeFiletypes,
+ @PackageExcludeFiletypes,
+ @TimestampFormat,
+ @MyNameSpace,
+ @MyResourcePrefix,
+ @ShowSaveMessage,
+ @ShowOverwriteMessage,
+ @NexusAPIKey,
+ (SELECT ArchiveFormatID FROM ArchiveFormats WHERE FormatName = @ArchiveFormat)
+ )";
+
+ command.Parameters.AddWithValue("@RepoFolder", config.RepoFolder ?? (object)DBNull.Value);
+ _ = command.Parameters.AddWithValue("@UseGit", config.UseGit);
+ command.Parameters.AddWithValue("@GitHubRepo", config.GitHubRepo ?? (object)DBNull.Value);
+ _ = command.Parameters.AddWithValue("@UseModManager", config.UseModManager);
+ command.Parameters.AddWithValue("@GameFolder", config.GameFolder ?? (object)DBNull.Value);
+ command.Parameters.AddWithValue("@ModStagingFolder", config.ModStagingFolder ?? (object)DBNull.Value);
+ command.Parameters.AddWithValue("@ModManagerExecutable", config.ModManagerExecutable ?? (object)DBNull.Value);
+ command.Parameters.AddWithValue("@ModManagerParameters", config.ModManagerParameters ?? (object)DBNull.Value);
+ command.Parameters.AddWithValue("@IdeExecutable", config.IdeExecutable ?? (object)DBNull.Value);
+ _ = command.Parameters.AddWithValue("@LimitFiletypes", config.LimitFiletypes);
+ _ = command.Parameters.AddWithValue("@PromoteIncludeFiletypes", string.Join(",", config.PromoteIncludeFiletypes ?? Array.Empty()));
+ _ = command.Parameters.AddWithValue("@PackageExcludeFiletypes", string.Join(",", config.PackageExcludeFiletypes ?? Array.Empty()));
+ command.Parameters.AddWithValue("@TimestampFormat", config.TimestampFormat ?? (object)DBNull.Value);
+ command.Parameters.AddWithValue("@MyNameSpace", config.MyNameSpace ?? (object)DBNull.Value);
+ command.Parameters.AddWithValue("@MyResourcePrefix", config.MyResourcePrefix ?? (object)DBNull.Value);
+ _ = command.Parameters.AddWithValue("@ShowSaveMessage", config.ShowSaveMessage);
+ _ = command.Parameters.AddWithValue("@ShowOverwriteMessage", config.ShowOverwriteMessage);
+ command.Parameters.AddWithValue("@NexusAPIKey", config.NexusAPIKey ?? (object)DBNull.Value);
+ command.Parameters.AddWithValue("@ArchiveFormat", config.ArchiveFormat ?? (object)DBNull.Value);
+
+ _ = command.ExecuteNonQuery();
+ }
+
+ if (config.ModStages != null && config.ModStages.Length > 0)
+ {
+ using (var deleteCommand = new SQLiteCommand("DELETE FROM Stages WHERE IsReserved = 0", connection))
+ {
+ _ = deleteCommand.ExecuteNonQuery();
+ }
+
+ foreach (var stage in config.ModStages)
+ {
+ var stageName = stage.TrimStart('*', '#');
+ var isSource = stage.StartsWith('*');
+ var isReserved = stage.StartsWith('#');
+
+ using var stageCommand = new SQLiteCommand(connection);
+ stageCommand.CommandText = @"
+ INSERT OR REPLACE INTO Stages (StageName, IsSource, IsReserved)
+ VALUES (@StageName, @IsSource, @IsReserved)";
+ _ = stageCommand.Parameters.AddWithValue("@StageName", stageName);
+ _ = stageCommand.Parameters.AddWithValue("@IsSource", isSource);
+ _ = stageCommand.Parameters.AddWithValue("@IsReserved", isReserved);
+
+ _ = stageCommand.ExecuteNonQuery();
+ }
+ }
+
+ transaction.Commit();
+ }
+ }
+}
\ No newline at end of file
diff --git a/App/DbManager.cs b/App/DbManager.cs
new file mode 100644
index 0000000..e16acef
--- /dev/null
+++ b/App/DbManager.cs
@@ -0,0 +1,165 @@
+using System.Data.SQLite;
+using System.Diagnostics;
+using System.IO;
+using System.Windows;
+
+namespace DevModManager
+{
+ public class DbManager
+ {
+ private static readonly Lazy _instance = new Lazy(() => new DbManager());
+ private const string ConnectionString = "Data Source=data/DevModManager.db;Version=3;";
+ private const string dbFilePath = "data/DevModManager.db";
+
+ private DbManager() { }
+
+ public static DbManager Instance => _instance.Value;
+
+ public void Initialize()
+ {
+ bool dbExists = File.Exists(dbFilePath);
+ Debug.WriteLine($"Database file path: {dbFilePath}");
+ Debug.WriteLine($"Database exists: {dbExists}");
+
+ if (!dbExists)
+ {
+ ReloadDatabaseFromInstaller();
+ return;
+ }
+
+ using var connection = GetConnection();
+ connection.Open();
+
+ if (IsConfigTableEmpty() || !IsDatabaseInitialized())
+ {
+ var config = Config.LoadFromYaml();
+ if (IsSampleOrInvalidData(config))
+ {
+ Debug.WriteLine($"Loaded sample data from YAML: {config}");
+ bool settingsSaved = LaunchSettingsWindow(SettingsLaunchSource.DatabaseInitialization);
+
+ config = Config.LoadFromYaml();
+ if (IsSampleOrInvalidData(config))
+ {
+ Debug.WriteLine("Configuration data is still invalid after settings window.");
+ var resultRetry = MessageBox.Show("Configuration data is invalid. Would you like to retry?", "Error", MessageBoxButton.YesNo, MessageBoxImage.Error);
+ if (resultRetry == MessageBoxResult.Yes)
+ {
+ settingsSaved = LaunchSettingsWindow(SettingsLaunchSource.DatabaseInitialization);
+ }
+ else
+ {
+ Debug.WriteLine("User chose not to retry. Shutting down application.");
+ Application.Current.Shutdown();
+ return;
+ }
+ }
+
+ if (!settingsSaved)
+ {
+ Debug.WriteLine("Settings were not saved. Shutting down application.");
+ Application.Current.Shutdown();
+ return;
+ }
+ }
+ SetInitializationStatus(true);
+ }
+ else
+ {
+ Debug.WriteLine("Loading configuration from database.");
+ _ = Config.LoadFromDatabase();
+ }
+ }
+
+ private void ReloadDatabaseFromInstaller()
+ {
+ _ = MessageBox.Show("The database file is missing. Please reinstall the application and try again.", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ Application.Current.Shutdown();
+ }
+
+ private bool IsSampleOrInvalidData(Config config)
+ {
+ return config.RepoFolder == "<>" ||
+ config.GitHubRepo == "<>" ||
+ config.ModStagingFolder == "<>" ||
+ config.GameFolder == "<>" ||
+ !Directory.Exists(config.RepoFolder) ||
+ !Directory.Exists(config.ModStagingFolder) ||
+ !Directory.Exists(config.GameFolder);
+ }
+
+ public bool LaunchSettingsWindow(SettingsLaunchSource source)
+ {
+ var settingsWindow = new SettingsWindow(source);
+ bool? result = settingsWindow.ShowDialog();
+ Debug.WriteLine($"SettingsWindow result: {result}");
+ return result == true;
+ }
+
+ public SQLiteConnection GetConnection()
+ {
+ return new SQLiteConnection(ConnectionString);
+ }
+
+ private bool IsConfigTableEmpty()
+ {
+ using var connection = GetConnection();
+ connection.Open();
+ using var command = new SQLiteCommand("SELECT COUNT(*) FROM Config", connection);
+ return Convert.ToInt32(command.ExecuteScalar()) == 0;
+ }
+
+ public bool IsDatabaseInitialized()
+ {
+ using var connection = GetConnection();
+ connection.Open();
+ using var command = new SQLiteCommand("SELECT IsInitialized FROM InitializationStatus", connection);
+ var result = command.ExecuteScalar();
+ return result != null && Convert.ToBoolean(result);
+ }
+
+ public void SetInitializationStatus(bool status)
+ {
+ using var connection = GetConnection();
+ connection.Open();
+ using (var command = new SQLiteCommand("INSERT OR REPLACE INTO InitializationStatus (Id, IsInitialized, InitializationTime) VALUES (1, @IsInitialized, @InitializationTime)", connection))
+ {
+ _ = command.Parameters.AddWithValue("@IsInitialized", status);
+ command.Parameters.AddWithValue("@InitializationTime", DateTime.UtcNow);
+ _ = command.ExecuteNonQuery();
+ }
+ Debug.WriteLine($"Database marked as initialized: {status}");
+ }
+
+ public static void FlushDB()
+ {
+ using var connection = Instance.GetConnection();
+ connection.Open();
+
+ using var transaction = connection.BeginTransaction();
+ try
+ {
+ transaction.Commit();
+
+ using (var vacuumCommand = new SQLiteCommand("VACUUM;", connection))
+ {
+ _ = vacuumCommand.ExecuteNonQuery();
+ }
+
+ using var reindexCommand = new SQLiteCommand("REINDEX;", connection);
+ _ = reindexCommand.ExecuteNonQuery();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Error during FlushDB: {ex.Message}");
+ transaction.Rollback();
+ }
+ finally
+ {
+ connection.Close();
+ }
+
+ SQLiteConnection.ClearAllPools();
+ }
+ }
+}
diff --git a/App/DevModManager.csproj b/App/DevModManager.csproj
new file mode 100644
index 0000000..8971d5d
--- /dev/null
+++ b/App/DevModManager.csproj
@@ -0,0 +1,177 @@
+
+
+
+
+ WinExe
+
+ net8.0-windows
+
+ enable
+
+ enable
+
+ true
+
+ True
+
+ False
+
+ False
+
+ ZeeOgre.DevModManager
+ A mod lifecycle manager designed to leverage Vortex or MO2 to efficiently handle release cycles of Bethesda Game mods
+ Copyright 2024 ZeeOgre
+ docs\img\ZeeOgre_NoBackground.jpg
+ https://github.com/ZeeOgre/DevModManager
+ docs\README.md
+ https://github.com/ZeeOgre/DevModManager
+ git
+ AGPL-3.0-or-later
+ 0.0.8
+ Debug;Release;GitRelease;GitPublish
+ ZeeOgre
+ docs\img\ZeeOgre.ico
+ DevModManager.App
+ en-US
+
+
+
+
+ powershell -ExecutionPolicy Bypass -Command "& {
+ if ('$(Configuration)' -eq 'GitRelease') {
+ . '$(SolutionDir)scripts\IncrementVersion.ps1' -SettingsFile '$(SolutionDir)Properties\Settings.settings' -AipFilePath '$(SolutionDir)..\DevModManager_AIP\DevModManager_AIP.aip' -CsprojFilePath '$(SolutionDir)DevModManager.csproj' -AppConfigFilePath '$(SolutionDir)App.config' -VersionTxtFilePath '$(SolutionDir)Properties\version.txt'
+ }
+ }"
+
+
+
+
+
+
+ powershell -Command "& {
+ if ('$(Configuration)' -eq 'GitRelease') {
+ Set-Location '$(SolutionDir)';
+ $newVersion = Get-Content '$(SolutionDir)Properties\version.txt';
+ git add .;
+ git commit -m \"Increment version to $newVersion\";
+ git push;
+ Write-Output 'Post-build event completed.';
+ }
+ }"
+
+
+
+
+
+ True
+
+
+
+ True
+ bin\GitPublish\
+
+
+
+
+
+ True
+ \
+
+
+
+
+
+ True
+ \
+
+
+ True
+ \
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+
+
+ Always
+
+
+
+
+
+ Always
+
+
+
+
+
+ SettingsSingleFileGenerator
+ Settings.Designer.cs
+
+
+ True
+ True
+ Settings.settings
+
+
+
+
+
+
+
+
+ $(OutputPath)
+
+
+
+
+
+
+
+ $(PublishDir)
+
+
+
+
+
+
+
+ $(OutputPath)
+
+
+
+
+
+
+
+
+
+
+
+ $(OutputPath)
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/App/FormatConverters.cs b/App/FormatConverters.cs
new file mode 100644
index 0000000..c39fc0d
--- /dev/null
+++ b/App/FormatConverters.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace DevModManager
+{
+ public class ArchiveFormatConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is string format)
+ {
+ format = format.ToLower();
+ if (format.Contains("7z"))
+ {
+ return "7z";
+ }
+ else if (format.Contains("zip"))
+ {
+ return "zip";
+ }
+ }
+ return "zip"; // Default to zip if no match
+ }
+
+
+ public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is string format)
+ {
+ return format.ToLower(); // Simply return the selected format
+ }
+ return null; // or return a default if needed
+ }
+
+ }
+
+ public class BethesdaUrlConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is string url && !string.IsNullOrEmpty(url))
+ {
+ if (url.StartsWith("https://"))
+ {
+ return ModItem.DB.ExtractID(url);
+ }
+ else if (Guid.TryParse(url, out _))
+ {
+ return $"https://creations.bethesda.net/en/starfield/details/{url}";
+ }
+ }
+ return "Bethesda";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public class NexusUrlConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is string url && !string.IsNullOrEmpty(url))
+ {
+ if (url.StartsWith("https://"))
+ {
+ return ModItem.DB.ExtractID(url);
+ }
+ else if (int.TryParse(url, out _))
+ {
+ return $"https://www.nexusmods.com/starfield/mods/{url}";
+ }
+ }
+ return "Nexus";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public class NullToBooleanConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value != null;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public class NullToVisibilityConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value == null ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public class PathToFolderConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return System.IO.Path.GetDirectoryName(value?.ToString() ?? string.Empty) ?? string.Empty;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public class PathToFileNameConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return System.IO.Path.GetFileName(value?.ToString() ?? string.Empty) ?? string.Empty;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public class BackupStatusToVisibilityConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return string.IsNullOrEmpty(value as string) ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public class BooleanToVisibilityConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is bool boolValue)
+ {
+ return boolValue ? Visibility.Visible : Visibility.Collapsed;
+ }
+ if (value is string stringValue)
+ {
+ return string.IsNullOrEmpty(stringValue) ? Visibility.Collapsed : Visibility.Visible;
+ }
+ return Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is Visibility visibility)
+ {
+ return visibility == Visibility.Visible;
+ }
+ return false;
+ }
+ }
+
+
+ public class StringNullOrEmptyToBooleanConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return !string.IsNullOrEmpty(value as string);
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/App/GetJunctionTarget.cs b/App/GetJunctionTarget.cs
new file mode 100644
index 0000000..720a7dd
--- /dev/null
+++ b/App/GetJunctionTarget.cs
@@ -0,0 +1,129 @@
+using Microsoft.Win32.SafeHandles;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace DevModManager
+{
+ public partial class ModItem
+ {
+ public partial class Files
+ {
+ public static string GetJunctionTarget(string junctionPoint)
+ {
+ var directoryInfo = new DirectoryInfo(junctionPoint);
+ if (!directoryInfo.Attributes.HasFlag(FileAttributes.ReparsePoint))
+ {
+ return string.Empty;
+ }
+
+ using var handle = CreateFile(
+ junctionPoint,
+ FileAccess.Read,
+ FileShare.ReadWrite,
+ IntPtr.Zero,
+ FileMode.Open,
+ FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
+ IntPtr.Zero);
+ if (handle.IsInvalid)
+ {
+ int errorCode = Marshal.GetLastWin32Error();
+ throw new IOException($"Unable to open junction point. Error code: {errorCode}");
+ }
+
+ var reparseDataBuffer = new byte[REPARSE_DATA_BUFFER_HEADER_SIZE + MAXIMUM_REPARSE_DATA_BUFFER_SIZE];
+ if (!DeviceIoControl(
+ handle,
+ FSCTL_GET_REPARSE_POINT,
+ IntPtr.Zero,
+ 0,
+ reparseDataBuffer,
+ reparseDataBuffer.Length,
+ out _,
+ IntPtr.Zero))
+ {
+ int errorCode = Marshal.GetLastWin32Error();
+ throw new IOException($"Unable to get information about junction point. Error code: {errorCode}");
+ }
+
+ var target = ParseReparsePoint(reparseDataBuffer);
+ return target;
+ }
+
+ private static string ParseReparsePoint(byte[] reparseDataBuffer)
+ {
+
+ GCHandle handle = GCHandle.Alloc(reparseDataBuffer, GCHandleType.Pinned);
+ try
+ {
+ var reparseData = Marshal.PtrToStructure(handle.AddrOfPinnedObject());
+ if (reparseData.ReparseTag != IO_REPARSE_TAG_MOUNT_POINT)
+ {
+ throw new IOException("The reparse point is not a junction point.");
+ }
+
+ if (reparseData.PathBuffer == null)
+ {
+ throw new IOException("The reparse point buffer is null.");
+ }
+
+ var target = Encoding.Unicode.GetString(reparseData.PathBuffer, reparseData.SubstituteNameOffset, reparseData.SubstituteNameLength);
+ if (target.StartsWith("\\??\\"))
+ {
+ target = target.Substring(4);
+ }
+
+ return target;
+ }
+ finally
+ {
+ handle.Free();
+ }
+
+
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
+ private static extern SafeFileHandle CreateFile(
+ string lpFileName,
+ [MarshalAs(UnmanagedType.U4)] FileAccess dwDesiredAccess,
+ [MarshalAs(UnmanagedType.U4)] FileShare dwShareMode,
+ IntPtr lpSecurityAttributes,
+ [MarshalAs(UnmanagedType.U4)] FileMode dwCreationDisposition,
+ [MarshalAs(UnmanagedType.U4)] uint dwFlagsAndAttributes,
+ IntPtr hTemplateFile);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool DeviceIoControl(
+ SafeFileHandle hDevice,
+ uint dwIoControlCode,
+ IntPtr lpInBuffer,
+ uint nInBufferSize,
+ [Out] byte[] lpOutBuffer,
+ int nOutBufferSize,
+ out uint lpBytesReturned,
+ IntPtr lpOverlapped);
+
+ private const uint FSCTL_GET_REPARSE_POINT = 0x000900A8;
+ private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003;
+ private const int REPARSE_DATA_BUFFER_HEADER_SIZE = 8;
+ private const int MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 16384;
+ private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
+ private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct REPARSE_DATA_BUFFER
+ {
+ public uint ReparseTag;
+ public ushort ReparseDataLength;
+ public ushort Reserved;
+ public ushort SubstituteNameOffset;
+ public ushort SubstituteNameLength;
+ public ushort PrintNameOffset;
+ public ushort PrintNameLength;
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = MAXIMUM_REPARSE_DATA_BUFFER_SIZE)]
+ public byte[] PathBuffer;
+ }
+ }
+ }
+}
diff --git a/App/LoadOrderWindow.xaml b/App/LoadOrderWindow.xaml
new file mode 100644
index 0000000..db45855
--- /dev/null
+++ b/App/LoadOrderWindow.xaml
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/LoadOrderWindow.xaml.cs b/App/LoadOrderWindow.xaml.cs
new file mode 100644
index 0000000..8cab87b
--- /dev/null
+++ b/App/LoadOrderWindow.xaml.cs
@@ -0,0 +1,197 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using System.Windows.Navigation;
+using MessageBox = System.Windows.MessageBox;
+
+
+namespace DevModManager
+{
+ public partial class LoadOrderWindow : Window
+ {
+ public ObservableCollection Groups { get; set; }
+
+ public LoadOrderWindow()
+ {
+ InitializeComponent();
+ DataContext = this;
+ Groups = new ObservableCollection(); // Initialize Groups to avoid null reference
+ LoadData();
+ LoadOrderDataGrid.RowDetailsVisibilityMode = DataGridRowDetailsVisibilityMode.Visible;
+ }
+
+ private void LoadData()
+ {
+ var plugins = PluginManager.LoadPlugins();
+ var groupedPlugins = plugins.GroupBy(p => p.GroupID).Select(g =>
+ {
+ var group = PluginManager.GetGroupById(Groups, g.Key) ?? new ModGroup { GroupID = g.Key };
+ return new ModGroup
+ {
+ GroupID = group.GroupID,
+ Description = group.Description,
+ Plugins = new ObservableCollection(g)
+ };
+ });
+
+ Groups = new ObservableCollection(groupedPlugins);
+ }
+
+ private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
+ {
+ var hyperlink = (Hyperlink)sender;
+ _ = (Plugin)hyperlink.DataContext;
+
+ if (!string.IsNullOrEmpty(e.Uri.AbsoluteUri))
+ {
+ _ = Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
+ }
+
+ e.Handled = true;
+ }
+
+ private void UpdateHyperlink(TextBlock textBlock, string id, string type)
+ {
+ textBlock.Inlines.Clear();
+ string url = string.Empty;
+
+ if (type == "Bethesda")
+ {
+ var bethesdaUrlConverter = new BethesdaUrlConverter();
+ url = bethesdaUrlConverter.Convert(id, typeof(string), null, CultureInfo.InvariantCulture) as string ?? string.Empty;
+ }
+ else if (type == "Nexus")
+ {
+ var nexusUrlConverter = new NexusUrlConverter();
+ url = nexusUrlConverter.Convert(id, typeof(string), null, CultureInfo.InvariantCulture) as string ?? string.Empty;
+ }
+
+ var newHyperlink = new Hyperlink(new Run(id ?? "Unknown"))
+ {
+ NavigateUri = new Uri(url)
+ };
+ newHyperlink.RequestNavigate += Hyperlink_RequestNavigate;
+ textBlock.Inlines.Add(newHyperlink);
+
+ // Save the updated plugin data
+ PluginManager.SavePluginsToJson(Groups.ToList(), Groups.SelectMany(g => g.Plugins).ToList());
+ }
+
+ private void MoveUpButton_Click(object sender, RoutedEventArgs e)
+ {
+ var selectedPlugin = LoadOrderDataGrid.SelectedItem as Plugin;
+ if (selectedPlugin == null) return;
+
+ var group = PluginManager.GetGroupById(Groups, selectedPlugin.GroupID);
+ if (group == null) return;
+
+ var index = group.Plugins.IndexOf(selectedPlugin);
+ if (index > 0)
+ {
+ group.Plugins.Move(index, index - 1);
+ RefreshDataGrid();
+ }
+ }
+
+ private void MoveDownButton_Click(object sender, RoutedEventArgs e)
+ {
+ var selectedPlugin = LoadOrderDataGrid.SelectedItem as Plugin;
+ if (selectedPlugin == null) return;
+
+ var group = PluginManager.GetGroupById(Groups, selectedPlugin.GroupID);
+ if (group == null) return;
+
+ var index = group.Plugins.IndexOf(selectedPlugin);
+ if (index < group.Plugins.Count - 1)
+ {
+ group.Plugins.Move(index, index + 1);
+ RefreshDataGrid();
+ }
+ }
+
+ private void EditRowDataButton_Click(object sender, RoutedEventArgs e)
+ {
+ var selectedItem = LoadOrderDataGrid.SelectedItem;
+ if (selectedItem is Plugin selectedPlugin)
+ {
+ var editorWindow = new PluginEditorWindow(selectedPlugin, new ObservableCollection(Groups));
+ if (editorWindow.ShowDialog() == true)
+ {
+ PluginManager.SavePluginsToJson(Groups.ToList(), Groups.SelectMany(g => g.Plugins).ToList());
+ LoadOrderDataGrid.Items.Refresh();
+ }
+ }
+ else if (selectedItem is ModGroup selectedGroup)
+ {
+ var editorWindow = new ModGroupEditorWindow(selectedGroup, Groups.SelectMany(g => g.Plugins).ToList());
+ if (editorWindow.ShowDialog() == true)
+ {
+ PluginManager.SavePluginsToJson(Groups.ToList(), Groups.SelectMany(g => g.Plugins).ToList());
+ LoadOrderDataGrid.Items.Refresh();
+ }
+ }
+ }
+
+
+ private void RefreshDataGrid()
+ {
+ LoadOrderDataGrid.Items.Refresh();
+ PluginManager.SavePluginsToJson(Groups.ToList(), Groups.SelectMany(g => g.Plugins).ToList());
+ }
+
+ private void SaveButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Save the updated plugin data
+ PluginManager.SavePluginsToJson(Groups.ToList(), Groups.SelectMany(g => g.Plugins).ToList());
+ _ = MessageBox.Show("Changes saved successfully.", "Save", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private void EditButton_Click(object sender, RoutedEventArgs e)
+ {
+ var selectedPlugin = LoadOrderDataGrid.SelectedItem as Plugin;
+ if (selectedPlugin == null) return;
+
+ // Assuming you have a property or field named `Groups` that contains the list of ModGroup objects
+ var editorWindow = new PluginEditorWindow(selectedPlugin, new ObservableCollection(Groups));
+ if (editorWindow.ShowDialog() == true)
+ {
+ // Save the updated plugin data
+ PluginManager.SavePluginsToJson(Groups.ToList(), Groups.SelectMany(g => g.Plugins).ToList());
+ LoadOrderDataGrid.Items.Refresh(); // Refresh the DataGrid view
+ }
+ }
+
+ private void OpenGameFolderButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Implement the logic to open the game folder
+ _ = MessageBox.Show("Open Game Folder clicked.", "Open Game Folder", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private void OpenGameSettingsFolderButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Implement the logic to open the game settings folder
+ _ = MessageBox.Show("Open Game Settings Folder clicked.", "Open Game Settings Folder", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private void OpenGameSaveFolderButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Implement the logic to open the game save folder
+ _ = MessageBox.Show("Open Game Save Folder clicked.", "Open Game Save Folder", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private void EditPluginsButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Implement the logic to edit plugins.txt
+ _ = MessageBox.Show("Edit plugins.txt clicked.", "Edit Plugins", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private void EditContentCatalogButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Implement the logic to edit ContentCatalog.txt
+ _ = MessageBox.Show("Edit ContentCatalog.txt clicked.", "Edit Content Catalog", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+}
diff --git a/App/MainWindow.xaml b/App/MainWindow.xaml
new file mode 100644
index 0000000..4aa0215
--- /dev/null
+++ b/App/MainWindow.xaml
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/MainWindow.xaml.cs b/App/MainWindow.xaml.cs
new file mode 100644
index 0000000..76b4ea0
--- /dev/null
+++ b/App/MainWindow.xaml.cs
@@ -0,0 +1,292 @@
+using System.Diagnostics;
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using System.Windows.Media;
+using System.Windows.Navigation;
+using MessageBox = System.Windows.MessageBox;
+
+namespace DevModManager
+{
+ public partial class MainWindow : Window
+ {
+ private MainWindowViewModel _viewModel;
+ private string _previousSelectedStage = string.Empty;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ Debug.WriteLine("MainWindow Initialize Complete");
+ _viewModel = new MainWindowViewModel();
+ Debug.WriteLine("MainWindow ViewModel Loaded");
+
+ DataContext = _viewModel;
+ Debug.WriteLine("MainWindow DataContext Bound");
+
+ Loaded += _viewModel.MainWindowLoaded;
+ Debug.WriteLine("ViewModel_MainWindowLoaded");
+
+ this.Closing += MainWindow_Closing;
+ Debug.WriteLine("MainWindow Closing Event Set");
+ }
+
+ private void MainWindow_Loaded(object sender, RoutedEventArgs e)
+ {
+ Debug.WriteLine("MainWindow LoadStages()");
+ _viewModel.LoadStages();
+ Debug.WriteLine("MainWindow LoadStages() complete");
+
+ Debug.WriteLine("MainWindow LoadModItems()");
+ _viewModel.LoadModItems();
+ Debug.WriteLine("MainWindow LoadModItems() complete");
+ }
+
+ private void MainWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
+ {
+ Debug.WriteLine("MainWindowClose called by " + sender);
+ // Add any conditions that might prevent closing
+ // e.Cancel = true; // Uncomment to prevent closing for testing
+ Debug.WriteLine("MainWindowClose: Flushing database before closing.");
+ DbManager.FlushDB();
+ }
+
+ private void ViewModel_MainWindowLoaded()
+ {
+ Debug.WriteLine("ViewModel_MainWindowLoaded");
+ // Additional logic for ViewModel loaded
+ }
+
+ private void OpenFolder(string path)
+ {
+ if (Directory.Exists(path))
+ {
+ _ = Process.Start(new ProcessStartInfo
+ {
+ FileName = "explorer.exe",
+ Arguments = path,
+ UseShellExecute = true
+ });
+ }
+ else
+ {
+ _ = MessageBox.Show($"The folder '{path}' does not exist.", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
+ {
+ var uriString = e.Uri.IsAbsoluteUri ? e.Uri.AbsoluteUri : e.Uri.ToString();
+
+ // Check if the URI is a local path or a file URL
+ if (uriString.StartsWith("file://"))
+ {
+ var localPath = new Uri(uriString).LocalPath;
+ OpenFolder(localPath);
+ }
+ else if (Directory.Exists(uriString))
+ {
+ OpenFolder(uriString);
+ }
+ else
+ {
+ HandleUrl(sender, uriString);
+ }
+
+ e.Handled = true;
+ }
+
+ private void HandleUrl(object sender, string uriString)
+ {
+ // Handle URLs
+ if (uriString.StartsWith("https://"))
+ {
+ uriString = ModItem.DB.ExtractID(uriString);
+ }
+ else
+ {
+ if (Guid.TryParse(uriString, out _))
+ {
+ uriString = $"https://creations.bethesda.net/en/starfield/details/{uriString}";
+ }
+ else if (int.TryParse(uriString, out _))
+ {
+ uriString = $"https://www.nexusmods.com/starfield/mods/{uriString}";
+ }
+ }
+
+ if (!uriString.Contains("bethesda.net") && !uriString.Contains("nexusmods.com"))
+ {
+ if (sender is Hyperlink hyperlink && hyperlink.DataContext is ModItem modItem)
+ {
+ var urlInputDialog = new UrlInputDialog(modItem, uriString);
+ if (urlInputDialog.ShowDialog() == true)
+ {
+ // Assuming UrlInputDialog updates the modItem with the new URL
+ //modItem.Url = uriString; // Update the modItem with the new URL
+ //_viewModel.SaveModItems(); // Save changes to the mod items
+ _viewModel.LoadModItems(); // Refresh the mod items to reflect the changes
+ }
+ }
+ }
+ else
+ {
+ // Open the URL directly
+ _ = Process.Start(new ProcessStartInfo(uriString) { UseShellExecute = true });
+ }
+ }
+
+ private void OpenBackupFolder_ButtonClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button && button.Tag is ModItem modItem)
+ {
+ OpenFolder(PathBuilder.GetBackupFolder(modItem.ModName));
+ }
+ }
+
+ private T? FindParent(DependencyObject child) where T : DependencyObject
+ {
+ DependencyObject? parentObject = VisualTreeHelper.GetParent(child);
+ if (parentObject == null) return null;
+
+ T? parent = parentObject as T;
+ if (parent != null)
+ {
+ return parent;
+ }
+ else
+ {
+ return FindParent(parentObject);
+ }
+ }
+
+ // Helper method to find a child of a specific type and name
+ private T? FindChild(DependencyObject parent, string childName) where T : DependencyObject
+ {
+ if (parent == null) return null;
+
+ T? foundChild = null;
+
+ int childrenCount = VisualTreeHelper.GetChildrenCount(parent);
+ for (int i = 0; i < childrenCount; i++)
+ {
+ var child = VisualTreeHelper.GetChild(parent, i);
+ T? childType = child as T;
+ if (childType != null)
+ {
+ if (!string.IsNullOrEmpty(childName))
+ {
+ if (child is FrameworkElement frameworkElement && frameworkElement.Name == childName)
+ {
+ foundChild = (T)child;
+ break;
+ }
+ }
+ else
+ {
+ foundChild = (T)child;
+ break;
+ }
+ }
+
+ foundChild = FindChild(child, childName);
+ if (foundChild != null) break;
+ }
+
+ return foundChild;
+ }
+
+ private void Gather_ButtonClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button && button.Tag is ModItem modItem)
+ {
+ List updatedFiles = ModItem.Files.GetUpdatedGameFolderFiles(modItem);
+ var updatedFilesWindow = new UpdatedFilesWindow(modItem, updatedFiles);
+ _ = updatedFilesWindow.ShowDialog();
+ }
+ e.Handled = true;
+ }
+
+ private void OpenModFolder_ButtonClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button && button.Tag is ModItem modItem)
+ {
+ OpenFolder(PathBuilder.GetModStagingFolder(modItem.ModName));
+ }
+ e.Handled = true;
+ }
+
+ private void TextBlock_PreviewMouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (sender is TextBlock textBlock)
+ {
+ var hyperlink = textBlock.Inlines.OfType().FirstOrDefault();
+ if (hyperlink != null && hyperlink.DataContext is ModItem modItem)
+ {
+ var navigateUri = hyperlink.NavigateUri?.ToString();
+ if (string.IsNullOrWhiteSpace(navigateUri))
+ {
+ // Determine urlType based on the hyperlink text
+ string urlType = hyperlink.Inlines.OfType().FirstOrDefault()?.Text.Contains("Bethesda") == true ? "Bethesda" : "Nexus";
+ _viewModel.PromptForUrlIfNeeded(modItem, urlType);
+ e.Handled = true; // Mark the event as handled to prevent further processing
+ }
+ }
+ }
+ }
+
+ private void Promote_ButtonClicked(object sender, RoutedEventArgs e)
+ {
+ var button = sender as Button;
+ var modItem = button?.Tag as ModItem;
+ if (modItem != null)
+ {
+ var modActionWindow = new ModActionWindow(modItem, _viewModel.CurrentStages, "Promote");
+ _ = modActionWindow.ShowDialog();
+ }
+ e.Handled = true;
+ }
+
+ private void Package_ButtonClicked(object sender, RoutedEventArgs e)
+ {
+ var button = sender as Button;
+ var modItem = button?.Tag as ModItem;
+ if (modItem != null)
+ {
+ var modActionWindow = new ModActionWindow(modItem, _viewModel.CurrentStages, "Package");
+ _ = modActionWindow.ShowDialog();
+ }
+ e.Handled = true;
+ }
+
+ private void OnCurrentStageButtonClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button && button.Tag is ModItem modItem)
+ {
+ var modActionWindow = new ModActionWindow(modItem, _viewModel.CurrentStages, "Deploy");
+ if (modActionWindow.ShowDialog() == true)
+ {
+ // Handle the result from ModActionWindow
+ var selectedStage = modActionWindow.SelectedStage;
+ if (selectedStage != null)
+ {
+ _viewModel.LoadModItems(); // Refresh the mod items
+ }
+ }
+ }
+ }
+
+ private void OpenSettingsWindow(SettingsLaunchSource launchSource)
+ {
+ var settingsWindow = new SettingsWindow(launchSource);
+ _ = settingsWindow.ShowDialog();
+ }
+
+ private void SettingsButton_Click(object sender, RoutedEventArgs e)
+ {
+ OpenSettingsWindow(SettingsLaunchSource.MainWindow);
+ }
+ }
+
+}
+
diff --git a/App/MainWindowViewModel.cs b/App/MainWindowViewModel.cs
new file mode 100644
index 0000000..e61c624
--- /dev/null
+++ b/App/MainWindowViewModel.cs
@@ -0,0 +1,334 @@
+using System.Collections.ObjectModel;
+using System.Data.SQLite;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Windows;
+using System.Windows.Input;
+using MessageBox = System.Windows.MessageBox;
+
+namespace DevModManager
+{
+ public class MainWindowViewModel : ViewModelBase
+ {
+
+ public bool HasUnsavedChanges { get; set; }
+
+ private ObservableCollection _modItems;
+ private readonly Config _config;
+ private string _selectedModStage;
+ private ObservableCollection _currentStages;
+ private bool _isLoadingStages;
+ private bool _isLoadingModItems;
+ private bool _isMainWindowLoaded = false;
+ private ModItem _workingMod;
+
+ public ObservableCollection ModItems
+ {
+ get => _modItems;
+ set => SetProperty(ref _modItems, value);
+ }
+
+ public Config Config => _config;
+
+ public ICommand OpenSettingsCommand { get; }
+ public ICommand BackupCommand { get; }
+ public ICommand LaunchModManagerCommand { get; }
+ public ICommand LaunchIDECommand { get; }
+ public ICommand OpenGitHubCommand { get; }
+ public ICommand LoadOrderCommand { get; }
+ public ICommand OpenGameFolderCommand { get; }
+ public ICommand PromoteCommand { get; }
+
+ public ObservableCollection CurrentStages
+ {
+ get => _currentStages;
+ set => SetProperty(ref _currentStages, value);
+ }
+
+ public ModItem WorkingMod
+ {
+ get => _workingMod;
+ set
+ {
+ _workingMod = value;
+ OnPropertyChanged(nameof(WorkingMod));
+ }
+ }
+
+ public string SelectedModStage
+ {
+ get => _selectedModStage;
+ set
+ {
+ if (_selectedModStage != value)
+ {
+ _selectedModStage = value;
+ OnPropertyChanged();
+ //HandleSelectedModStageChange();
+ }
+ }
+ }
+
+ public MainWindowViewModel()
+ {
+ _config = Config.Instance ?? throw new InvalidOperationException("Config instance is not initialized.");
+ _modItems = new ObservableCollection();
+ _workingMod = new ModItem();
+ //LoadStages();
+ //LoadModItems();
+ OpenSettingsCommand = new RelayCommand(OpenSettings);
+ BackupCommand = new RelayCommand(ExecuteBackupCommand);
+ LaunchModManagerCommand = new RelayCommand(LaunchModManager);
+ LaunchIDECommand = new RelayCommand(LaunchIDE);
+ OpenGitHubCommand = new RelayCommand(OpenGitHub);
+ LoadOrderCommand = new RelayCommand(LoadOrder);
+ OpenGameFolderCommand = new RelayCommand(OpenGameFolder);
+ }
+
+
+ public void MainWindowLoaded(object sender, RoutedEventArgs e)
+ {
+ // Initialization logic
+ Debug.WriteLine("MWViewModel: MainWindow loaded.");
+ _isMainWindowLoaded = true;
+ }
+
+ public void FlushDatabase()
+ {
+ // Logic to flush the database
+ Debug.WriteLine("MWViewModel Flushing database before closing.");
+ }
+
+ public void LoadStages()
+ {
+ _isLoadingStages = true;
+ try
+ {
+ // Fetch stages for _currentStages and _sourceStages
+ var stagesQuery = "SELECT StageName FROM Stages WHERE isReserved = 0";
+ var stages = ExecuteStageQuery(stagesQuery);
+ stages.Insert(0, string.Empty); // Prepend string.Empty
+
+ _currentStages = new ObservableCollection(stages);
+ }
+ catch (Exception ex)
+ {
+ // Handle exceptions appropriately
+ _ = MessageBox.Show($"Failed to load stages: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ finally
+ {
+ _isLoadingStages = false;
+ }
+
+ _isLoadingStages = false;
+ }
+
+ private List ExecuteStageQuery(string query)
+ {
+ var stages = new List();
+ using (var connection = DbManager.Instance.GetConnection())
+ {
+ connection.Open();
+ using var command = new SQLiteCommand(query, connection);
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ stages.Add(reader.GetString(0));
+ }
+ }
+ return stages;
+ }
+
+ public void LoadModItems()
+ {
+ _isLoadingModItems = true;
+ try
+ {
+ ModItems = ModItem.LoadModItems();
+
+ if (!ModItems.Any())
+ {
+ ModItems = ModItem.BuildModItems();
+ }
+ }
+ finally
+ {
+ _isLoadingModItems = false;
+ }
+ _isLoadingModItems = false;
+ }
+
+ private void OpenGameFolder()
+ {
+ string gameFolder = Config.Instance.GameFolder;
+ if (!string.IsNullOrEmpty(gameFolder) && Directory.Exists(gameFolder))
+ {
+ _ = Process.Start(new ProcessStartInfo
+ {
+ FileName = gameFolder,
+ UseShellExecute = true,
+ Verb = "open"
+ });
+ }
+ else
+ {
+ _ = MessageBox.Show("Game folder is not set or does not exist.", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ public void PromptForUrlIfNeeded(ModItem modItem, string urlType)
+ {
+ var urlInputDialog = new UrlInputDialog(modItem, urlType);
+ if (urlInputDialog.ShowDialog() == true)
+ {
+ string newUrl = urlInputDialog.Url;
+ if (!string.IsNullOrWhiteSpace(newUrl))
+ {
+ if (urlType == "Bethesda" && string.IsNullOrWhiteSpace(modItem.BethesdaUrl))
+ {
+ modItem.BethesdaUrl = newUrl;
+ }
+ else if (urlType == "Nexus" && string.IsNullOrWhiteSpace(modItem.NexusUrl))
+ {
+ modItem.NexusUrl = newUrl;
+ }
+
+ SaveModItems();
+ OnPropertyChanged(nameof(ModItems));
+ }
+ else
+ {
+ // Close the window and make no change
+ _ = MessageBox.Show("URL cannot be empty. No changes were made.");
+ }
+ }
+ }
+
+ public void OpenSettings()
+ {
+ var settingsWindow = new SettingsWindow(SettingsLaunchSource.MainWindow);
+ _ = settingsWindow.ShowDialog();
+ }
+
+ private void Close()
+ {
+ // Implement the logic to close the window if needed
+ }
+
+ public void ExecuteBackupCommand()
+ {
+ var backedUpMods = new List();
+
+ foreach (var modItem in ModItems)
+ {
+ string backupFolder = PathBuilder.GetModSourceBackupFolder(modItem.ModName);
+ string backupZipPath = ModItem.Files.CreateBackup(modItem.ModFolderPath, backupFolder);
+
+ if (!string.IsNullOrEmpty(backupZipPath))
+ {
+ backedUpMods.Add(modItem.ModName);
+ }
+ }
+
+ // Save the updated ModItems to ModStatus.json
+ SaveModItems();
+ OnPropertyChanged(nameof(ModItems));
+
+ DisplayBackupResults(backedUpMods);
+ }
+
+ public void SaveModItems()
+ {
+ _ = Path.Combine(_config.RepoFolder ?? string.Empty, "ModStatus.json");
+ ModItem.SaveModItems(ModItems);
+ }
+
+ private void DisplayBackupResults(List backedUpMods)
+ {
+ if (!backedUpMods.Any())
+ {
+ _ = MessageBox.Show("All backed up files are current, nothing to do", "Backup Results", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var message = new StringBuilder();
+ foreach (var modName in backedUpMods)
+ {
+ _ = message.AppendLine(modName);
+ }
+ _ = message.AppendLine();
+ _ = message.AppendLine($"{backedUpMods.Count} mods backed up.");
+
+ _ = MessageBox.Show(message.ToString(), "Backup Results", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private void LaunchModManager()
+ {
+ var processInfo = new ProcessStartInfo
+ {
+ FileName = Config.Instance.ModManagerExecutable,
+ Arguments = Config.Instance.ModManagerParameters
+ };
+ _ = Process.Start(processInfo);
+ }
+
+ private void LaunchIDE()
+ {
+ var processInfo = new ProcessStartInfo
+ {
+ FileName = Config.Instance.IdeExecutable,
+ Arguments = string.Empty
+ };
+ _ = Process.Start(processInfo);
+ }
+
+ private void OpenGitHub()
+ {
+ _ = Process.Start("explorer.exe", Config.Instance.GitHubRepo ?? string.Empty);
+ }
+
+ public void LoadOrder()
+ {
+ // Launch the LoadOrder window
+ LoadOrderWindow loadOrderWindow = new LoadOrderWindow();
+ loadOrderWindow.Show();
+ }
+
+ public bool IsModFolderAccessible(ModItem modItem)
+ {
+ return !string.IsNullOrEmpty(modItem.DeployedStage) &&
+ !string.IsNullOrEmpty(modItem.ModDeploymentFolder) &&
+ Directory.Exists(PathBuilder.GetModStagingFolder(modItem.ModName));
+ }
+
+ //public void HandleSelectedModStageChange()
+ //{
+ // if (_isLoadingStages || _isLoadingModItems || !_isMainWindowLoaded)
+ // {
+ // MessageBox.Show($"Exiting early: _isLoadingStages={_isLoadingStages}, _isLoadingModItems={_isLoadingModItems}, _isMainWindowLoaded={_isMainWindowLoaded}");
+ // return;
+ // }
+
+ // if (WorkingMod == null)
+ // {
+ // MessageBox.Show("WorkingMod is null.");
+ // return;
+ // }
+
+ // if (string.IsNullOrEmpty(WorkingMod.ModName))
+ // {
+ // MessageBox.Show("WorkingMod.ModName is null or empty.");
+ // return;
+ // }
+
+ // // Call DeployStage with the selected ModItem and _selectedModStage
+ // ModStageManager.DeployStage(WorkingMod, SelectedModStage);
+ // // Refresh the ModFolderAccessibilityConverter
+ // ModItem.DB.WriteMod(WorkingMod);
+ // //MessageBox.Show($"MainWindowVw - Mod {WorkingMod.ModName} deployed to stage {SelectedModStage}");
+ // OnPropertyChanged();
+ //}
+ }
+}
diff --git a/App/ModActionWindow.xaml b/App/ModActionWindow.xaml
new file mode 100644
index 0000000..b069c22
--- /dev/null
+++ b/App/ModActionWindow.xaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/ModActionWindow.xaml.cs b/App/ModActionWindow.xaml.cs
new file mode 100644
index 0000000..f86a69f
--- /dev/null
+++ b/App/ModActionWindow.xaml.cs
@@ -0,0 +1,122 @@
+using System.Collections.ObjectModel;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace DevModManager
+{
+ public partial class ModActionWindow : Window
+ {
+ public string SelectedStage { get; private set; }
+ private readonly ModItem _modItem;
+ private readonly ObservableCollection _stages;
+
+ public ModActionWindow(ModItem modItem, ObservableCollection stages, string actionType)
+ {
+ InitializeComponent();
+ _modItem = modItem;
+ _stages = stages;
+ SourceStageComboBox.ItemsSource = _stages;
+ TargetStageComboBox.ItemsSource = _stages;
+ SourceStageComboBox.SelectedItem = _modItem.DeployedStage;
+
+ InitializeUI(actionType);
+ }
+
+ private void InitializeUI(string actionType)
+ {
+ switch (actionType)
+ {
+ case "Promote":
+ Title = "Promote Mod to new Stage";
+ ActionButton.Content = "Promote";
+ SetTwoBoxLayout();
+ break;
+ case "Package":
+ Title = "Package Mod for Distribution";
+ ActionButton.Content = "Package";
+ SetOneBoxLayout();
+ break;
+ case "Deploy":
+ Title = "Deploy Mod to ModManager";
+ ActionButton.Content = "Deploy";
+ SetOneBoxLayout();
+ break;
+ }
+
+ // Set the action message
+ ActionMessageTextBlock.Text = $"Taking action on : {_modItem.ModName}";
+ }
+
+ private void SetOneBoxLayout()
+ {
+ SourceStageLabel.Visibility = Visibility.Collapsed;
+ SourceStageComboBox.Visibility = Visibility.Collapsed;
+ TargetStageLabel.SetValue(System.Windows.Controls.Grid.ColumnProperty, 0);
+ TargetStageLabel.SetValue(System.Windows.Controls.Grid.ColumnSpanProperty, 2);
+ TargetStageComboBox.ItemsSource = _modItem.AvailableStages;
+ TargetStageComboBox.SetValue(System.Windows.Controls.Grid.ColumnProperty, 0);
+ TargetStageComboBox.SetValue(System.Windows.Controls.Grid.ColumnSpanProperty, 2);
+ }
+
+ private void SetTwoBoxLayout()
+ {
+ SourceStageLabel.Visibility = Visibility.Visible;
+ SourceStageComboBox.Visibility = Visibility.Visible;
+ SourceStageComboBox.ItemsSource = _modItem.AvailableStages;
+ SourceStageComboBox.SelectionChanged += SourceStageComboBox_SelectionChanged;
+ TargetStageLabel.SetValue(System.Windows.Controls.Grid.ColumnProperty, 1);
+ TargetStageLabel.SetValue(System.Windows.Controls.Grid.ColumnSpanProperty, 1);
+ TargetStageComboBox.SetValue(System.Windows.Controls.Grid.ColumnProperty, 1);
+ TargetStageComboBox.SetValue(System.Windows.Controls.Grid.ColumnSpanProperty, 1);
+ UpdateTargetStageComboBox();
+ }
+
+ private void SourceStageComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ UpdateTargetStageComboBox();
+ }
+
+ private void UpdateTargetStageComboBox()
+ {
+ var selectedSourceStage = SourceStageComboBox.SelectedItem as string;
+ var validStages = ModItem.DB.GetDeployableStages();
+
+ if (selectedSourceStage != null)
+ {
+ validStages = validStages.Where(stage => stage != selectedSourceStage).ToList();
+ }
+
+ TargetStageComboBox.ItemsSource = validStages;
+ }
+
+ private void ActionButton_Click(object sender, RoutedEventArgs e)
+ {
+ string selectedStage = TargetStageComboBox.SelectedItem as string ?? string.Empty;
+ SelectedStage = selectedStage;
+
+ if (ActionButton.Content.ToString() == "Deploy")
+ {
+ // Handle deploy logic
+ ModStageManager.DeployStage(_modItem, selectedStage);
+ }
+ else if (ActionButton.Content.ToString() == "Package")
+ {
+ // Handle package logic
+ ModStageManager.PackageMod(_modItem, selectedStage);
+ }
+ else
+ {
+ // Handle promote logic
+ _ = ModStageManager.PromoteModStage(_modItem, SourceStageComboBox.SelectedItem as string, selectedStage);
+ }
+ DialogResult = true;
+ _modItem.SaveMod();
+ Close();
+ }
+
+ }
+}
+
+
+
+
diff --git a/App/ModGroupEditorWindow.xaml b/App/ModGroupEditorWindow.xaml
new file mode 100644
index 0000000..b5cd477
--- /dev/null
+++ b/App/ModGroupEditorWindow.xaml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/ModGroupEditorWindow.xaml.cs b/App/ModGroupEditorWindow.xaml.cs
new file mode 100644
index 0000000..ffdcbcd
--- /dev/null
+++ b/App/ModGroupEditorWindow.xaml.cs
@@ -0,0 +1,85 @@
+using System.Windows;
+
+namespace DevModManager
+{
+ public partial class ModGroupEditorWindow : Window
+ {
+ private ModGroup _modGroup;
+ private List _allPlugins;
+
+ public ModGroupEditorWindow(ModGroup modGroup, List allPlugins)
+ {
+ InitializeComponent();
+ _modGroup = modGroup;
+ _allPlugins = allPlugins;
+
+ // Bind data to UI elements
+ DescriptionTextBox.Text = _modGroup.Description;
+ PluginIDsTextBox.Text = string.Join(", ", _modGroup.PluginIDs);
+ UpdatePluginsListBox();
+ }
+
+ private void SaveButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Update ModGroup properties
+ _modGroup.Description = DescriptionTextBox.Text;
+
+ // Parse PluginIDs from the TextBox
+ var pluginIDs = PluginIDsTextBox.Text.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(id => int.Parse(id.Trim()))
+ .ToList();
+
+ // Update PluginIDs and Plugins collections
+ _modGroup.PluginIDs.Clear();
+ _modGroup.Plugins.Clear();
+ foreach (var id in pluginIDs)
+ {
+ _modGroup.PluginIDs.Add(id);
+ var plugin = _allPlugins.FirstOrDefault(p => p.ModID == id);
+ if (plugin != null)
+ {
+ _modGroup.Plugins.Add(plugin);
+ }
+ }
+
+ DialogResult = true;
+ Close();
+ }
+
+ private void CancelButton_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+
+ private void PluginIDsTextBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
+ {
+ // Parse PluginIDs from the TextBox
+ var pluginIDs = PluginIDsTextBox.Text.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(id => int.Parse(id.Trim()))
+ .ToList();
+
+ // Update Plugins collection
+ _modGroup.Plugins.Clear();
+ foreach (var id in pluginIDs)
+ {
+ var plugin = _allPlugins.FirstOrDefault(p => p.ModID == id);
+ if (plugin != null)
+ {
+ _modGroup.Plugins.Add(plugin);
+ }
+ }
+
+ // Update the display of PluginIDsTextBox
+ PluginIDsTextBox.Text = string.Join(", ", _modGroup.Plugins.Select(p => p.ModID));
+
+ // Update the PluginsListBox
+ UpdatePluginsListBox();
+ }
+
+ private void UpdatePluginsListBox()
+ {
+ PluginsListBox.ItemsSource = _modGroup.Plugins.Select(p => p.PluginName).ToList();
+ }
+ }
+}
diff --git a/App/ModItem.DB.cs b/App/ModItem.DB.cs
new file mode 100644
index 0000000..a4f5f0f
--- /dev/null
+++ b/App/ModItem.DB.cs
@@ -0,0 +1,393 @@
+using System.Collections.ObjectModel;
+using System.Data.SQLite;
+using System.IO;
+
+namespace DevModManager
+{
+ public partial class ModItem
+ {
+ public class DB
+ {
+ //write moditems into the database
+
+
+ public static void WriteMod(ModItem modItem)
+ {
+ using var connection = DbManager.Instance.GetConnection();
+ connection.Open();
+ using var transaction = connection.BeginTransaction();
+ // Insert or replace ModItem
+ string query = @"
+ INSERT OR REPLACE INTO ModItems (ModName, ModFolderPath, CurrentStageID)
+ VALUES (@ModName, @ModFolderPath,
+ (SELECT StageId FROM Stages WHERE StageName = @DeployedStage))";
+ using (var command = new SQLiteCommand(query, connection))
+ {
+ _ = command.Parameters.AddWithValue("@ModName", modItem.ModName);
+ _ = command.Parameters.AddWithValue("@ModFolderPath", modItem.ModFolderPath);
+
+ if (modItem.DeployedStage != null)
+ {
+ _ = command.Parameters.AddWithValue("@DeployedStage", modItem.DeployedStage);
+ }
+ else
+ {
+ command.Parameters.AddWithValue("@DeployedStage", DBNull.Value);
+ }
+
+ _ = command.ExecuteNonQuery();
+ }
+
+ // Insert or replace files
+ foreach (var fileEntry in modItem.ModFiles)
+ {
+ query = @"
+ INSERT OR REPLACE INTO FileInfo (ModID, StageID, Filename, RelativePath, DTStamp, HASH, isArchive)
+ VALUES ((SELECT ModID FROM ModItems WHERE ModName = @ModName),(SELECT StageID FROM Stages WHERE StageName = @Stage),@FullPath, @RelativePath, @DTStamp, @HASH, @isArchive)";
+
+ using var command = new SQLiteCommand(query, connection);
+ _ = command.Parameters.AddWithValue("@ModName", modItem.ModName);
+ command.Parameters.AddWithValue("@Stage", fileEntry.Value.Stage);
+ _ = command.Parameters.AddWithValue("@FullPath", Path.Combine(modItem.ModFolderPath, fileEntry.Value.RelativePath));
+ command.Parameters.AddWithValue("@RelativePath", fileEntry.Value.RelativePath);
+ command.Parameters.AddWithValue("@DTStamp", fileEntry.Value.Timestamp);
+ command.Parameters.AddWithValue("@HASH", fileEntry.Value.Hash);
+ _ = command.Parameters.AddWithValue("@isArchive", 0); // Assuming these are not archive files
+
+ _ = command.ExecuteNonQuery();
+ }
+ foreach (var archiveEntry in modItem.CurrentArchiveFiles)
+ {
+ string fileName = Path.GetFileNameWithoutExtension(archiveEntry.Value.Path);
+ if (fileName == null)
+ { continue; }
+ string timestampString = fileName.Substring(fileName.LastIndexOf('_') + 1);
+ DateTime dtStamp;
+
+ if (!DateTime.TryParseExact(timestampString, Config.Instance.TimestampFormat, null, System.Globalization.DateTimeStyles.None, out dtStamp))
+ {
+ dtStamp = DateTime.Now; // Fallback to current time if parsing fails
+ }
+
+ query = @"
+ INSERT OR REPLACE INTO FileInfo (ModID, StageID, Filename, RelativePath, DTStamp, HASH, isArchive)
+ VALUES ((SELECT ModID FROM ModItems WHERE ModName = @ModName),(SELECT StageID FROM Stages WHERE StageName = @Stage),@FullPath, @RelativePath, @DTStamp, @HASH, @isArchive)";
+
+ using var command = new SQLiteCommand(query, connection);
+ _ = command.Parameters.AddWithValue("@ModName", modItem.ModName);
+ command.Parameters.AddWithValue("@Stage", archiveEntry.Value.Stage);
+ command.Parameters.AddWithValue("@FullPath", archiveEntry.Value.Path);
+ command.Parameters.AddWithValue("@RelativePath", DBNull.Value);
+ command.Parameters.AddWithValue("@DTStamp", dtStamp);
+ command.Parameters.AddWithValue("@HASH", DBNull.Value); // Assuming no hash for archives
+ _ = command.Parameters.AddWithValue("@isArchive", 1); // These are archive files
+
+ _ = command.ExecuteNonQuery();
+ }
+ // Insert or replace available stages
+ if (modItem.AvailableStages != null)
+ {
+ foreach (var stage in modItem.AvailableStages)
+ {
+ query = @"
+ INSERT OR REPLACE INTO ModStages (ModID, StageID)
+ VALUES ((SELECT ModID FROM ModItems WHERE ModName = @ModName),
+ (SELECT StageID FROM Stages WHERE StageName = @StageName))";
+ using var command = new SQLiteCommand(query, connection);
+ _ = command.Parameters.AddWithValue("@ModName", modItem.ModName);
+ command.Parameters.AddWithValue("@StageName", stage);
+ _ = command.ExecuteNonQuery();
+ }
+ }
+
+ // Insert or replace external IDs
+ if (!string.IsNullOrEmpty(modItem.NexusUrl) || !string.IsNullOrEmpty(modItem.BethesdaUrl))
+ {
+ query = @"
+ INSERT OR REPLACE INTO ExternalIDs (ModID, NexusID, BethesdaID)
+ VALUES ((SELECT ModID FROM ModItems WHERE ModName = @ModName), @NexusID, @BethesdaID)";
+ using var command = new SQLiteCommand(query, connection);
+ _ = command.Parameters.AddWithValue("@ModName", modItem.ModName);
+ _ = command.Parameters.AddWithValue("@NexusID", string.IsNullOrEmpty(modItem.NexusUrl) ? (object)DBNull.Value : ExtractID(modItem.NexusUrl));
+ _ = command.Parameters.AddWithValue("@BethesdaID", string.IsNullOrEmpty(modItem.BethesdaUrl) ? (object)DBNull.Value : ExtractID(modItem.BethesdaUrl));
+ _ = command.ExecuteNonQuery();
+ }
+
+ transaction.Commit();
+ }
+
+ public static string ExtractID(string url)
+ {
+ var uri = new Uri(url);
+ return uri.Segments.Last().TrimEnd('/');
+ }
+
+
+ public static void SaveToDatabase(ObservableCollection modItems)
+ {
+ foreach (var modItem in modItems)
+ {
+ WriteMod(modItem);
+ }
+ }
+
+ //Load ModItems from the database
+
+ public static ObservableCollection LoadFromDatabase()
+ {
+ var modItems = new ObservableCollection();
+ using (var connection = DbManager.Instance.GetConnection())
+ {
+ connection.Open();
+ string query = "SELECT * FROM vwModItems";
+
+ using var command = new SQLiteCommand(query, connection);
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ var modItem = new ModItem
+ {
+ ModName = reader.IsDBNull(reader.GetOrdinal("ModName")) ? string.Empty : reader.GetString(reader.GetOrdinal("ModName")),
+ ModFolderPath = reader.IsDBNull(reader.GetOrdinal("ModFolderPath")) ? string.Empty : reader.GetString(reader.GetOrdinal("ModFolderPath")),
+ DeployedStage = reader.IsDBNull(reader.GetOrdinal("CurrentStage")) ? string.Empty : reader.GetString(reader.GetOrdinal("CurrentStage")),
+ NexusUrl = reader.IsDBNull(reader.GetOrdinal("NexusID")) ? string.Empty : $"https://www.nexusmods.com/starfield/mods/{reader.GetString(reader.GetOrdinal("NexusID"))}",
+ BethesdaUrl = reader.IsDBNull(reader.GetOrdinal("BethesdaID")) ? string.Empty : $"https://creations.bethesda.net/en/starfield/details/{reader.GetString(reader.GetOrdinal("BethesdaID"))}"
+ };
+
+ // Populate the CurrentArchiveFiles dictionary
+ var latestModArchives = GetLatestModArchives(modItem);
+ foreach (var archive in latestModArchives)
+ {
+ modItem.CurrentArchiveFiles[archive.Key] = archive.Value;
+ }
+
+ GetModFiles(modItem);
+ modItem.AvailableStages = GetAvailableStages(modItem.ModName);
+ modItems.Add(modItem);
+
+ }
+ }
+
+ return modItems;
+ }
+
+
+ private static List GetAvailableStages(string modName)
+ {
+ var stages = new List();
+ using (var connection = DbManager.Instance.GetConnection())
+ {
+ connection.Open();
+ string query = "SELECT StageName FROM vwModStages WHERE ModName = @ModName";
+
+ using var command = new SQLiteCommand(query, connection);
+ _ = command.Parameters.AddWithValue("@ModName", modName);
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ stages.Add(reader.GetString(0));
+ }
+ }
+
+ return stages;
+ }
+
+ public static void LoadModFiles(ModItem modItem)
+ {
+ if (Directory.Exists(modItem.ModFolderPath))
+ {
+ var files = Directory.GetFiles(modItem.ModFolderPath, "*.*", SearchOption.AllDirectories);
+ foreach (var file in files)
+ {
+ var relativePath = Path.GetRelativePath(modItem.ModFolderPath, file);
+ var timestamp = File.GetLastWriteTime(file);
+ var hash = ModItem.Files.ComputeHash(file);
+
+ modItem.ModFiles[relativePath] = (ModItem.DB.GetSourceStage(), relativePath, timestamp, hash);
+ }
+ }
+ }
+
+
+ //Work with Stages
+
+
+ public static List GetStages()
+ {
+ var stages = new List();
+ using (var connection = DbManager.Instance.GetConnection())
+ {
+ connection.Open();
+ string query = "SELECT StageName FROM Stages";
+
+ using var command = new SQLiteCommand(query, connection);
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ stages.Add(reader.GetString(0));
+ }
+ }
+
+ return stages;
+ }
+
+ public static string GetSourceStage()
+ {
+ using var connection = DbManager.Instance.GetConnection();
+ connection.Open();
+ string query = "SELECT StageName FROM Stages WHERE isSource = 1";
+
+ using var command = new SQLiteCommand(query, connection);
+ return command.ExecuteScalar() as string ?? string.Empty;
+ }
+
+
+ public static List GetDeployableStages()
+ {
+ var deployableStages = new List();
+ using (var connection = DbManager.Instance.GetConnection())
+ {
+ connection.Open();
+ string query = "SELECT StageName FROM Stages WHERE isSource = 0 AND isReserved = 0";
+
+ using var command = new SQLiteCommand(query, connection);
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ deployableStages.Add(reader.GetString(0));
+ }
+ }
+ return deployableStages;
+ }
+
+
+ public static void UpdateModItemStage(string modName, string newStage)
+ {
+
+ using var connection = DbManager.Instance.GetConnection();
+ connection.Open();
+ string query = @"
+ UPDATE ModItems
+ SET CurrentStageID = (SELECT StageID FROM Stages WHERE StageName = @NewStage)
+ WHERE ModName = @ModName";
+
+ using var command = new SQLiteCommand(query, connection);
+ _ = command.Parameters.AddWithValue("@ModName", modName);
+ _ = command.Parameters.AddWithValue("@NewStage", newStage);
+ _ = command.ExecuteNonQuery();
+ }
+
+ public static ModItem GetModItemByName(string modName)
+ {
+ using var connection = DbManager.Instance.GetConnection();
+ connection.Open();
+ string query = "SELECT ModName, DeployedStage, NexusID, BethesdaID FROM vwModItems WHERE ModName = @ModName";
+
+ using var command = new SQLiteCommand(query, connection);
+ _ = command.Parameters.AddWithValue("@ModName", modName);
+
+ using var reader = command.ExecuteReader();
+ if (reader.Read())
+ {
+ var modItem = new ModItem
+ {
+ ModName = reader.IsDBNull(reader.GetOrdinal("ModName")) ? string.Empty : reader.GetString(reader.GetOrdinal("ModName")),
+ DeployedStage = reader.IsDBNull(reader.GetOrdinal("DeployedStage")) ? string.Empty : reader.GetString(reader.GetOrdinal("DeployedStage")),
+ NexusUrl = reader.IsDBNull(reader.GetOrdinal("NexusID")) ? string.Empty : $"https://www.nexusmods.com/starfield/mods/{reader.GetString(reader.GetOrdinal("NexusID"))}",
+ BethesdaUrl = reader.IsDBNull(reader.GetOrdinal("BethesdaID")) ? string.Empty : $"https://creations.bethesda.net/en/starfield/details/{reader.GetString(reader.GetOrdinal("BethesdaID"))}"
+ };
+
+ LoadModFiles(modItem);
+ return modItem;
+ }
+
+ return null;
+ }
+
+ public static int GetModID(ModItem modItem)
+ {
+ using var connection = DbManager.Instance.GetConnection();
+ connection.Open();
+ string query = "SELECT ModID FROM ModItems WHERE ModName = @ModName";
+
+ using var command = new SQLiteCommand(query, connection);
+ _ = command.Parameters.AddWithValue("@ModName", modItem.ModName);
+ return Convert.ToInt32(command.ExecuteScalar());
+ }
+
+ public static Dictionary GetModFiles(ModItem modItem)
+ {
+ var modFiles = new Dictionary();
+ int modID = GetModID(modItem);
+
+ using (var connection = DbManager.Instance.GetConnection())
+ {
+ connection.Open();
+ string query = "SELECT RelativePath, DTStamp, HASH FROM FileInfo WHERE ModID = @ModID AND isArchive = 0";
+
+ using var command = new SQLiteCommand(query, connection);
+ _ = command.Parameters.AddWithValue("@ModID", modID);
+
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ var relativePath = reader.GetString(0);
+ var timestamp = reader.GetDateTime(1);
+ var hash = reader.GetString(2);
+
+ modFiles[relativePath] = (relativePath, timestamp, hash);
+ }
+ }
+
+ return modFiles;
+ }
+
+ public static Dictionary GetModArchives(ModItem modItem)
+ {
+ var modArchives = new Dictionary();
+ int modID = GetModID(modItem);
+
+ using (var connection = DbManager.Instance.GetConnection())
+ {
+ connection.Open();
+ string query = "SELECT StageName, FileName FROM vwModArchives WHERE ModId = @ModID";
+
+ using var command = new SQLiteCommand(query, connection);
+ _ = command.Parameters.AddWithValue("@ModID", modID);
+
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ var stage = reader.GetString(0);
+ var path = reader.GetString(1);
+
+ modArchives[stage] = (stage, path);
+ }
+ }
+
+ return modArchives;
+ }
+
+ public static Dictionary GetLatestModArchives(ModItem modItem)
+ {
+ var latestModArchives = new Dictionary();
+ var modArchives = GetModArchives(modItem);
+
+ foreach (var stage in modArchives.Keys)
+ {
+ var latestArchive = modArchives
+ .Where(archive => archive.Key == stage)
+ .OrderByDescending(archive => archive.Value.Path)
+ .FirstOrDefault();
+
+ if (!string.IsNullOrEmpty(latestArchive.Value.Path))
+ {
+ latestModArchives[stage] = latestArchive.Value;
+ }
+ }
+
+ return latestModArchives;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/App/ModItem.Files.cs b/App/ModItem.Files.cs
new file mode 100644
index 0000000..ea44379
--- /dev/null
+++ b/App/ModItem.Files.cs
@@ -0,0 +1,230 @@
+using SharpCompress.Common;
+using SharpCompress.Writers;
+using System.Diagnostics;
+using System.IO;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Windows;
+using MessageBox = System.Windows.MessageBox;
+
+namespace DevModManager
+{
+ public partial class ModItem
+ {
+ public partial class Files
+ {
+ private static readonly Config _config = Config.Instance;
+ public static readonly string[] ArchiveFileTypes = { ".zip", ".7z" };
+
+ public static void CreateArchiveFromDirectory(string sourceDirectory, string archivePath)
+ {
+ if (Directory.Exists(sourceDirectory))
+ {
+ CreateArchiveWithRetry(sourceDirectory, archivePath);
+ }
+ }
+
+ private static void CreateArchiveWithRetry(string sourceDirectory, string archivePath)
+ {
+ const int maxRetries = 3;
+ int attempts = 0;
+ while (attempts < maxRetries)
+ {
+ try
+ {
+ if (_config.ArchiveFormat == "zip")
+ {
+ using var zipArchive = ZipFile.Open(archivePath, ZipArchiveMode.Create);
+ AddDirectoryToArchive(zipArchive, sourceDirectory, sourceDirectory);
+ }
+ else if (_config.ArchiveFormat == "7z")
+ {
+ Create7zArchive(sourceDirectory, archivePath);
+ }
+ break;
+ }
+ catch (IOException ex) when (ex.Message.Contains("because it is being used by another process"))
+ {
+ attempts++;
+ if (attempts >= maxRetries)
+ throw;
+ Thread.Sleep(1000);
+ }
+ }
+ }
+
+ private static void AddDirectoryToArchive(ZipArchive zipArchive, string sourceDirectory, string baseDirectory)
+ {
+ var files = Directory.GetFiles(sourceDirectory, "*.*", SearchOption.AllDirectories);
+ var addedFiles = new HashSet();
+
+ foreach (var file in files)
+ {
+ var relativePath = Path.GetRelativePath(baseDirectory, file);
+ if (!addedFiles.Contains(relativePath))
+ {
+ _ = zipArchive.CreateEntryFromFile(file, relativePath);
+ _ = addedFiles.Add(relativePath);
+ }
+ }
+ }
+
+ private static void Create7zArchive(string sourceDirectory, string archivePath)
+ {
+ using var archiveStream = File.Create(archivePath);
+ using var writer = WriterFactory.Open(archiveStream, ArchiveType.SevenZip, CompressionType.LZMA);
+ var files = Directory.GetFiles(sourceDirectory, "*.*", SearchOption.AllDirectories);
+ foreach (var file in files)
+ {
+ var relativePath = Path.GetRelativePath(sourceDirectory, file);
+ writer.Write(relativePath, file);
+ }
+ }
+
+ public static void CreateJunctionPoint(string junctionPoint, string targetDir)
+ {
+ _ = Directory.CreateDirectory(Path.GetDirectoryName(junctionPoint));
+
+ if (Directory.Exists(junctionPoint))
+ {
+ Directory.Delete(junctionPoint, true);
+ }
+
+ var processInfo = new ProcessStartInfo("cmd.exe", $"/c mklink /J \"{junctionPoint}\" \"{targetDir}\"")
+ {
+ CreateNoWindow = true,
+ UseShellExecute = false
+ };
+ Process.Start(processInfo)?.WaitForExit();
+ }
+
+ public static void RemoveJunctionPoint(string path)
+ {
+ if (Directory.Exists(path) && IsJunctionPoint(path))
+ {
+ Directory.Delete(path, true);
+ }
+ }
+
+ private static bool IsJunctionPoint(string path)
+ {
+ var dirInfo = new DirectoryInfo(path);
+ return dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint);
+ }
+ public static string GetNewestFile(string directory, string[] fileTypes)
+
+ {
+ var files = Directory.GetFiles(directory, "*.*", SearchOption.AllDirectories)
+ .Where(f => fileTypes == null || fileTypes.Any(ext => f.EndsWith(ext)))
+ .OrderByDescending(f => new FileInfo(f).LastWriteTime)
+ .FirstOrDefault();
+
+ return files ?? string.Empty;
+ }
+
+ public static string CreateBackup(string sourcePath, string backupFolder)
+ {
+ string timestamp = DateTime.Now.ToString(_config.TimestampFormat);
+ string archiveExtension = _config.ArchiveFormat == "7z" ? ".7z" : ".zip";
+ string archivePath = Path.Combine(backupFolder, $"{Path.GetFileName(sourcePath)}_{timestamp}{archiveExtension}");
+
+ // Ensure backupFolder exists
+ if (!Directory.Exists(backupFolder))
+ {
+ _ = Directory.CreateDirectory(backupFolder);
+ }
+
+ // Get the newest file in the backup folder
+ var existingFiles = Directory.GetFiles(backupFolder);
+ var latestBackupFile = existingFiles.OrderByDescending(f => new FileInfo(f).LastWriteTime).FirstOrDefault();
+ DateTime latestBackupTime = latestBackupFile != null ? new FileInfo(latestBackupFile).LastWriteTime : DateTime.MinValue;
+
+ // Get the newest file in the source folder
+ var stagingFiles = Directory.GetFiles(sourcePath);
+ var latestStagingFile = stagingFiles.OrderByDescending(f => new FileInfo(f).LastWriteTime).FirstOrDefault();
+ DateTime latestStagingTime = latestStagingFile != null ? new FileInfo(latestStagingFile).LastWriteTime : DateTime.MinValue;
+
+ // Compare the times
+ if (latestStagingTime <= latestBackupTime)
+ {
+ if (_config.ShowOverwriteMessage)
+ {
+ string latestBackupFileName = latestBackupFile != null ? Path.GetFileName(latestBackupFile) : "unknown";
+ MessageBoxResult result = MessageBox.Show($"{latestBackupFileName} is up to date. Do you still want to create a backup?", "Backup Not Needed", MessageBoxButton.YesNo, MessageBoxImage.Information);
+ if (result == MessageBoxResult.No)
+ {
+ return string.Empty; // No backup needed
+ }
+ }
+ }
+
+ // Create the archive file from the sourcePath without deleting the source files
+ if (File.Exists(archivePath))
+ {
+ File.Delete(archivePath); // Overwrite the existing file
+ }
+ ZipFile.CreateFromDirectory(sourcePath, archivePath);
+
+ return archivePath;
+ }
+
+ public static string ComputeHash(string filePath)
+ {
+ using var sha256 = SHA256.Create();
+ using var stream = File.OpenRead(filePath);
+ var hashBytes = sha256.ComputeHash(stream);
+ return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
+ }
+
+ public static List GetUpdatedGameFolderFiles(ModItem modItem)
+ {
+ //ModItem.CheckDeploymentStatus(modItem); because of how this is called, we assume this is valid.
+ string gameFolder = Config.Instance.GameFolder;
+ string deployFolder = PathBuilder.GetDeployBackupFolder(modItem.ModName);
+ string latestFile = GetNewestFile(deployFolder, ArchiveFileTypes);
+ DateTime referenceTimestamp = ParseFileTimestamp(latestFile);
+ string[] files = Directory.GetFiles(gameFolder, "*.*", SearchOption.AllDirectories);
+ List newerFiles = new List();
+ foreach (string file in files)
+ {
+ FileInfo fileInfo = new FileInfo(file);
+ if (fileInfo.LastWriteTime > referenceTimestamp)
+ {
+ newerFiles.Add(file);
+ }
+ }
+ return newerFiles;
+ }
+
+ public static DateTime ParseFileTimestamp(string filePath)
+ {
+ // Extract the file name from the file path
+ string fileName = Path.GetFileNameWithoutExtension(filePath);
+
+ // Find the last underscore in the file name
+ int lastUnderscoreIndex = fileName.LastIndexOf('_');
+ if (lastUnderscoreIndex == -1)
+ {
+ throw new FormatException("The file name does not contain a timestamp.");
+ }
+
+ // Extract the timestamp part from the file name
+ string timestampPart = fileName.Substring(lastUnderscoreIndex + 1);
+
+ // Parse the timestamp using the configured timestamp format
+ if (DateTime.TryParseExact(timestampPart, Config.Instance.TimestampFormat, null, System.Globalization.DateTimeStyles.None, out DateTime timestamp))
+ {
+ return timestamp;
+ }
+ else
+ {
+ _ = MessageBox.Show($"Failed to parse the timestamp from the file name: {fileName}\r\nAttempting to return the file creation time", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ return File.GetCreationTime(filePath);
+ }
+ }
+
+
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/App/ModItem.cs b/App/ModItem.cs
new file mode 100644
index 0000000..8e34bfe
--- /dev/null
+++ b/App/ModItem.cs
@@ -0,0 +1,309 @@
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Windows;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace DevModManager
+{
+ public partial class ModItem
+ {
+ public string ModName { get; set; } = string.Empty;
+ public string ModFolderPath { get; set; } = string.Empty;
+ public string? DeployedStage { get; set; }
+ public Dictionary CurrentArchiveFiles { get; set; } = new Dictionary();
+ public string? NexusUrl { get; set; }
+ public string? BethesdaUrl { get; set; }
+ public string? ModDeploymentFolder { get; set; }
+ public Dictionary ModFiles { get; set; } = new Dictionary();
+ public List AvailableStages { get; set; } = new List();
+
+
+
+ public void SaveMod()
+ {
+ DB.WriteMod(this);
+ }
+
+
+ public static void SaveModItems(ObservableCollection modItems)
+ {
+ ModItem.DB.SaveToDatabase(modItems);
+ SaveToYaml(modItems);
+ }
+
+ private static void SaveToYaml(ObservableCollection modItems)
+ {
+ var config = Config.Instance;
+ if (config.RepoFolder == null)
+ {
+ throw new InvalidOperationException("RepoFolder is not configured.");
+ }
+
+ var serializer = new SerializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .Build();
+
+ string yaml = serializer.Serialize(modItems);
+ File.WriteAllText(Path.Combine(config.RepoFolder, "ModStatus.yaml"), yaml);
+ }
+
+ public static ObservableCollection LoadModItems()
+ {
+ _ = new ObservableCollection();
+
+ ObservableCollection modItems = DB.LoadFromDatabase();
+ if (modItems.Count > 0) return modItems;
+
+ // Fallback to LoadFromYaml
+ modItems = LoadFromYaml();
+ if (modItems.Count > 0) return modItems;
+
+ // Fallback to BuildModItems
+ modItems = BuildModItems();
+
+ // If modItems is still empty, show an error window
+ if (modItems.Count == 0)
+ {
+ _ = System.Windows.MessageBox.Show(
+ "The collection of mod items is empty. Please ensure the directory structure is laid out as specified in the README.",
+ "Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ System.Windows.Application.Current.Shutdown();
+ }
+
+ return modItems;
+ }
+
+ private static ObservableCollection LoadFromYaml()
+ {
+ var config = Config.Instance;
+ if (config.RepoFolder == null)
+ {
+ throw new InvalidOperationException("RepoFolder is not configured.");
+ }
+
+ var yamlFilePath = Path.Combine(config.RepoFolder, "ModStatus.yaml");
+
+ if (!File.Exists(yamlFilePath))
+ {
+ return new ObservableCollection();
+ }
+
+ var deserializer = new DeserializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .Build();
+
+ var yamlContent = File.ReadAllText(yamlFilePath);
+ var modItems = deserializer.Deserialize>(yamlContent);
+
+ return modItems ?? new ObservableCollection();
+ }
+
+ public static ObservableCollection BuildModItems()
+ {
+ var config = Config.Instance;
+ if (config.RepoFolder == null)
+ {
+ throw new InvalidOperationException("RepoFolder is not configured.");
+ }
+
+ var modItems = new ObservableCollection();
+
+ // Fetch stages from the database
+ var stages = ModItem.DB.GetStages();
+ string sourceStage = ModItem.DB.GetSourceStage();
+ string sourceFolderPath = Path.Combine(config.RepoFolder, sourceStage);
+
+ if (Directory.Exists(sourceFolderPath))
+ {
+ var modFolders = Directory.GetDirectories(sourceFolderPath);
+ foreach (var modFolder in modFolders)
+ {
+ var modName = Path.GetFileName(modFolder);
+ var modItem = new ModItem
+ {
+ ModName = modName,
+ ModFolderPath = modFolder,
+ DeployedStage = string.Empty, // Start with an empty stage
+ NexusUrl = string.Empty, // Initialize as empty
+ BethesdaUrl = string.Empty, // Initialize as empty
+ ModDeploymentFolder = string.Empty // Initialize as empty
+ };
+
+ // Set the initial AvailableStages
+ modItem.AvailableStages.Add(sourceStage);
+ modItem.AvailableStages = modItem.AvailableStages.Distinct().ToList();
+
+ // Load files with relative paths, timestamps, and hashes
+ GatherModFiles(modItem);
+
+ // Check deployment status
+ CheckDeploymentStatus(modItem);
+
+ // Get the latest archives for each stage
+ foreach (var stage in stages)
+ {
+ var latestZip = GetLatestZip(modName, stage);
+ if (!string.IsNullOrEmpty(latestZip))
+ {
+ modItem.CurrentArchiveFiles[stage] = (stage, latestZip);
+ modItem.AvailableStages.Add(sourceStage);
+ modItem.AvailableStages = modItem.AvailableStages.Distinct().ToList();
+ }
+ }
+
+ // Ensure no empty entries are added
+ var latestModArchives = ModItem.DB.GetLatestModArchives(modItem);
+ foreach (var archive in latestModArchives)
+ {
+ if (!string.IsNullOrEmpty(archive.Value.Path))
+ {
+ modItem.CurrentArchiveFiles[archive.Key] = archive.Value;
+ }
+ }
+
+ modItems.Add(modItem);
+ }
+ }
+ SaveModItems(modItems);
+ return modItems;
+ }
+
+ public static void GatherModFiles(ModItem modItem)
+ {
+ var config = Config.Instance;
+ if (config.RepoFolder == null)
+ {
+ throw new InvalidOperationException("RepoFolder is not configured.");
+ }
+
+ var stages = ModItem.DB.GetStages();
+ var excludedFileTypes = config.PackageExcludeFiletypes?.ToArray() ?? Array.Empty();
+
+ foreach (var stage in stages)
+ {
+ var stageFolderPath = Path.Combine(config.RepoFolder, stage, modItem.ModName);
+ if (!Directory.Exists(stageFolderPath))
+ {
+ continue;
+ }
+
+ var files = Directory.GetFiles(stageFolderPath, "*", SearchOption.AllDirectories);
+ foreach (var file in files)
+ {
+ var fileInfo = new FileInfo(file);
+ var relativePath = Path.GetRelativePath(stageFolderPath, file);
+ var fileName = fileInfo.Name;
+ var dtStamp = fileInfo.LastWriteTime;
+ var hash = ModItem.Files.ComputeHash(file);
+
+ if (!IsExcludedFileType(fileName, excludedFileTypes))
+ {
+ modItem.ModFiles[fileName] = (stage, relativePath, dtStamp, hash);
+ modItem.AvailableStages.Add(stage);
+ modItem.AvailableStages = modItem.AvailableStages.Distinct().ToList();
+ }
+ }
+ var stageBackupPath = PathBuilder.GetModStageBackupFolder(modItem.ModName, stage);
+ if (Directory.Exists(stageBackupPath))
+ {
+ var archives = Directory.GetFiles(stageBackupPath, $"*.{Config.Instance.ArchiveFormat}");
+ modItem.CurrentArchiveFiles[stage] = (stage, archives.FirstOrDefault());
+ }
+
+ }
+ }
+
+ private static bool IsExcludedFileType(string fileName, string[] excludedFileTypes)
+ {
+ foreach (var excludedFileType in excludedFileTypes)
+ {
+ if (fileName.EndsWith(excludedFileType, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static string GetLatestZip(string modName, string stage)
+ {
+ var config = Config.Instance;
+ if (config.RepoFolder == null)
+ {
+ throw new InvalidOperationException("RepoFolder is not configured.");
+ }
+
+ var stageFolder = Path.Combine(config.RepoFolder, "BACKUP", modName, stage);
+ if (!Directory.Exists(stageFolder))
+ {
+ return string.Empty;
+ }
+
+ var fileTypes = new[] { "*.zip", "*.7z", "*.rar", "*.ba2" };
+ var newestFile = ModItem.Files.GetNewestFile(stageFolder, fileTypes);
+ return newestFile ?? string.Empty;
+ }
+
+ public static void CheckDeploymentStatus(ModItem modItem)
+ {
+ var config = Config.Instance;
+ if (config.RepoFolder == null)
+ {
+ throw new InvalidOperationException("RepoFolder is not configured.");
+ }
+
+ var modStagingFolder = PathBuilder.GetModStagingFolder(modItem.ModName);
+ if (Directory.Exists(modStagingFolder))
+ {
+ var targetDir = ModItem.Files.GetJunctionTarget(modStagingFolder);
+ if (targetDir != null && targetDir.StartsWith(Path.Combine(config.RepoFolder)))
+ {
+ // Extract the stage from the targetDir
+ var relativePath = targetDir.Substring(config.RepoFolder.Length).TrimStart(Path.DirectorySeparatorChar);
+ var parts = relativePath.Split(Path.DirectorySeparatorChar);
+ if (parts.Length >= 2)
+ {
+ var stage = parts[0];
+ var modName = parts[1];
+
+ if (modName.Equals(modItem.ModName, StringComparison.OrdinalIgnoreCase))
+ {
+ modItem.DeployedStage = stage.ToUpper();
+ modItem.ModDeploymentFolder = modStagingFolder;
+ }
+ }
+ }
+ else
+ {
+ var result = MessageBox.Show(
+ $"The mod {modItem.ModName} is not correctly deployed. Do you want to delete the target folder?",
+ "Deployment Issue",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning
+ );
+
+ if (result == MessageBoxResult.Yes)
+ {
+ ModItem.Files.RemoveJunctionPoint(modStagingFolder);
+ var redeployResult = MessageBox.Show(
+ $"Do you want to redeploy the mod {modItem.ModName}?",
+ "Redeploy Mod",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question
+ );
+
+ if (redeployResult == MessageBoxResult.Yes)
+ {
+ var sourceStage = modItem.DeployedStage ?? string.Empty;
+ ModItem.Files.CreateJunctionPoint(modStagingFolder, Path.Combine(config.RepoFolder, sourceStage, modItem.ModName));
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/App/ModStageControl.xaml b/App/ModStageControl.xaml
new file mode 100644
index 0000000..a8cc48e
--- /dev/null
+++ b/App/ModStageControl.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/App/ModStageControl.xaml.cs b/App/ModStageControl.xaml.cs
new file mode 100644
index 0000000..2b7ce9a
--- /dev/null
+++ b/App/ModStageControl.xaml.cs
@@ -0,0 +1,78 @@
+using System.Collections.ObjectModel;
+using System.Data.SQLite;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace DevModManager
+{
+ public partial class ModStageControl : UserControl
+ {
+ public ObservableCollection SourceStages { get; set; }
+ public ObservableCollection DestinationStages { get; set; }
+
+ public ModStageControl()
+ {
+ InitializeComponent();
+ DataContext = this; // Set DataContext to the current instance
+ LoadStages();
+ }
+
+ private void LoadStages()
+ {
+ try
+ {
+ // Fetch stages for SourceStages
+ var stagesQuery = "SELECT StageName FROM Stages WHERE isReserved = 0";
+ var stages = ExecuteStageQuery(stagesQuery);
+
+ SourceStages = new ObservableCollection(stages);
+
+ // Fetch stages for DestinationStages
+ var destinationStagesQuery = "SELECT StageName FROM Stages WHERE isReserved = 0 AND isSource = 0";
+ var destinationStages = ExecuteStageQuery(destinationStagesQuery);
+
+ DestinationStages = new ObservableCollection(destinationStages);
+ }
+ catch (Exception ex)
+ {
+ // Handle exceptions appropriately
+ _ = MessageBox.Show($"Failed to load stages: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private List ExecuteStageQuery(string query)
+ {
+ var stages = new List();
+ using (var connection = DbManager.Instance.GetConnection())
+ {
+ connection.Open();
+ using var command = new SQLiteCommand(query, connection);
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ stages.Add(reader.GetString(0));
+ }
+ }
+ return stages;
+ }
+
+ private void PromoteButton_Click(object sender, RoutedEventArgs e)
+ {
+ var modItem = DataContext as ModItem;
+ if (modItem != null)
+ {
+ string sourceStage = SourceStageComboBox.SelectedItem as string;
+ string destinationStage = DestinationStageComboBox.SelectedItem as string;
+
+ // Check if either stage is an empty string
+ if (string.IsNullOrEmpty(sourceStage) || string.IsNullOrEmpty(destinationStage))
+ {
+ _ = MessageBox.Show("Both source and destination stages must be selected.", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ return;
+ }
+
+ _ = ModStageManager.PromoteModStage(modItem, sourceStage, destinationStage);
+ }
+ }
+ }
+}
diff --git a/App/ModStageManager.cs b/App/ModStageManager.cs
new file mode 100644
index 0000000..14d5903
--- /dev/null
+++ b/App/ModStageManager.cs
@@ -0,0 +1,179 @@
+using System.IO;
+using System.Windows;
+using MessageBox = System.Windows.MessageBox;
+
+namespace DevModManager
+{
+ public static class ModStageManager
+ {
+ public static string PromoteModStage(ModItem modItem, string sourceStage, string targetStage)
+ {
+ var config = Config.Instance;
+ string sourcePath = Path.Combine(config.RepoFolder, sourceStage, modItem.ModName);
+ string targetPath = Path.Combine(config.RepoFolder, targetStage, modItem.ModName);
+ string backupFolder = Path.Combine(config.RepoFolder, "BACKUP", modItem.ModName, targetStage);
+
+ // Delete files from targetPath
+ if (Directory.Exists(targetPath))
+ {
+ Directory.Delete(targetPath, true);
+ }
+
+ // Copy included file types from sourcePath to targetPath
+ _ = Directory.CreateDirectory(targetPath);
+ var filesToCopy = Directory.GetFiles(sourcePath, "*.*", SearchOption.TopDirectoryOnly)
+ .Where(f => config.PromoteIncludeFiletypes.Any(ext => f.EndsWith(ext)));
+
+ if (!filesToCopy.Any())
+ {
+ _ = MessageBox.Show("There were no source files of the correct type. Have you created the .esm and .ba2 files yet?", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ return null;
+ }
+
+ foreach (var file in filesToCopy)
+ {
+ File.Copy(file, Path.Combine(targetPath, Path.GetFileName(file)));
+ }
+
+ // Create a backup from targetPath
+ string backupZipPath = ModItem.Files.CreateBackup(targetPath, backupFolder);
+
+ // Update the appropriate zip property based on the target stage
+
+
+ return backupZipPath;
+ }
+
+ public static void PackageMod(ModItem modItem, string sourceStage)
+ {
+
+ string releasePath = PathBuilder.GetModStageFolder(modItem.ModName, sourceStage);
+ string backupNexusPath = PathBuilder.GetPackageBackup(modItem.ModName);
+ string nexusPath = PathBuilder.GetPackageDestination(modItem.ModName);
+
+ _ = Directory.CreateDirectory(backupNexusPath);
+ _ = Directory.CreateDirectory(nexusPath);
+
+ // Create the dated zip file from the releasePath
+ string datedZipFile = ModItem.Files.CreateBackup(releasePath, backupNexusPath);
+
+ if (string.IsNullOrEmpty(datedZipFile))
+ {
+ throw new InvalidOperationException("Failed to create the dated zip file.");
+ }
+
+ // Define the undated zip file path
+ string undatedZipFile = Path.Combine(nexusPath, $"{modItem.ModName}.zip");
+
+ // Copy the dated zip file to the undated zip file
+ File.Copy(datedZipFile, undatedZipFile, true);
+ }
+
+
+ public static ModItem DeployStage(ModItem modItem, String targetStage)
+ {
+
+ if (modItem == null)
+ {
+ return modItem;
+ }
+ if (string.IsNullOrEmpty(targetStage))
+ {
+ ModItem.Files.RemoveJunctionPoint(modItem.ModDeploymentFolder);
+ modItem.ModDeploymentFolder = String.Empty;
+ modItem.DeployedStage = String.Empty;
+ return modItem;
+
+ }
+
+ var sourcePath = PathBuilder.GetModStageFolder(modItem.ModName, targetStage);
+ var targetPath = PathBuilder.GetModStagingFolder(modItem.ModName);
+ var backupPath = PathBuilder.GetDeployBackupFolder(modItem.ModName);
+
+ _ = ModItem.Files.CreateBackup(sourcePath, backupPath);
+ ModItem.Files.CreateJunctionPoint(targetPath, sourcePath);
+ modItem.ModDeploymentFolder = targetPath;
+ modItem.DeployedStage = targetStage;
+ //MessageBox.Show($"ModStageMgr - Mod {modItem.ModName} deployed to stage {targetStage} \r\n from {sourcePath} to {targetPath}.");
+ return modItem;
+ }
+
+
+ // public static void ExecuteModStageChangedCommand(string modName, ObservableCollection modItems)
+ //{
+ // var config = Config.Instance;
+ // var modItem = modItems.FirstOrDefault(m => m.ModName == modName);
+ // if (modItem != null)
+ // {
+ // string modStagingFolder = Path.Combine(config.ModStagingFolder, modName);
+ // string repoFolder = config.RepoFolder;
+ // string backupFolder = Path.Combine(repoFolder, "BACKUP");
+
+ // bool createBackup = modItem.DeployedStage == "DEV";
+
+ // switch (modItem.DeployedStage)
+ // {
+ // case "DEV":
+ // modItem.DeployedStage = "TEST";
+ // HandleStageChange(modItem);
+ // break;
+ // case "TEST":
+ // modItem.DeployedStage = "RELEASE";
+ // HandleStageChange(modItem);
+ // break;
+ // default:
+ // ModItem.Files.RemoveJunctionPoint(modStagingFolder);
+ // break;
+ // }
+
+ // if (createBackup && modItem.DeployedStage != "DEV")
+ // {
+ // _ = ModItem.Files.CreateBackup(modName, backupFolder);
+ // }
+ // WriteModStatus(modItems);
+ // }
+ //}
+
+ //private static void HandleStageChange(ModItem modItem)
+ //{
+ // var config = Config.Instance;
+ // string modStagingFolder = config.ModStagingFolder;
+ // string repoFolder = config.RepoFolder;
+ // string stage = modItem.DeployedStage;
+ // string modName = modItem.ModName;
+ // string stageFolder = Path.Combine(repoFolder, stage, modName);
+ // string dataFolder = Path.Combine(modStagingFolder, modName);
+ // string backupFolder = Path.Combine(repoFolder, "BACKUP", modName, "DEPLOYED");
+ // string zipPath = Path.Combine(backupFolder, $"{modName}_{config.TimestampFormat}.zip");
+
+ // _ = ModItem.Files.CreateBackup(stageFolder, backupFolder);
+ // ModItem.Files.CreateJunctionPoint(dataFolder, stageFolder);
+ // modItem.ModDeploymentFolder = dataFolder;
+ //}
+
+ //public static void WriteModStatus(ObservableCollection modItems)
+ //{
+ // var config = Config.Instance;
+ // //string modStatusPath = Path.Combine(config.RepoFolder, "ModStatus.json");
+ // var modStatus = modItems.Select(modItem => new
+ // {
+ // modItem.ModName,
+ // modItem.ModFolderPath,
+ // modItem.ModDeploymentFolder,
+ // modItem.DeployedStage,
+ // modItem.CurrentArchiveFiles,
+ // modItem.BethesdaUrl,
+ // modItem.NexusUrl,
+ // modItem.ModFiles
+
+ // });
+
+ // //File.WriteAllText(modStatusPath, JsonConvert.SerializeObject(modStatus, Formatting.Indented));
+ //}
+
+ //public static ModItem GetModStatus(string modName, ObservableCollection modItems)
+ //{
+ // return modItems.FirstOrDefault(m => m.ModName == modName);
+ //}
+ }
+}
\ No newline at end of file
diff --git a/App/PathBuilder.cs b/App/PathBuilder.cs
new file mode 100644
index 0000000..30fc5d9
--- /dev/null
+++ b/App/PathBuilder.cs
@@ -0,0 +1,97 @@
+using System.IO;
+
+namespace DevModManager
+{
+ public static class PathBuilder
+ {
+ public static readonly string RepoFolder = Config.Instance.RepoFolder;
+ public static readonly string BackupFolder = Path.Combine(RepoFolder, "BACKUP");
+ public static readonly string ModStagingFolder = Config.Instance.ModStagingFolder;
+ public static readonly List ValidStages = Config.Instance.ModStages.ToList();
+
+ public static string BuildPath(string modName, string? stage = null, bool isBackup = false, bool isDeploy = false)
+ {
+ if (isBackup && isDeploy)
+ {
+ throw new ArgumentException("isBackup and isDeploy cannot both be true.");
+ }
+
+ if (stage != null && !ValidStages.Contains(stage))
+ {
+ throw new ArgumentException($"Invalid stage: {stage}");
+ }
+
+ if (stage != null && stage.StartsWith("#") && !isBackup)
+ {
+ throw new ArgumentException($"Stage {stage} is reserved for backup only.");
+ }
+
+ if (modName == null)
+ {
+ throw new ArgumentNullException(nameof(modName));
+ }
+
+ if (stage == null && !isDeploy)
+ {
+ // Return source path
+ string sourceStage = ValidStages.FirstOrDefault(s => s.StartsWith("*")) ?? throw new InvalidOperationException("No source stage found.");
+ return Path.Combine(RepoFolder, sourceStage.TrimStart('*'), modName);
+ }
+
+ if (isBackup)
+ {
+ return Path.Combine(BackupFolder, modName, stage.TrimStart('#'));
+ }
+
+ if (isDeploy)
+ {
+ return Path.Combine(ModStagingFolder, modName);
+ }
+
+ return Path.Combine(RepoFolder, stage, modName);
+ }
+
+ public static string GetBackupFolder(string modName)
+ {
+ return Path.Combine(BackupFolder, modName);
+ }
+
+ public static string GetModStagingFolder(string modName)
+ {
+ return Path.Combine(ModStagingFolder, modName);
+ }
+
+ public static string GetModSourceBackupFolder(string modName)
+ {
+ string sourceStage = ValidStages.FirstOrDefault(s => s.StartsWith("*")) ?? throw new InvalidOperationException("No source stage found.");
+ return Path.Combine(BackupFolder, modName, sourceStage.TrimStart('*'));
+ }
+
+ public static string GetDeployBackupFolder(string modName)
+ {
+ return Path.Combine(BackupFolder, modName, "DEPLOYED");
+ }
+
+ public static string GetPackageDestination(string modName)
+ {
+ return Path.Combine(RepoFolder, "NEXUS", modName);
+ }
+
+ public static string GetPackageBackup(string modName)
+ {
+ return Path.Combine(BackupFolder, modName, "NEXUS");
+ }
+
+ public static string GetModStageFolder(string modName, string stage)
+ {
+ return Path.Combine(RepoFolder, stage, modName);
+ }
+
+ public static string GetModStageBackupFolder(string modName, string stage)
+ {
+ return Path.Combine(BackupFolder, modName, stage.TrimStart('#', '*'));
+ }
+
+ }
+}
+
diff --git a/App/Plugin.cs b/App/Plugin.cs
new file mode 100644
index 0000000..58fa360
--- /dev/null
+++ b/App/Plugin.cs
@@ -0,0 +1,221 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using System.Collections.ObjectModel;
+using System.IO;
+
+namespace DevModManager
+{
+ public class Plugin
+ {
+ public int ModID { get; set; }
+ public bool ModEnabled { get; set; }
+ public string PluginName { get; set; }
+ public string Description { get; set; }
+ public string Achievements { get; set; }
+ public string Files { get; set; }
+ public string TimeStamp { get; set; }
+ public string Version { get; set; }
+ public string BethesdaID { get; set; }
+ public string NexusID { get; set; }
+ public int GroupID { get; set; }
+ public int GroupOrdinal { get; set; }
+ }
+
+ public class ModGroup
+ {
+ public int GroupID { get; set; }
+ public int Ordinal { get; set; }
+ public string Description { get; set; }
+ public int ParentID { get; set; }
+ public ObservableCollection PluginIDs { get; set; } = new ObservableCollection();
+ public ObservableCollection Plugins { get; set; } = new ObservableCollection();
+ }
+
+ public class LoadOutProfile
+ {
+ public string Name { get; set; }
+ public int[] ActivePlugins { get; set; }
+ }
+
+ public static class PluginManager
+ {
+ public const string PluginsFilePath = @"%LOCALAPPDATA%\Starfield\plugins.txt";
+ public const string ContentCatalogFilePath = @"%LOCALAPPDATA%\Starfield\ContentCatalog.txt";
+ private static readonly string repoFolder = Config.Instance.RepoFolder;
+ private static readonly string metadataFilePath = Path.Combine(repoFolder, "METADATA", "plugin_meta.json");
+
+ public static List LoadPlugins()
+ {
+ var plugins = new List();
+ var groups = new List();
+ var loadOutProfiles = new List();
+
+ // Expand environment variables in file paths
+ string pluginsFilePath = Environment.ExpandEnvironmentVariables(PluginsFilePath);
+ string contentCatalogFilePath = Environment.ExpandEnvironmentVariables(ContentCatalogFilePath);
+
+ // Initialize metadata modgroup array with default values
+ groups.Add(new ModGroup
+ {
+ GroupID = 0,
+ Ordinal = 0,
+ Description = "Default",
+ ParentID = -1
+ });
+
+ // Read plugins.txt
+ var pluginLines = File.ReadAllLines(pluginsFilePath);
+
+ var currentGroup = groups[0];
+ var currentParent = groups[0];
+ var currentDepth = 0;
+ int modIDCounter = 1;
+
+ foreach (var line in pluginLines)
+ {
+ if (line.StartsWith("###"))
+ {
+ // Handle section lines
+ var depth = line.TakeWhile(c => c == '#').Count();
+ var description = line.Substring(depth).Trim();
+
+ if (depth > currentDepth)
+ {
+ // Create a new child group
+ var newGroup = new ModGroup
+ {
+ GroupID = groups.Count,
+ ParentID = currentGroup.GroupID,
+ Description = description,
+ Ordinal = currentGroup.Ordinal + 1
+ };
+ groups.Add(newGroup);
+ currentGroup = newGroup;
+ }
+ else if (depth == currentDepth)
+ {
+ // Create a new sibling group
+ var newGroup = new ModGroup
+ {
+ GroupID = groups.Count,
+ ParentID = currentParent.GroupID,
+ Description = description,
+ Ordinal = currentParent.Ordinal + 1
+ };
+ groups.Add(newGroup);
+ currentGroup = newGroup;
+ }
+ else
+ {
+ // Go back to the appropriate parent group
+ currentGroup = groups.First(g => g.GroupID == currentGroup.ParentID);
+ while (currentGroup.Ordinal >= depth)
+ {
+ currentGroup = groups.First(g => g.GroupID == currentGroup.ParentID);
+ }
+
+ // Create a new child group
+ var newGroup = new ModGroup
+ {
+ GroupID = groups.Count,
+ ParentID = currentGroup.GroupID,
+ Description = description,
+ Ordinal = currentGroup.Ordinal + 1
+ };
+ groups.Add(newGroup);
+ currentGroup = newGroup;
+ }
+
+ currentParent = groups.First(g => g.GroupID == currentGroup.ParentID);
+ currentDepth = depth;
+ }
+ else if (line.EndsWith(".esm") || line.EndsWith(".esp"))
+ {
+ var plugin = new Plugin
+ {
+ ModID = modIDCounter++,
+ ModEnabled = line.StartsWith("*"),
+ PluginName = line.TrimStart('*').Trim(),
+ GroupID = currentGroup.GroupID,
+ GroupOrdinal = plugins.Count(p => p.GroupID == currentGroup.GroupID)
+ };
+ plugins.Add(plugin);
+ currentGroup.PluginIDs.Add(plugin.ModID);
+ currentGroup.Plugins.Add(plugin);
+ }
+ }
+
+ // Read contentcatalog.txt
+ var contentCatalogJson = File.ReadAllText(contentCatalogFilePath);
+ var contentCatalog = JsonConvert.DeserializeObject>(contentCatalogJson);
+
+ foreach (var plugin in plugins)
+ {
+ var entry = contentCatalog.Values.FirstOrDefault(v => v["Files"] != null && v["Files"].Any(f => f.ToString().Equals(plugin.PluginName, StringComparison.OrdinalIgnoreCase)));
+
+ if (entry != null)
+ {
+ plugin.Achievements = entry["AchievementSafe"]?.ToString();
+ plugin.Files = string.Join(", ", entry["Files"]);
+ plugin.Description = entry["Title"]?.ToString();
+ string version = entry["Version"]?.ToString();
+ if (!string.IsNullOrEmpty(version))
+ {
+ string[] versionParts = version.Split('.');
+ if (versionParts.Length > 1)
+ {
+ if (long.TryParse(versionParts[0]?.ToString(), out long unixTime))
+ {
+ DateTimeOffset dateTimeOffset = DateTimeOffset.FromUnixTimeSeconds(unixTime);
+ plugin.TimeStamp = dateTimeOffset.ToString(Config.Instance.TimestampFormat);
+ }
+ plugin.Version = versionParts[1];
+ }
+ }
+
+ // Extract BethesdaID from the key
+ var bethesdaID = contentCatalog.FirstOrDefault(x => x.Value == entry).Key;
+ plugin.BethesdaID = bethesdaID.StartsWith("TM_") ? bethesdaID.Substring(3) : bethesdaID;
+ }
+ }
+
+ SavePluginsToJson(groups, plugins, loadOutProfiles);
+
+ return plugins;
+ }
+
+ public static void SavePluginsToJson(List groups, List plugins, List loadouts = null)
+ {
+ var jsonObject = new
+ {
+ Groups = groups.Select(g => new ModGroup
+ {
+ GroupID = g.GroupID,
+ Ordinal = g.Ordinal,
+ Description = g.Description,
+ ParentID = g.ParentID,
+ PluginIDs = g.PluginIDs
+ }).ToList(),
+ LoadOuts = loadouts,
+ Plugins = plugins
+ };
+
+ string repoFolder = Config.Instance.RepoFolder; // Use the Config singleton to get the RepoFolder path
+ string metadataFilePath = Path.Combine(repoFolder, "METADATA", "plugin_meta.json");
+
+ _ = Directory.CreateDirectory(Path.GetDirectoryName(metadataFilePath));
+ File.WriteAllText(metadataFilePath, JsonConvert.SerializeObject(jsonObject, Formatting.Indented));
+ }
+
+ public static ModGroup GetGroupById(IEnumerable groups, int groupId)
+ {
+ return groups.FirstOrDefault(g => g.GroupID == groupId);
+ }
+
+ public static Plugin GetPluginById(IEnumerable plugins, int modId)
+ {
+ return plugins.FirstOrDefault(p => p.ModID == modId);
+ }
+ }
+
+}
diff --git a/App/PluginEditorWindow.xaml b/App/PluginEditorWindow.xaml
new file mode 100644
index 0000000..549a407
--- /dev/null
+++ b/App/PluginEditorWindow.xaml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/PluginEditorWindow.xaml.cs b/App/PluginEditorWindow.xaml.cs
new file mode 100644
index 0000000..27311b5
--- /dev/null
+++ b/App/PluginEditorWindow.xaml.cs
@@ -0,0 +1,31 @@
+using System.Collections.ObjectModel;
+using System.Windows;
+
+namespace DevModManager
+{
+ public partial class PluginEditorWindow : Window
+ {
+ public Plugin Plugin { get; set; }
+ public ObservableCollection Groups { get; set; }
+
+ public PluginEditorWindow(Plugin plugin, ObservableCollection groups)
+ {
+ InitializeComponent();
+ Plugin = plugin;
+ Groups = groups;
+ DataContext = this;
+ }
+
+ private void SaveButton_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = true;
+ Close();
+ }
+
+ private void CancelButton_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+ }
+}
diff --git a/App/Properties/Settings.Designer.cs b/App/Properties/Settings.Designer.cs
new file mode 100644
index 0000000..7f47cbe
--- /dev/null
+++ b/App/Properties/Settings.Designer.cs
@@ -0,0 +1,44 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace DevModManager.Properties {
+
+
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.11.0.0")]
+ internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
+
+ private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
+
+ public static Settings Default {
+ get {
+ return defaultInstance;
+ }
+ }
+
+ [global::System.Configuration.ApplicationScopedSettingAttribute()]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Configuration.DefaultSettingValueAttribute("lib;locale")]
+ public string ProbingPaths {
+ get {
+ return ((string)(this["ProbingPaths"]));
+ }
+ }
+
+ [global::System.Configuration.ApplicationScopedSettingAttribute()]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Configuration.DefaultSettingValueAttribute("0.0.5")]
+ public string version {
+ get {
+ return ((string)(this["version"]));
+ }
+ }
+ }
+}
diff --git a/App/Properties/Settings.settings b/App/Properties/Settings.settings
new file mode 100644
index 0000000..5a0fa7b
--- /dev/null
+++ b/App/Properties/Settings.settings
@@ -0,0 +1,12 @@
+
+
+
+
+
+ lib;locale
+
+
+ 0.0.8
+
+
+
\ No newline at end of file
diff --git a/App/Properties/version.txt b/App/Properties/version.txt
new file mode 100644
index 0000000..d169b2f
--- /dev/null
+++ b/App/Properties/version.txt
@@ -0,0 +1 @@
+0.0.8
diff --git a/App/RelayCommand.cs b/App/RelayCommand.cs
new file mode 100644
index 0000000..e377e1a
--- /dev/null
+++ b/App/RelayCommand.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Windows.Input;
+
+public class RelayCommand : ICommand
+{
+ private readonly Action