XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.IO;
using System.Security.Cryptography;
using System.Security;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using XFEToolBox.Core.Model;
using XFEToolBox.Core.Tools;

namespace XFEToolBox.Client.Utilities;

internal static class ToolProjectWorkspaceService
{
    private const string DefaultToolIconRelativePath = "Assets/icon.png";
    private const string DefaultToolIconResourcePath = "Resources/Image/default_tool_icon.png";
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };

    public static string DefaultProjectsRoot => Path.Combine(AppPath.AppLocalData, "EditorWorkspaces");

    private static string HistoryPath => Path.Combine(DefaultProjectsRoot, "project-history.json");

    public static async Task<string> CreateProjectAsync(string projectName, string projectPath)
    {
        projectName = projectName.Trim();
        if (string.IsNullOrWhiteSpace(projectName))
            throw new InvalidOperationException("请输入工具名称。");
        if (projectName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
            throw new InvalidOperationException("工具名称包含不能用于文件夹名称的字符。");

        var root = Path.GetFullPath(projectPath.Trim());
        if (Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any())
            throw new InvalidOperationException("项目位置不是空文件夹,请选择其他位置。");

        var viewsDirectory = Path.Combine(root, "Code", "Views");
        var modelsDirectory = Path.Combine(root, "Code", "Models");
        var viewModelsDirectory = Path.Combine(root, "Code", "ViewModels");
        var assetsDirectory = Path.Combine(root, "Assets");
        Directory.CreateDirectory(viewsDirectory);
        Directory.CreateDirectory(modelsDirectory);
        Directory.CreateDirectory(viewModelsDirectory);
        Directory.CreateDirectory(assetsDirectory);

        var safeId = new string(projectName.ToLowerInvariant()
            .Select(character => char.IsLetterOrDigit(character) ? character : '-')
            .ToArray()).Trim('-');
        if (string.IsNullOrWhiteSpace(safeId))
            safeId = "new-tool-project";
        var className = new string(projectName.Where(char.IsLetterOrDigit).ToArray());
        if (string.IsNullOrWhiteSpace(className) || !char.IsLetter(className[0]))
            className = "NewToolProject";
        var toolNamespace = $"XFEToolBox.Tools.{className}";
        var jsonProjectName = JsonSerializer.Serialize(projectName);
        var xamlProjectName = SecurityElement.Escape(projectName) ?? projectName;

        var templates = new Dictionary<string, string>
        {
            ["manifest.json"] = $$"""
                {
                  "packageFormatVersion": 1,
                  "id": "local.{{safeId}}",
                  "name": {{jsonProjectName}},
                  "subtitle": "WPF 独立工具",
                  "version": "1.0.0",
                  "description": "请在这里填写工具说明。",
                  "author": "XFEstudio",
                  "icon": "{{DefaultToolIconRelativePath}}",
                  "category": "开发工具",
                  "tags": [ "WPF" ],
                  "nugetPackages": [],
                  "requiresAdministrator": false,
                  "entry": {
                    "viewXaml": "Code/Views/MainPage.xaml",
                    "viewClass": "{{toolNamespace}}.MainPage",
                    "viewCodeBehind": "Code/Views/MainPage.xaml.cs",
                    "viewModel": "Code/ViewModels/MainPageViewModel.cs",
                    "viewModelClass": "{{toolNamespace}}.MainPageViewModel"
                  },
                  "window": {
                    "width": 760,
                    "height": 560,
                    "minWidth": 420,
                    "minHeight": 300,
                    "allowResize": true,
                    "allowMaximize": true,
                    "showMinimizeButton": true,
                    "showCloseButton": true
                  },
                  "requestedPermissions": []
                }
                """,
            [Path.Combine("Code", "Views", "MainPage.xaml")] = $$"""
                <UserControl x:Class="{{toolNamespace}}.MainPage"
                             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
                    <Grid Margin="24">
                        <TextBlock Text="在这里编写工具界面" />
                    </Grid>
                </UserControl>
                """,
            [Path.Combine("Code", "Views", "MainPage.xaml.cs")] = $$"""
                using System.Windows;
                using System.Windows.Controls;

                namespace {{toolNamespace}};

                public partial class MainPage : UserControl
                {
                    public MainPage()
                    {
                        InitializeComponent();
                        DataContext = new MainPageViewModel();
                        Unloaded += OnUnloaded;
                    }

                    private void OnUnloaded(object sender, RoutedEventArgs e) =>
                        ((MainPageViewModel)DataContext).SaveSettings();
                }
                """,
            [Path.Combine("Code", "ViewModels", "MainPageViewModel.cs")] = $$"""
                using CommunityToolkit.Mvvm.ComponentModel;
                using CommunityToolkit.Mvvm.Input;
                using XFEToolBox.Core.Tools;

                namespace {{toolNamespace}};

                public partial class MainPageViewModel : ObservableObject
                {
                    [ObservableProperty]
                    private string result = string.Empty;

                    public MainPageViewModel()
                    {
                        if (ToolDataStore.IsInitialized)
                            result = ToolDataStore.Read("settings", new ToolSettings(string.Empty)).LastResult;
                    }

                    public void SaveSettings()
                    {
                        if (ToolDataStore.IsInitialized)
                            ToolDataStore.Write("settings", new ToolSettings(Result));
                    }

                    [RelayCommand]
                    private void Execute() => Result = "工具执行成功";

                    private sealed record ToolSettings(string LastResult);
                }
                """,
            [Path.Combine("Code", "Models", "ToolModel.cs")] = $$"""
                namespace {{toolNamespace}};

                public sealed class ToolModel
                {
                }
                """,
            ["README.md"] = $"# {projectName}\n\n使用 XFEToolBox Code Studio 编辑并发布此工具。\n"
        };

        foreach (var (relativePath, content) in templates)
        {
            var output = Path.Combine(root, relativePath);
            Directory.CreateDirectory(Path.GetDirectoryName(output)!);
            await File.WriteAllTextAsync(output, content, new UTF8Encoding(false));
        }

        await WriteDefaultToolIconAsync(Path.Combine(root, DefaultToolIconRelativePath));

        await RememberProjectAsync(root);
        return root;
    }

    public static async Task<IReadOnlyList<ToolProjectHistoryItem>> LoadHistoryAsync()
    {
        try
        {
            var hasSavedHistory = File.Exists(HistoryPath);
            var items = hasSavedHistory
                ? (JsonSerializer.Deserialize<ToolProjectHistoryItem[]>(await File.ReadAllTextAsync(HistoryPath), JsonOptions) ?? []).ToList()
                : [];
            if (!hasSavedHistory && Directory.Exists(DefaultProjectsRoot))
            {
                foreach (var directory in Directory.EnumerateDirectories(DefaultProjectsRoot))
                {
                    if (!File.Exists(Path.Combine(directory, "manifest.json"))
                        || items.Any(item => item.ProjectPath.Equals(directory, StringComparison.OrdinalIgnoreCase)))
                        continue;
                    items.Add(new ToolProjectHistoryItem(
                        await ReadProjectNameAsync(directory),
                        Path.GetFullPath(directory),
                        Directory.GetLastWriteTimeUtc(directory)));
                }
            }
            return items.OrderByDescending(item => item.LastOpenedUtc).Take(30).ToArray();
        }
        catch
        {
            return [];
        }
    }

    public static async Task RememberProjectAsync(string projectPath)
    {
        var fullPath = Path.GetFullPath(projectPath);
        var items = (await LoadHistoryAsync()).ToList();
        items.RemoveAll(item => item.ProjectPath.Equals(fullPath, StringComparison.OrdinalIgnoreCase));
        items.Insert(0, new ToolProjectHistoryItem(await ReadProjectNameAsync(fullPath), fullPath, DateTime.UtcNow));
        await SaveHistoryAsync(items.Take(30));
    }

    public static async Task RemoveProjectFromHistoryAsync(string projectPath)
    {
        var items = (await LoadHistoryAsync())
            .Where(item => !item.ProjectPath.Equals(projectPath, StringComparison.OrdinalIgnoreCase));
        await SaveHistoryAsync(items);
    }

    public static async Task<IReadOnlyList<string>> LoadExplorerOrderAsync(string projectPath)
    {
        try
        {
            var path = GetLayoutPath(projectPath);
            return File.Exists(path)
                ? JsonSerializer.Deserialize<string[]>(await File.ReadAllTextAsync(path), JsonOptions) ?? []
                : [];
        }
        catch
        {
            return [];
        }
    }

    public static async Task SaveExplorerOrderAsync(string projectPath, IEnumerable<string> relativePaths)
    {
        var path = GetLayoutPath(projectPath);
        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
        await File.WriteAllTextAsync(path, JsonSerializer.Serialize(relativePaths.Distinct(StringComparer.OrdinalIgnoreCase), JsonOptions), new UTF8Encoding(false));
    }

    private static async Task<string> ReadProjectNameAsync(string root)
    {
        try
        {
            var manifestPath = Path.Combine(root, "manifest.json");
            var manifest = JsonSerializer.Deserialize<ToolPackageManifest>(await File.ReadAllTextAsync(manifestPath), JsonOptions);
            if (!string.IsNullOrWhiteSpace(manifest?.Name))
                return manifest.Name;
        }
        catch
        {
            // 清单缺失或格式错误时使用目录名作为历史项目名称。
        }

        return Path.GetFileName(Path.TrimEndingDirectorySeparator(root));
    }

    private static async Task SaveHistoryAsync(IEnumerable<ToolProjectHistoryItem> items)
    {
        Directory.CreateDirectory(DefaultProjectsRoot);
        await File.WriteAllTextAsync(HistoryPath, JsonSerializer.Serialize(items, JsonOptions), new UTF8Encoding(false));
    }

    private static async Task WriteDefaultToolIconAsync(string outputPath)
    {
        var assemblyName = typeof(ToolProjectWorkspaceService).Assembly.GetName().Name
                           ?? throw new InvalidOperationException("无法确定客户端程序集名称。");
        var resourceUri = new Uri(
            $"pack://application:,,,/{assemblyName};component/{DefaultToolIconResourcePath}",
            UriKind.Absolute);
        var resource = Application.GetResourceStream(resourceUri)
                       ?? throw new InvalidOperationException("无法读取内置的默认工具图标。");

        await using var source = resource.Stream;
        await using var destination = new FileStream(
            outputPath,
            FileMode.CreateNew,
            FileAccess.Write,
            FileShare.None,
            81920,
            FileOptions.Asynchronous);
        await source.CopyToAsync(destination);
    }

    private static string GetLayoutPath(string projectPath)
    {
        var normalized = Path.GetFullPath(projectPath).ToUpperInvariant();
        var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant();
        return Path.Combine(AppPath.AppLocalData, "EditorLayouts", key + ".json");
    }
}

internal sealed record ToolProjectHistoryItem(string Name, string ProjectPath, DateTime LastOpenedUtc)
{
    [JsonIgnore]
    public bool Exists => Directory.Exists(ProjectPath);
    [JsonIgnore]
    public string StateText => Exists ? "可用" : "位置不存在";
    [JsonIgnore]
    public string LastOpenedText => LastOpenedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
}