Files
I150/srcs/RobotNet10/Commons/RobotNet10.ScriptEngine/FileManager.cs
2026-07-03 16:37:12 +07:00

413 lines
14 KiB
C#

using System.Collections.Concurrent;
using System.IO.Compression;
using System.Text;
using RobotNet10.ScriptEngine.Shared;
namespace RobotNet10.ScriptEngine;
/// <summary>
/// Service for managing script files and folders.
/// </summary>
public class FileManager
{
private readonly Lock _stateLockObject = new();
private ScriptEngineState _state = ScriptEngineState.Idle;
private string? _editConnectionId;
/// <summary>
/// Gets or sets the root path for script files.
/// </summary>
public string RootPath { get; set; } = "scripts";
/// <summary>
/// Gets or sets the backup path for script backups.
/// </summary>
public string BackupPath { get; set; } = "bkscripts";
/// <summary>
/// Gets the current state of the FileManager.
/// </summary>
public ScriptEngineState State => _state;
/// <summary>
/// Gets the connection ID that currently has edit permission.
/// </summary>
public string? EditConnectionId => _editConnectionId;
/// <summary>
/// Initializes a new instance of FileManager and ensures root directories exist.
/// </summary>
public FileManager()
{
if (!Directory.Exists(RootPath))
{
Directory.CreateDirectory(RootPath);
}
if (!Directory.Exists(BackupPath))
{
Directory.CreateDirectory(BackupPath);
}
}
/// <summary>
/// Checks if editing is allowed in the current state.
/// According to StateMachine_Design.md, scripts can only be edited when state is Idle.
/// </summary>
public bool IsEditAllowed()
{
return _state == ScriptEngineState.Idle;
}
/// <summary>
/// Checks if rollback is allowed in the current state.
/// </summary>
public bool IsRollbackAllowed()
{
return _state == ScriptEngineState.Idle || _state == ScriptEngineState.Ready || _state == ScriptEngineState.BuildError;
}
/// <summary>
/// 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.
/// </summary>
public void SetState(ScriptEngineState newState)
{
lock (_stateLockObject)
{
_state = newState;
}
}
/// <summary>
/// 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.
/// </summary>
/// <returns>The connection ID that previously had edit permission, or null if none.</returns>
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;
}
}
/// <summary>
/// Revokes edit permission for a connection.
/// </summary>
public void RevokeEditPermission(string connectionId)
{
lock (_stateLockObject)
{
if (_editConnectionId == connectionId)
{
_editConnectionId = null;
}
}
}
/// <summary>
/// Checks if a connection has edit permission.
/// </summary>
public bool HasEditPermission(string connectionId)
{
return _editConnectionId == connectionId && IsEditAllowed();
}
/// <summary>
/// Gets the root folder structure with all files and folders.
/// </summary>
public async Task<ScriptFolderDto> 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<ScriptFolderDto>();
var files = new List<ScriptFileDto>();
// 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]);
}
/// <summary>
/// Saves file content.
/// </summary>
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);
}
/// <summary>
/// Creates a new file.
/// </summary>
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);
}
/// <summary>
/// Creates a new folder.
/// </summary>
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);
}
/// <summary>
/// Deletes a file.
/// </summary>
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);
}
/// <summary>
/// Deletes a folder.
/// </summary>
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);
}
/// <summary>
/// Creates a backup ZIP file of all scripts.
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// Restores scripts from a backup ZIP file.
/// </summary>
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);
});
}
/// <summary>
/// Lists available backup files.
/// </summary>
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)];
}
/// <summary>
/// Deletes a backup file.
/// </summary>
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);
}
/// <summary>
/// Aggregates all script files code into a single string.
/// Files are processed in alphabetical order by path.
/// </summary>
/// <returns>The aggregated code from all script files.</returns>
public async Task<string> 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;
}
}