463 lines
18 KiB
C#
463 lines
18 KiB
C#
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<IReadOnlyList<MapProjectSummary>> 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<MapProjectSummary> 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 = 3,
|
|
Id = id,
|
|
Name = normalizedName,
|
|
CreatedAt = existing?.Document.CreatedAt ?? now,
|
|
UpdatedAt = now,
|
|
IncludesCadastralParcels = includesCadastralParcels,
|
|
Folder = existing?.Document.Folder ?? string.Empty,
|
|
IsArchived = existing?.Document.IsArchived ?? false,
|
|
AccessMode = existing?.Document.AccessMode ?? "editable",
|
|
Map = snapshotDocument.RootElement.Clone()
|
|
};
|
|
|
|
if (existing?.Document.AccessMode == "readonly")
|
|
{
|
|
throw new InvalidOperationException("Il progetto e' in sola lettura. Duplicalo per modificarlo.");
|
|
}
|
|
|
|
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<MapProjectSummary> 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,
|
|
AccessMode = "editable",
|
|
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<IReadOnlyList<MapProjectVersion>> GetVersionsAsync(string projectId)
|
|
{
|
|
await _gate.WaitAsync();
|
|
try
|
|
{
|
|
Directory.CreateDirectory(HistoryDirectory);
|
|
var versions = new List<MapProjectVersion>();
|
|
foreach (var path in Directory.EnumerateFiles(HistoryDirectory, $"{projectId}--*{ProjectExtension}"))
|
|
{
|
|
try
|
|
{
|
|
await using var stream = File.OpenRead(path);
|
|
var document = await JsonSerializer.DeserializeAsync<MapProjectDocument>(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<string> 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<MapProjectDocument>(stream, _jsonOptions)
|
|
?? throw new InvalidDataException("Revisione non leggibile.");
|
|
return document.Map.GetRawText();
|
|
}
|
|
finally { _gate.Release(); }
|
|
}
|
|
|
|
public async Task<string> 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<int> 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<string> CreateProjectPackageAsync(string projectId, bool readOnly = false)
|
|
{
|
|
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 mode = readOnly ? "readonly" : "editable";
|
|
var path = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(project.Path)}-{mode}{ProjectExtension}");
|
|
var package = new MapProjectDocument
|
|
{
|
|
SchemaVersion = 3,
|
|
Id = project.Document.Id,
|
|
Name = project.Document.Name,
|
|
CreatedAt = project.Document.CreatedAt,
|
|
UpdatedAt = project.Document.UpdatedAt,
|
|
IncludesCadastralParcels = project.Document.IncludesCadastralParcels,
|
|
Folder = project.Document.Folder,
|
|
IsArchived = false,
|
|
AccessMode = mode,
|
|
Map = project.Document.Map.Clone()
|
|
};
|
|
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(package, _jsonOptions), new UTF8Encoding(false));
|
|
return path;
|
|
}
|
|
finally { _gate.Release(); }
|
|
}
|
|
|
|
public async Task<MapProjectSummary> ImportProjectAsync(string path)
|
|
{
|
|
await _gate.WaitAsync();
|
|
try
|
|
{
|
|
await using var stream = File.OpenRead(path);
|
|
var source = await JsonSerializer.DeserializeAsync<MapProjectDocument>(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;
|
|
source.AccessMode = source.AccessMode == "readonly" ? "readonly" : "editable";
|
|
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<string> 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();
|
|
}
|
|
}
|
|
|
|
public async Task<string> CreateScheduledBackupAsync(int keep = 10)
|
|
{
|
|
var source = await CreateBackupAsync();
|
|
var directory = Path.Combine(FileSystem.AppDataDirectory, "AutomaticBackups");
|
|
Directory.CreateDirectory(directory);
|
|
var target = Path.Combine(directory, $"LME-auto-{DateTime.Now:yyyyMMdd-HHmmss}.lmebackup.zip");
|
|
File.Copy(source, target, true);
|
|
foreach (var stale in Directory.EnumerateFiles(directory, "*.lmebackup.zip")
|
|
.OrderByDescending(File.GetLastWriteTimeUtc).Skip(Math.Clamp(keep, 2, 30)))
|
|
{
|
|
File.Delete(stale);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
public DateTimeOffset? GetLatestScheduledBackupTime()
|
|
{
|
|
var directory = Path.Combine(FileSystem.AppDataDirectory, "AutomaticBackups");
|
|
if (!Directory.Exists(directory)) return null;
|
|
var latest = Directory.EnumerateFiles(directory, "*.lmebackup.zip")
|
|
.OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault();
|
|
return latest is null ? null : File.GetLastWriteTimeUtc(latest);
|
|
}
|
|
|
|
private async Task<IReadOnlyList<ProjectFileEntry>> ReadProjectFilesAsync()
|
|
{
|
|
Directory.CreateDirectory(ProjectsDirectory);
|
|
var projects = new List<ProjectFileEntry>();
|
|
foreach (var path in Directory.EnumerateFiles(ProjectsDirectory, $"*{ProjectExtension}"))
|
|
{
|
|
try
|
|
{
|
|
await using var stream = File.OpenRead(path);
|
|
var document = await JsonSerializer.DeserializeAsync<MapProjectDocument>(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 string AccessMode { get; set; } = "editable";
|
|
public JsonElement Map { get; set; }
|
|
|
|
public MapProjectSummary ToSummary() => new(Id, Name, UpdatedAt, IncludesCadastralParcels, Folder, IsArchived, AccessMode == "readonly");
|
|
}
|
|
}
|
|
|
|
public sealed record MapProjectSummary(
|
|
string Id,
|
|
string Name,
|
|
DateTimeOffset UpdatedAt,
|
|
bool IncludesCadastralParcels,
|
|
string Folder,
|
|
bool IsArchived,
|
|
bool IsReadOnly);
|
|
|
|
public sealed record MapProjectVersion(string Id, DateTimeOffset UpdatedAt);
|