#nullable enable using System.IO; using System.Text.RegularExpressions; using T3.Core.Settings; using T3.Editor.Gui.UiHelpers; using T3.Serialization; namespace T3.Editor.Gui.Dialog; /// /// Finds a previous TiXL install's user folders and copies selected data (projects, settings, /// layouts, themes, keymaps) into the current version's folders. All operations copy — the source /// is never modified, so the older install stays usable. /// internal static partial class PreviousVersionImport { /// A sibling TiXL user folder from a different version, usable as an import source. internal sealed record PreviousVersion(string FolderName, string SettingsPath, string ProjectsPath, bool IsStable); /// One project found in a previous version's project folder. internal sealed record ProjectEntry(string Name, string SourcePath, bool AlreadyImported); internal const string LayoutsSubfolder = "Layouts"; /// /// Picks the best previous version to offer as an import source: highest major.minor that /// isn't the running one, preferring a stable folder over an alpha folder of the same minor. /// internal static bool TryFindBest(out PreviousVersion? best) { best = null; var currentName = FileLocations.VersionedAppFolderName; var settingsRoot = Directory.GetParent(FileLocations.SettingsDirectory)?.FullName; var projectsRoot = Directory.GetParent(FileLocations.DefaultProjectFolder)?.FullName; if (settingsRoot == null || !Directory.Exists(settingsRoot)) return false; var bestVersion = new Version(0, 0); foreach (var dir in Directory.GetDirectories(settingsRoot)) { var name = Path.GetFileName(dir); if (string.Equals(name, currentName, StringComparison.OrdinalIgnoreCase)) continue; var match = FolderNamePattern().Match(name); if (!match.Success) continue; var version = new Version(int.Parse(match.Groups[1].Value), int.Parse(match.Groups[2].Value)); var isStable = !match.Groups[3].Success; // Higher version wins; on a tie, stable beats alpha. var comparison = version.CompareTo(bestVersion); if (best != null && comparison < 0) continue; if (best != null && comparison == 0 && !(isStable && !best.IsStable)) continue; var projectsPath = projectsRoot != null ? Path.Combine(projectsRoot, name) : string.Empty; best = new PreviousVersion(name, dir, projectsPath, isStable); bestVersion = version; } return best != null; } /// /// True if the current settings folder already holds user data — i.e. this is a routine version /// bump rather than a fresh folder, so the import section is hidden. /// /// /// Keyed off the version marker and userSettings.json, both written only after a real /// session. Theme/layout/keymap defaults can be written during startup, so their presence is /// not a reliable "folder was used before" signal. /// internal static bool CurrentFolderHasPriorData() { return VersionMarker.LastRunVersion != null || File.Exists(Path.Combine(FileLocations.SettingsDirectory, "userSettings.json")); } internal static bool HasProjects(PreviousVersion previous) => HasFiles(previous.ProjectsPath); internal static bool HasSettingsFile(PreviousVersion previous) => File.Exists(Path.Combine(previous.SettingsPath, "userSettings.json")); internal static bool HasSubfolder(PreviousVersion previous, string subfolder) => HasFiles(Path.Combine(previous.SettingsPath, subfolder)); /// Approximate on-disk size of the previous version's project tree, for display before import. internal static long TryGetProjectsSizeBytes(PreviousVersion previous) { try { if (!Directory.Exists(previous.ProjectsPath)) return 0; long total = 0; foreach (var file in Directory.EnumerateFiles(previous.ProjectsPath, "*", SearchOption.AllDirectories)) { try { total += new FileInfo(file).Length; } catch (IOException) { // Skip files we can't stat; the figure is only an estimate for the user. } } return total; } catch (Exception e) { Log.Debug($"Could not measure previous project folder: {e.Message}"); return 0; } } /// /// Copies the allowlisted keys from the previous userSettings.json into the live settings /// and saves. Deliberately narrow — only values that don't drift between versions are copied; /// layouts, themes, and keymaps are separate import categories. /// internal static void ImportSettingsAllowlist(PreviousVersion previous) { var previousFile = Path.Combine(previous.SettingsPath, "userSettings.json"); var previousConfig = JsonUtils.TryLoadingJson(previousFile); if (previousConfig == null) { Log.Warning($"Could not read settings to import from {previousFile}"); return; } var current = UserSettings.Config; current.UserName = previousConfig.UserName; current.UiScaleFactor = previousConfig.UiScaleFactor; current.KeyBindingName = previousConfig.KeyBindingName; current.ColorThemeName = previousConfig.ColorThemeName; UserSettings.Save(); } /// Copies a settings subfolder (Layouts / Themes / KeyBindings) from the previous version, overwriting matching files. internal static void ImportSubfolder(PreviousVersion previous, string subfolder) { var source = Path.Combine(previous.SettingsPath, subfolder); if (!Directory.Exists(source)) return; CopyDirectory(source, Path.Combine(FileLocations.SettingsDirectory, subfolder), null); } /// /// Lists the projects in the previous version's project folder. Each immediate subfolder is a /// project; a project is "already imported" when a folder of the same name exists in the current /// default project folder. /// internal static IReadOnlyList EnumerateProjects(PreviousVersion previous) { if (!Directory.Exists(previous.ProjectsPath)) return []; var entries = new List(); foreach (var dir in Directory.GetDirectories(previous.ProjectsPath)) { var name = Path.GetFileName(dir); var alreadyImported = Directory.Exists(Path.Combine(FileLocations.DefaultProjectFolder, name)); entries.Add(new ProjectEntry(name, dir, alreadyImported)); } return entries; } /// Copies a single project folder into the current default project folder, skipping build output. internal static void ImportProject(ProjectEntry project) { if (!Directory.Exists(project.SourcePath)) return; CopyDirectory(project.SourcePath, Path.Combine(FileLocations.DefaultProjectFolder, project.Name), _projectExcludedDirs); } private static void CopyDirectory(string source, string target, ISet? excludedDirNames) { Directory.CreateDirectory(target); foreach (var file in Directory.GetFiles(source)) { File.Copy(file, Path.Combine(target, Path.GetFileName(file)), overwrite: true); } foreach (var dir in Directory.GetDirectories(source)) { var name = Path.GetFileName(dir); if (excludedDirNames != null && excludedDirNames.Contains(name)) continue; CopyDirectory(dir, Path.Combine(target, name), excludedDirNames); } } private static bool HasFiles(string path) { return !string.IsNullOrEmpty(path) && Directory.Exists(path) && Directory.EnumerateFileSystemEntries(path).Any(); } private static readonly HashSet _projectExcludedDirs = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", ".temp" }; [GeneratedRegex(@"^TiXL(\d+)\.(\d+)(?:-(.+))?$", RegexOptions.IgnoreCase)] private static partial Regex FolderNamePattern(); }