using System.Text; using System.Text.Json; using System.IO.Compression; 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"); private string HistoryDirectory => Path.Combine(ProjectsDirectory, ".history"); public async Task> GetProjectsAsync(bool includeArchived = true) { await _gate.WaitAsync(); try { var files = await ReadProjectFilesAsync(); return files .Select(item => item.Document.ToSummary()) .Where(item => includeArchived || !item.IsArchived) .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"); if (existing is not null) { Directory.CreateDirectory(HistoryDirectory); var revisionName = $"{id}--{existing.Document.UpdatedAt:yyyyMMddHHmmssfff}{ProjectExtension}"; File.Copy(existing.Path, Path.Combine(HistoryDirectory, revisionName), true); PruneHistory(id, 15); } var document = new MapProjectDocument { SchemaVersion = 2, Id = id, Name = normalizedName, CreatedAt = existing?.Document.CreatedAt ?? now, UpdatedAt = now, IncludesCadastralParcels = includesCadastralParcels, Folder = existing?.Document.Folder ?? string.Empty, IsArchived = existing?.Document.IsArchived ?? false, 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 DuplicateAsync(string projectId) { await _gate.WaitAsync(); try { var files = await ReadProjectFilesAsync(); var source = files.FirstOrDefault(item => item.Document.Id == projectId) ?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'."); var usedNames = files.Select(item => item.Document.Name).ToHashSet(StringComparer.CurrentCultureIgnoreCase); var baseName = $"{source.Document.Name} - copia"; var name = baseName; for (var index = 2; usedNames.Contains(name); index++) { name = $"{baseName} {index}"; } var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid().ToString("N"); var copy = new MapProjectDocument { SchemaVersion = 2, Id = id, Name = name, CreatedAt = now, UpdatedAt = now, IncludesCadastralParcels = source.Document.IncludesCadastralParcels, Folder = source.Document.Folder, Map = source.Document.Map.Clone() }; var path = Path.Combine(ProjectsDirectory, BuildFileName(name, id)); await File.WriteAllTextAsync(path, JsonSerializer.Serialize(copy, _jsonOptions), new UTF8Encoding(false)); return copy.ToSummary(); } finally { _gate.Release(); } } public async Task UpdateOrganizationAsync(string projectId, string? folder = null, bool? isArchived = null) { await _gate.WaitAsync(); try { var project = (await ReadProjectFilesAsync()).FirstOrDefault(item => item.Document.Id == projectId) ?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'."); project.Document.Folder = NormalizeFolder(folder ?? project.Document.Folder); project.Document.IsArchived = isArchived ?? project.Document.IsArchived; project.Document.UpdatedAt = DateTimeOffset.UtcNow; await File.WriteAllTextAsync(project.Path, JsonSerializer.Serialize(project.Document, _jsonOptions), new UTF8Encoding(false)); } finally { _gate.Release(); } } public async Task> GetVersionsAsync(string projectId) { await _gate.WaitAsync(); try { Directory.CreateDirectory(HistoryDirectory); var versions = new List(); foreach (var path in Directory.EnumerateFiles(HistoryDirectory, $"{projectId}--*{ProjectExtension}")) { try { await using var stream = File.OpenRead(path); var document = await JsonSerializer.DeserializeAsync(stream, _jsonOptions); if (document is not null) { versions.Add(new(Path.GetFileName(path), document.UpdatedAt)); } } catch (JsonException) { } catch (IOException) { } } return versions.OrderByDescending(item => item.UpdatedAt).ToArray(); } finally { _gate.Release(); } } public async Task LoadVersionAsync(string projectId, string versionId) { await _gate.WaitAsync(); try { if (!versionId.StartsWith($"{projectId}--", StringComparison.Ordinal) || Path.GetFileName(versionId) != versionId) { throw new InvalidDataException("Revisione non valida."); } var path = Path.Combine(HistoryDirectory, versionId); await using var stream = File.OpenRead(path); var document = await JsonSerializer.DeserializeAsync(stream, _jsonOptions) ?? throw new InvalidDataException("Revisione non leggibile."); return document.Map.GetRawText(); } finally { _gate.Release(); } } public async Task CreateBackupAsync() { await _gate.WaitAsync(); try { Directory.CreateDirectory(ProjectsDirectory); var directory = Path.Combine(FileSystem.CacheDirectory, "Backups"); Directory.CreateDirectory(directory); var path = Path.Combine(directory, $"LMEMappeCatastali-backup-{DateTime.Now:yyyyMMdd-HHmmss}.lmebackup.zip"); if (File.Exists(path)) File.Delete(path); ZipFile.CreateFromDirectory(ProjectsDirectory, path, CompressionLevel.Optimal, false); return path; } finally { _gate.Release(); } } public async Task RestoreBackupAsync(string backupPath) { await _gate.WaitAsync(); try { Directory.CreateDirectory(ProjectsDirectory); using var archive = ZipFile.OpenRead(backupPath); var restored = 0; foreach (var entry in archive.Entries.Where(item => item.FullName.EndsWith(ProjectExtension, StringComparison.OrdinalIgnoreCase))) { var fileName = Path.GetFileName(entry.FullName); if (string.IsNullOrWhiteSpace(fileName)) continue; var targetDirectory = entry.FullName.Contains(".history/", StringComparison.OrdinalIgnoreCase) || entry.FullName.Contains(".history\\", StringComparison.OrdinalIgnoreCase) ? HistoryDirectory : ProjectsDirectory; Directory.CreateDirectory(targetDirectory); entry.ExtractToFile(Path.Combine(targetDirectory, fileName), true); if (targetDirectory == ProjectsDirectory) restored++; } return restored; } finally { _gate.Release(); } } public async Task CreateProjectPackageAsync(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'."); var directory = Path.Combine(FileSystem.CacheDirectory, "Projects"); Directory.CreateDirectory(directory); var path = Path.Combine(directory, Path.GetFileName(project.Path)); File.Copy(project.Path, path, true); return path; } finally { _gate.Release(); } } public async Task ImportProjectAsync(string path) { await _gate.WaitAsync(); try { await using var stream = File.OpenRead(path); var source = await JsonSerializer.DeserializeAsync(stream, _jsonOptions) ?? throw new InvalidDataException("Progetto non leggibile."); if (source.Map.ValueKind != JsonValueKind.Object) throw new InvalidDataException("Mappa del progetto non valida."); source.Id = Guid.NewGuid().ToString("N"); source.Name = NormalizeName(source.Name); source.CreatedAt = source.UpdatedAt = DateTimeOffset.UtcNow; source.IsArchived = false; Directory.CreateDirectory(ProjectsDirectory); var target = Path.Combine(ProjectsDirectory, BuildFileName(source.Name, source.Id)); await File.WriteAllTextAsync(target, JsonSerializer.Serialize(source, _jsonOptions), new UTF8Encoding(false)); return source.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); if (Directory.Exists(HistoryDirectory)) { foreach (var path in Directory.EnumerateFiles(HistoryDirectory, $"{projectId}--*{ProjectExtension}")) { File.Delete(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 void PruneHistory(string projectId, int keep) { foreach (var path in Directory.EnumerateFiles(HistoryDirectory, $"{projectId}--*{ProjectExtension}") .OrderByDescending(File.GetLastWriteTimeUtc).Skip(keep)) { File.Delete(path); } } private static string NormalizeFolder(string value) { var normalized = string.Join(' ', (value ?? string.Empty).Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)); return normalized[..Math.Min(normalized.Length, 60)]; } private sealed record ProjectFileEntry(string Path, MapProjectDocument Document); private sealed class MapProjectDocument { public int SchemaVersion { get; set; } public string Id { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } public bool IncludesCadastralParcels { get; set; } public string Folder { get; set; } = string.Empty; public bool IsArchived { get; set; } public JsonElement Map { get; set; } public MapProjectSummary ToSummary() => new(Id, Name, UpdatedAt, IncludesCadastralParcels, Folder, IsArchived); } } public sealed record MapProjectSummary( string Id, string Name, DateTimeOffset UpdatedAt, bool IncludesCadastralParcels, string Folder, bool IsArchived); public sealed record MapProjectVersion(string Id, DateTimeOffset UpdatedAt);