Files
tooll3--t3/Editor/Gui/Dialog/PreviousVersionImport.cs
2026-07-13 13:13:17 +08:00

213 lines
8.5 KiB
C#

#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;
/// <summary>
/// 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.
/// </summary>
internal static partial class PreviousVersionImport
{
/// <summary>A sibling TiXL user folder from a different version, usable as an import source.</summary>
internal sealed record PreviousVersion(string FolderName, string SettingsPath, string ProjectsPath, bool IsStable);
/// <summary>One project found in a previous version's project folder.</summary>
internal sealed record ProjectEntry(string Name, string SourcePath, bool AlreadyImported);
internal const string LayoutsSubfolder = "Layouts";
/// <summary>
/// Picks the best previous version to offer as an import source: highest <c>major.minor</c> that
/// isn't the running one, preferring a stable folder over an alpha folder of the same minor.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Keyed off the version marker and <c>userSettings.json</c>, 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.
/// </remarks>
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));
/// <summary>Approximate on-disk size of the previous version's project tree, for display before import.</summary>
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;
}
}
/// <summary>
/// Copies the allowlisted keys from the previous <c>userSettings.json</c> 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.
/// </summary>
internal static void ImportSettingsAllowlist(PreviousVersion previous)
{
var previousFile = Path.Combine(previous.SettingsPath, "userSettings.json");
var previousConfig = JsonUtils.TryLoadingJson<UserSettings.ConfigData>(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();
}
/// <summary>Copies a settings subfolder (Layouts / Themes / KeyBindings) from the previous version, overwriting matching files.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
internal static IReadOnlyList<ProjectEntry> EnumerateProjects(PreviousVersion previous)
{
if (!Directory.Exists(previous.ProjectsPath))
return [];
var entries = new List<ProjectEntry>();
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;
}
/// <summary>Copies a single project folder into the current default project folder, skipping build output.</summary>
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<string>? 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<string> _projectExcludedDirs = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", ".temp" };
[GeneratedRegex(@"^TiXL(\d+)\.(\d+)(?:-(.+))?$", RegexOptions.IgnoreCase)]
private static partial Regex FolderNamePattern();
}