Initial commit
This commit is contained in:
412
srcs/RobotNet10/Commons/RobotNet10.ScriptEngine/FileManager.cs
Normal file
412
srcs/RobotNet10/Commons/RobotNet10.ScriptEngine/FileManager.cs
Normal file
@@ -0,0 +1,412 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user