using System.Collections.Concurrent; using System.IO.Compression; using System.Text; using RobotNet10.ScriptEngine.Shared; namespace RobotNet10.ScriptEngine; /// /// Service for managing script files and folders. /// public class FileManager { private readonly Lock _stateLockObject = new(); private ScriptEngineState _state = ScriptEngineState.Idle; private string? _editConnectionId; /// /// Gets or sets the root path for script files. /// public string RootPath { get; set; } = "scripts"; /// /// Gets or sets the backup path for script backups. /// public string BackupPath { get; set; } = "bkscripts"; /// /// Gets the current state of the FileManager. /// public ScriptEngineState State => _state; /// /// Gets the connection ID that currently has edit permission. /// public string? EditConnectionId => _editConnectionId; /// /// Initializes a new instance of FileManager and ensures root directories exist. /// public FileManager() { if (!Directory.Exists(RootPath)) { Directory.CreateDirectory(RootPath); } if (!Directory.Exists(BackupPath)) { Directory.CreateDirectory(BackupPath); } } /// /// Checks if editing is allowed in the current state. /// According to StateMachine_Design.md, scripts can only be edited when state is Idle. /// public bool IsEditAllowed() { return _state == ScriptEngineState.Idle; } /// /// Checks if rollback is allowed in the current state. /// public bool IsRollbackAllowed() { return _state == ScriptEngineState.Idle || _state == ScriptEngineState.Ready || _state == ScriptEngineState.BuildError; } /// /// Sets the state of the FileManager. /// Note: Permission is not automatically revoked when state changes. /// HasEditPermission will return false when state is not Idle, effectively locking the UI. /// public void SetState(ScriptEngineState newState) { lock (_stateLockObject) { _state = newState; } } /// /// Requests edit permission for a connection. Always succeeds and forces out the previous connection. /// The previous connection will be notified. If state is not Idle, permission is granted but HasEditPermission will return false. /// /// The connection ID that previously had edit permission, or null if none. public string? RequestEditPermission(string connectionId) { lock (_stateLockObject) { // Always grant permission to the requesting connection, forcing out any previous connection // This handles cases where previous connection disconnected abruptly without revoking var previousConnectionId = _editConnectionId; _editConnectionId = connectionId; return previousConnectionId; } } /// /// Revokes edit permission for a connection. /// public void RevokeEditPermission(string connectionId) { lock (_stateLockObject) { if (_editConnectionId == connectionId) { _editConnectionId = null; } } } /// /// Checks if a connection has edit permission. /// public bool HasEditPermission(string connectionId) { return _editConnectionId == connectionId && IsEditAllowed(); } /// /// Gets the root folder structure with all files and folders. /// public async Task GetRootFolderAsync() { return await Task.Run(() => BuildFolderStructure(RootPath, "Scripts", 0)); } private static ScriptFolderDto BuildFolderStructure(string directoryPath, string folderName, int level) { if (!Directory.Exists(directoryPath)) { return new ScriptFolderDto(folderName, level, [], []); } var folders = new List(); var files = new List(); // Process subdirectories foreach (var dir in Directory.GetDirectories(directoryPath).OrderBy(d => Path.GetFileName(d))) { var dirName = Path.GetFileName(dir); var subFolder = BuildFolderStructure(dir, dirName, level + 1); folders.Add(subFolder); } // Process files foreach (var file in Directory.GetFiles(directoryPath).OrderBy(f => Path.GetFileName(f))) { var fileName = Path.GetFileName(file); var code = File.ReadAllText(file, Encoding.UTF8); files.Add(new ScriptFileDto(fileName, level + 1, code)); } return new ScriptFolderDto(folderName, level, [.. folders], [.. files]); } /// /// Saves file content. /// public async Task SaveFileAsync(string relativePath, string content, string connectionId) { if (!HasEditPermission(connectionId)) throw new UnauthorizedAccessException($"Connection '{connectionId}' does not have edit permission"); var fullPath = Path.Combine(RootPath, NormalizePath(relativePath)); var directory = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8); } /// /// Creates a new file. /// public async Task CreateFileAsync(string relativePath, string content, string connectionId) { if (!HasEditPermission(connectionId)) throw new UnauthorizedAccessException($"Connection '{connectionId}' does not have edit permission"); var fullPath = Path.Combine(RootPath, NormalizePath(relativePath)); if (File.Exists(fullPath)) throw new IOException($"File '{relativePath}' already exists"); var directory = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8); } /// /// Creates a new folder. /// public void CreateFolder(string relativePath, string connectionId) { if (!HasEditPermission(connectionId)) throw new UnauthorizedAccessException($"Connection '{connectionId}' does not have edit permission"); var fullPath = Path.Combine(RootPath, NormalizePath(relativePath)); if (Directory.Exists(fullPath)) throw new IOException($"Folder '{relativePath}' already exists"); Directory.CreateDirectory(fullPath); } /// /// Deletes a file. /// public void DeleteFile(string relativePath, string connectionId) { if (!HasEditPermission(connectionId)) throw new UnauthorizedAccessException($"Connection '{connectionId}' does not have edit permission"); var fullPath = Path.Combine(RootPath, NormalizePath(relativePath)); if (!File.Exists(fullPath)) throw new FileNotFoundException($"File '{relativePath}' not found"); File.Delete(fullPath); } /// /// Deletes a folder. /// public void DeleteFolder(string relativePath, string connectionId) { if (!HasEditPermission(connectionId)) throw new UnauthorizedAccessException($"Connection '{connectionId}' does not have edit permission"); var fullPath = Path.Combine(RootPath, NormalizePath(relativePath)); if (!Directory.Exists(fullPath)) throw new DirectoryNotFoundException($"Folder '{relativePath}' not found"); Directory.Delete(fullPath, recursive: true); } /// /// Creates a backup ZIP file of all scripts. /// public async Task CreateBackupAsync(string? backupName = null) { string backupFileName; if (string.IsNullOrWhiteSpace(backupName)) { // Default name with timestamp var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HHmmss"); backupFileName = $"ScriptBackup_{timestamp}.zip"; } else { // Use provided name, ensure ScriptBackup_ prefix and .zip extension backupName = backupName.Trim(); // Remove .zip extension if present to normalize if (backupName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) { backupName = backupName.Substring(0, backupName.Length - 4); } // Add ScriptBackup_ prefix if not present if (!backupName.StartsWith("ScriptBackup_", StringComparison.OrdinalIgnoreCase)) { backupFileName = $"ScriptBackup_{backupName}.zip"; } else { backupFileName = $"{backupName}.zip"; } } var backupFilePath = Path.Combine(BackupPath, backupFileName); if (File.Exists(backupFilePath)) File.Delete(backupFilePath); await Task.Run(() => { using var zipArchive = ZipFile.Open(backupFilePath, ZipArchiveMode.Create); var rootFullPath = Path.GetFullPath(RootPath); if (Directory.Exists(RootPath)) { foreach (var file in Directory.GetFiles(RootPath, "*", SearchOption.AllDirectories)) { var relativePath = Path.GetRelativePath(rootFullPath, file); zipArchive.CreateEntryFromFile(file, relativePath.Replace('\\', '/')); } } }); return backupFileName; } /// /// Restores scripts from a backup ZIP file. /// public async Task RestoreBackupAsync(string backupFileName, bool replaceExisting = true) { if (!IsRollbackAllowed()) throw new InvalidOperationException($"Cannot restore backup in state: {_state}"); var backupFilePath = Path.Combine(BackupPath, backupFileName); if (!File.Exists(backupFilePath)) throw new FileNotFoundException($"Backup file '{backupFileName}' not found"); await Task.Run(() => { // Clear existing scripts if replacing if (replaceExisting && Directory.Exists(RootPath)) { Directory.Delete(RootPath, recursive: true); } if (!Directory.Exists(RootPath)) { Directory.CreateDirectory(RootPath); } // Extract ZIP ZipFile.ExtractToDirectory(backupFilePath, RootPath, overwriteFiles: replaceExisting); }); } /// /// Lists available backup files. /// public ScriptBackupInfo[] ListBackups() { if (!Directory.Exists(BackupPath)) return []; return [.. Directory.GetFiles(BackupPath, "ScriptBackup_*.zip") .Select(file => { var fileInfo = new FileInfo(file); return new ScriptBackupInfo { FileName = Path.GetFileName(file), Size = fileInfo.Length, CreatedAt = fileInfo.CreationTime }; }) .OrderByDescending(x => x.CreatedAt)]; } /// /// Deletes a backup file. /// public void DeleteBackup(string backupFileName) { var backupFilePath = Path.Combine(BackupPath, backupFileName); if (!File.Exists(backupFilePath)) throw new FileNotFoundException($"Backup file '{backupFileName}' not found"); File.Delete(backupFilePath); } /// /// Aggregates all script files code into a single string. /// Files are processed in alphabetical order by path. /// /// The aggregated code from all script files. public async Task AggregateAllCodeAsync() { return await Task.Run(() => { if (!Directory.Exists(RootPath)) return string.Empty; var codeBuilder = new StringBuilder(); var allFiles = Directory.GetFiles(RootPath, "*.cs", SearchOption.AllDirectories) .OrderBy(f => f) .ToList(); foreach (var file in allFiles) { var code = File.ReadAllText(file, Encoding.UTF8); if (!string.IsNullOrWhiteSpace(code)) { codeBuilder.AppendLine(code); codeBuilder.AppendLine(); // Add blank line between files } } return codeBuilder.ToString().TrimEnd(); }); } private static string NormalizePath(string path) { // Normalize path separators path = path.Replace('\\', '/'); // Remove leading slash if (path.StartsWith('/')) path = path[1..]; // Prevent directory traversal if (path.Contains("..")) throw new ArgumentException("Path cannot contain '..'", nameof(path)); return path; } }