using System.Text; using System.Text.Json; namespace LMEMappeCatastaliApp.Services; public sealed class ProjectStorageService { private const string ProjectExtension = ".lmeproject.json"; private readonly SemaphoreSlim _gate = new(1, 1); private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private string ProjectsDirectory => Path.Combine(FileSystem.AppDataDirectory, "Projects"); public async Task> GetProjectsAsync() { await _gate.WaitAsync(); try { var files = await ReadProjectFilesAsync(); return files .Select(item => item.Document.ToSummary()) .OrderByDescending(item => item.UpdatedAt) .ThenBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase) .ToArray(); } finally { _gate.Release(); } } public async Task SaveAsync( string name, string snapshotJson, bool includesCadastralParcels, string? projectId = null) { var normalizedName = NormalizeName(name); using var snapshotDocument = JsonDocument.Parse(snapshotJson); if (snapshotDocument.RootElement.ValueKind != JsonValueKind.Object) { throw new InvalidDataException("I dati della mappa non sono validi."); } await _gate.WaitAsync(); try { Directory.CreateDirectory(ProjectsDirectory); var files = await ReadProjectFilesAsync(); var existing = files.FirstOrDefault(item => (!string.IsNullOrWhiteSpace(projectId) && item.Document.Id == projectId) || string.Equals(item.Document.Name, normalizedName, StringComparison.CurrentCultureIgnoreCase)); var now = DateTimeOffset.UtcNow; var id = existing?.Document.Id ?? Guid.NewGuid().ToString("N"); var document = new MapProjectDocument { SchemaVersion = 1, Id = id, Name = normalizedName, CreatedAt = existing?.Document.CreatedAt ?? now, UpdatedAt = now, IncludesCadastralParcels = includesCadastralParcels, Map = snapshotDocument.RootElement.Clone() }; var path = existing?.Path ?? Path.Combine(ProjectsDirectory, BuildFileName(normalizedName, id)); var temporaryPath = path + ".tmp"; try { await File.WriteAllTextAsync( temporaryPath, JsonSerializer.Serialize(document, _jsonOptions), new UTF8Encoding(false)); File.Move(temporaryPath, path, true); } finally { if (File.Exists(temporaryPath)) { File.Delete(temporaryPath); } } return document.ToSummary(); } finally { _gate.Release(); } } public async Task LoadAsync(string projectId) { await _gate.WaitAsync(); try { var project = (await ReadProjectFilesAsync()) .FirstOrDefault(item => item.Document.Id == projectId) ?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'."); return project.Document.Map.GetRawText(); } finally { _gate.Release(); } } public async Task DeleteAsync(string projectId) { await _gate.WaitAsync(); try { var project = (await ReadProjectFilesAsync()) .FirstOrDefault(item => item.Document.Id == projectId) ?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'."); File.Delete(project.Path); } finally { _gate.Release(); } } private async Task> ReadProjectFilesAsync() { Directory.CreateDirectory(ProjectsDirectory); var projects = new List(); foreach (var path in Directory.EnumerateFiles(ProjectsDirectory, $"*{ProjectExtension}")) { try { await using var stream = File.OpenRead(path); var document = await JsonSerializer.DeserializeAsync(stream, _jsonOptions); if (document is not null && !string.IsNullOrWhiteSpace(document.Id) && !string.IsNullOrWhiteSpace(document.Name) && document.Map.ValueKind == JsonValueKind.Object) { projects.Add(new ProjectFileEntry(path, document)); } } catch (JsonException) { // A damaged project is ignored so the remaining archive stays usable. } catch (IOException) { } } return projects; } private static string NormalizeName(string name) { var normalized = string.Join(' ', (name ?? string.Empty).Split( [' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)); if (string.IsNullOrWhiteSpace(normalized)) { throw new ArgumentException("Inserisci il nome del progetto."); } return normalized.Length <= 80 ? normalized : normalized[..80]; } private static string BuildFileName(string name, string id) { var invalid = Path.GetInvalidFileNameChars().ToHashSet(); var safeName = new string(name .Select(character => invalid.Contains(character) ? '-' : character) .ToArray()) .Trim(' ', '.'); safeName = string.IsNullOrWhiteSpace(safeName) ? "Progetto" : safeName; return $"{safeName}--{id[..8]}{ProjectExtension}"; } private sealed record ProjectFileEntry(string Path, MapProjectDocument Document); private sealed class MapProjectDocument { public int SchemaVersion { get; init; } public string Id { get; init; } = string.Empty; public string Name { get; init; } = string.Empty; public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset UpdatedAt { get; init; } public bool IncludesCadastralParcels { get; init; } public JsonElement Map { get; init; } public MapProjectSummary ToSummary() => new(Id, Name, UpdatedAt, IncludesCadastralParcels); } } public sealed record MapProjectSummary( string Id, string Name, DateTimeOffset UpdatedAt, bool IncludesCadastralParcels);