413 lines
14 KiB
C#
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;
|
|
}
|
|
}
|