diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index 5898f3f..66b2291 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -211,6 +211,10 @@ Disegni e misure + + Annulla + Ripristina + Mostra superficie, perimetro e lati @@ -219,6 +223,7 @@ @(DrawToolsVisible ? "Nascondi strumenti sulla mappa" : "Mostra strumenti sulla mappa") Elimina disegni e POI + Recupera ultimo autosalvataggio @@ -246,9 +251,15 @@ GeoJSON KML KMZ compresso + GPX per GPS + Rapporto PDF + + + Solo oggetti selezionati nel pannello Layer + Particelle e dati GIS importati @@ -306,7 +317,7 @@ Seleziona un progetto @foreach (var project in Projects) { - @project.Name - @project.UpdatedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm") + @(project.IsArchived ? "[ARCHIVIATO] " : "")@(string.IsNullOrWhiteSpace(project.Folder) ? "" : $"{project.Folder} / ")@project.Name - @project.UpdatedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm") } @@ -314,9 +325,44 @@ Apri Elimina + + Cartella + + + + Sposta + Duplica + + + @(SelectedProject?.IsArchived == true ? "Ripristina" : "Archivia") + Condividi + + + Revisione precedente + + Seleziona revisione + @foreach (var version in ProjectVersions) + { + @version.UpdatedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss") + } + + + + Apri revisione + Confronta + @ProjectArchiveMessage + + Backup e trasferimento + + Crea backup + Ripristina backup + + Importa progetto condiviso + + Visualizzazione progetto @@ -353,6 +399,7 @@ Topografica - Esri World Topographic Terreno - OpenTopoMap Strade chiare - CARTO Voyager + Offline - solo dati vettoriali Strade - Azure Maps Satellite - Azure Maps Satellite - Google Maps @@ -383,6 +430,22 @@ + + Modifica e sicurezza dati + + + Autosalvataggio locale dopo ogni modifica + + + + Aggancia i vertici agli oggetti vicini + + + Distanza aggancio in pixel + + + + GPS e percorso @@ -393,6 +456,15 @@ Registra la traccia mentre cammini + + + Distanza e direzione dal bordo selezionato + + + + Avvisa quando entro o esco dall'area selezionata + + Salva punto GPS con precisione e ora @@ -453,6 +525,8 @@ private string PoiName { get; set; } = string.Empty; private string SelectedProjectId { get; set; } = string.Empty; private string ProjectArchiveMessage { get; set; } = "Nessun progetto salvato."; + private string ProjectFolder { get; set; } = string.Empty; + private string SelectedVersionId { get; set; } = string.Empty; private string Status { get; set; } = "online"; private string SelectedBaseLayer { get; set; } = "satellite"; private string GoogleMapsApiKey { get; set; } = string.Empty; @@ -486,6 +560,12 @@ private bool ExportDrawings { get; set; } = true; private bool ExportPoi { get; set; } = true; private bool ExportTrail { get; set; } = true; + private bool ExportSelectedOnly { get; set; } + private bool AutoSaveEnabled { get; set; } = true; + private bool SnapEnabled { get; set; } = true; + private int SnapDistancePixels { get; set; } = 14; + private bool GpsProximityEnabled { get; set; } = true; + private bool GpsBoundaryAlertEnabled { get; set; } private CancellationTokenSource? CatalogCancellation { get; set; } private CancellationTokenSource? GpsLookupCancellation { get; set; } private long LastGpsLookupAt; @@ -496,6 +576,8 @@ private IReadOnlyList Sheets { get; set; } = []; private IReadOnlyList Parcels { get; set; } = []; private IReadOnlyList Projects { get; set; } = []; + private IReadOnlyList ProjectVersions { get; set; } = []; + private MapProjectSummary? SelectedProject => Projects.FirstOrDefault(item => item.Id == SelectedProjectId); private MunicipalityOption? SelectedMunicipality => Municipalities.FirstOrDefault(item => item.Code == SelectedMunicipalityCode); @@ -574,6 +656,7 @@ MapCallbackReference = DotNetObjectReference.Create(this); await JS.InvokeVoidAsync("lmeMap.init", MapCallbackReference); await ApplyFlagPreferencesToMapAsync(); + await ApplyAdvancedOptions(); await CenterOnInitialLocationAsync(); await MigrateLegacyProjectAsync(); } @@ -1097,10 +1180,105 @@ } } - private Task SelectProject(ChangeEventArgs args) + private async Task SelectProject(ChangeEventArgs args) { SelectedProjectId = args.Value?.ToString() ?? string.Empty; - return Task.CompletedTask; + ProjectFolder = SelectedProject?.Folder ?? string.Empty; + SelectedVersionId = string.Empty; + ProjectVersions = string.IsNullOrWhiteSpace(SelectedProjectId) + ? [] + : await ProjectStorageService.GetVersionsAsync(SelectedProjectId); + } + + private Task UndoMapChange() => JS.InvokeVoidAsync("lmeMap.undoMapChange").AsTask(); + private Task RedoMapChange() => JS.InvokeVoidAsync("lmeMap.redoMapChange").AsTask(); + private Task RestoreAutosave() => JS.InvokeVoidAsync("lmeMap.restoreAutosave").AsTask(); + private Task SaveGpsMeasurementPoint() => JS.InvokeVoidAsync("lmeMap.saveGpsMeasurementPoint").AsTask(); + + private async Task DuplicateProject() + { + if (SelectedProject is null) return; + var copy = await ProjectStorageService.DuplicateAsync(SelectedProject.Id); + await RefreshProjectsAsync(); + SelectedProjectId = copy.Id; + ProjectFolder = copy.Folder; + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto duplicato: {copy.Name}."); + } + + private async Task UpdateProjectFolder() + { + if (SelectedProject is null) return; + await ProjectStorageService.UpdateOrganizationAsync(SelectedProject.Id, ProjectFolder); + await RefreshProjectsAsync(); + await JS.InvokeVoidAsync("lmeMap.showInfo", "Cartella del progetto aggiornata."); + } + + private async Task ToggleProjectArchive() + { + if (SelectedProject is not { } project) return; + await ProjectStorageService.UpdateOrganizationAsync(project.Id, isArchived: !project.IsArchived); + await RefreshProjectsAsync(); + await JS.InvokeVoidAsync("lmeMap.showInfo", project.IsArchived ? "Progetto ripristinato." : "Progetto archiviato."); + } + + private async Task ShareProject() + { + if (SelectedProject is null) return; + var path = await ProjectStorageService.CreateProjectPackageAsync(SelectedProject.Id); + await Share.Default.RequestAsync(new ShareFileRequest { Title = "Condividi progetto LME", File = new ShareFile(path, "application/json") }); + } + + private async Task ShareFullBackup() + { + var path = await ProjectStorageService.CreateBackupAsync(); + await Share.Default.RequestAsync(new ShareFileRequest { Title = "Backup completo LME Mappe Catastali", File = new ShareFile(path, "application/zip") }); + } + + private async Task RestoreBackup() + { + var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli backup LME" }); + if (file is null) return; + var path = await CopyPickedFileToCacheAsync(file); + var count = await ProjectStorageService.RestoreBackupAsync(path); + await RefreshProjectsAsync(); + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Backup ripristinato: {count} progetti disponibili."); + } + + private async Task ImportProject() + { + var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli progetto LME" }); + if (file is null) return; + var path = await CopyPickedFileToCacheAsync(file); + var imported = await ProjectStorageService.ImportProjectAsync(path); + await RefreshProjectsAsync(); + SelectedProjectId = imported.Id; + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto importato: {imported.Name}."); + } + + private static async Task CopyPickedFileToCacheAsync(FileResult file) + { + var path = Path.Combine(FileSystem.CacheDirectory, $"{Guid.NewGuid():N}-{file.FileName}"); + await using var input = await file.OpenReadAsync(); + await using var output = File.Create(path); + await input.CopyToAsync(output); + return path; + } + + private async Task LoadProjectVersion() + { + if (SelectedProject is null || string.IsNullOrWhiteSpace(SelectedVersionId)) return; + var snapshot = await ProjectStorageService.LoadVersionAsync(SelectedProject.Id, SelectedVersionId); + var options = await JS.InvokeAsync("lmeMap.loadProjectSnapshot", snapshot, $"{SelectedProject.Name} - revisione"); + ApplyProjectDisplayOptions(options); + ActiveTab = "map"; + } + + private async Task CompareProjectVersion() + { + if (SelectedProject is null || string.IsNullOrWhiteSpace(SelectedVersionId)) return; + var current = await ProjectStorageService.LoadAsync(SelectedProject.Id); + var previous = await ProjectStorageService.LoadVersionAsync(SelectedProject.Id, SelectedVersionId); + ProjectArchiveMessage = await JS.InvokeAsync("lmeMap.compareProjectSnapshots", current, previous); } private async Task ExportMapFile() @@ -1122,7 +1300,8 @@ includeCadastral = ExportCadastral, includeDrawings = ExportDrawings, includePoi = ExportPoi, - includeTrail = ExportTrail + includeTrail = ExportTrail, + selectedOnly = ExportSelectedOnly }); var exported = await MapExportService.CreateAsync(ExportFileName, ExportFormat, geoJson); await Share.Default.RequestAsync(new ShareFileRequest @@ -1403,6 +1582,12 @@ ExportDrawings = GetFlagPreference("export_drawings", true); ExportPoi = GetFlagPreference("export_poi", true); ExportTrail = GetFlagPreference("export_trail", true); + ExportSelectedOnly = GetFlagPreference("export_selected_only", false); + AutoSaveEnabled = GetFlagPreference("auto_save", true); + SnapEnabled = GetFlagPreference("snap_enabled", true); + SnapDistancePixels = Preferences.Default.Get($"{FlagPreferencePrefix}snap_distance", 14); + GpsProximityEnabled = GetFlagPreference("gps_proximity", true); + GpsBoundaryAlertEnabled = GetFlagPreference("gps_boundary_alert", false); foreach (var layer in CadastralLayers) { @@ -1428,6 +1613,12 @@ SetFlagPreference("export_drawings", ExportDrawings); SetFlagPreference("export_poi", ExportPoi); SetFlagPreference("export_trail", ExportTrail); + SetFlagPreference("export_selected_only", ExportSelectedOnly); + SetFlagPreference("auto_save", AutoSaveEnabled); + SetFlagPreference("snap_enabled", SnapEnabled); + Preferences.Default.Set($"{FlagPreferencePrefix}snap_distance", SnapDistancePixels); + SetFlagPreference("gps_proximity", GpsProximityEnabled); + SetFlagPreference("gps_boundary_alert", GpsBoundaryAlertEnabled); foreach (var layer in CadastralLayers) { @@ -1453,6 +1644,19 @@ } } + private async Task ApplyAdvancedOptions() + { + SaveFlagPreferences(); + await JS.InvokeVoidAsync("lmeMap.setAdvancedOptions", new + { + autoSaveEnabled = AutoSaveEnabled, + snapEnabled = SnapEnabled, + snapDistancePixels = SnapDistancePixels, + gpsProximityEnabled = GpsProximityEnabled, + gpsBoundaryAlertEnabled = GpsBoundaryAlertEnabled + }); + } + private static bool GetFlagPreference(string name, bool defaultValue) => Preferences.Default.Get($"{FlagPreferencePrefix}{name}", defaultValue); diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index 9d843eb..d7d66cb 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -28,8 +28,8 @@ com.companyname.lmemappecatastaliapp - 0.2.29 - 33 + 0.3.0 + 34 None diff --git a/Services/MapExportService.cs b/Services/MapExportService.cs index f5d74a0..a864975 100644 --- a/Services/MapExportService.cs +++ b/Services/MapExportService.cs @@ -35,6 +35,8 @@ public sealed class MapExportService { "kml" => "kml", "kmz" => "kmz", + "gpx" => "gpx", + "pdf" => "pdf", _ => "geojson" }; var baseName = NormalizeFileName(requestedName); @@ -49,6 +51,18 @@ public sealed class MapExportService return new(path, "application/geo+json", featureCount, Path.GetFileName(path)); } + if (format == "gpx") + { + await File.WriteAllTextAsync(path, BuildGpx(features, baseName), new UTF8Encoding(false), cancellationToken); + return new(path, "application/gpx+xml", featureCount, Path.GetFileName(path)); + } + + if (format == "pdf") + { + await File.WriteAllBytesAsync(path, BuildPdfReport(features, baseName), cancellationToken); + return new(path, "application/pdf", featureCount, Path.GetFileName(path)); + } + var kml = BuildKml(features, baseName); if (format == "kml") { @@ -67,6 +81,108 @@ public sealed class MapExportService return new(path, "application/vnd.google-earth.kmz", featureCount, Path.GetFileName(path)); } + private static string BuildGpx(JsonElement features, string name) + { + var root = new XElement("gpx", + new XAttribute("version", "1.1"), + new XAttribute("creator", "LME Mappe Catastali 0.3.0"), + new XAttribute(XNamespace.Xmlns + "xsi", "http://www.w3.org/2001/XMLSchema-instance"), + new XElement("metadata", new XElement("name", name))); + foreach (var feature in features.EnumerateArray()) + { + if (!feature.TryGetProperty("geometry", out var geometry) || geometry.ValueKind != JsonValueKind.Object) continue; + var properties = feature.TryGetProperty("properties", out var value) ? value : default; + var featureName = FeatureName(properties, GetString(properties, "_lmeCategory") ?? "drawing"); + var type = geometry.GetProperty("type").GetString(); + var coordinates = geometry.GetProperty("coordinates"); + if (type == "Point") + { + root.Add(GpxPoint("wpt", coordinates, featureName)); + } + else + { + foreach (var line in EnumerateGpxLines(type, coordinates)) + { + root.Add(new XElement("trk", new XElement("name", featureName), + new XElement("trkseg", line.Select(point => GpxPoint("trkpt", point, null))))); + } + } + } + return new XDocument(new XDeclaration("1.0", "utf-8", null), root).ToString(); + } + + private static XElement GpxPoint(string element, JsonElement coordinate, string? name) + { + var values = coordinate.EnumerateArray().ToArray(); + var point = new XElement(element, + new XAttribute("lat", values[1].GetDouble().ToString("G17", CultureInfo.InvariantCulture)), + new XAttribute("lon", values[0].GetDouble().ToString("G17", CultureInfo.InvariantCulture))); + if (!string.IsNullOrWhiteSpace(name)) point.Add(new XElement("name", name)); + return point; + } + + private static IEnumerable EnumerateGpxLines(string? type, JsonElement coordinates) + { + if (type == "LineString") yield return coordinates.EnumerateArray().ToArray(); + else if (type == "Polygon") + foreach (var ring in coordinates.EnumerateArray()) yield return ring.EnumerateArray().ToArray(); + else if (type == "MultiLineString") + foreach (var line in coordinates.EnumerateArray()) yield return line.EnumerateArray().ToArray(); + else if (type == "MultiPolygon") + foreach (var polygon in coordinates.EnumerateArray()) + foreach (var ring in polygon.EnumerateArray()) yield return ring.EnumerateArray().ToArray(); + } + + private static byte[] BuildPdfReport(JsonElement features, string title) + { + var lines = new List + { + title, + $"Rapporto LME Mappe Catastali - {DateTime.Now:dd/MM/yyyy HH:mm}", + $"Oggetti: {features.GetArrayLength()}", + "" + }; + var index = 1; + foreach (var feature in features.EnumerateArray()) + { + var properties = feature.TryGetProperty("properties", out var value) ? value : default; + var category = GetString(properties, "_lmeCategory") ?? "drawing"; + var geometry = feature.TryGetProperty("geometry", out var geo) ? geo.GetProperty("type").GetString() : "-"; + lines.Add($"{index++}. {FeatureName(properties, category)} [{category}, {geometry}]"); + } + var content = new StringBuilder("BT /F1 16 Tf 48 790 Td "); + foreach (var line in lines.Take(42)) + { + var safe = line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)") + .Select(character => character <= 255 ? character : '?').ToArray(); + content.Append('(').Append(safe).Append(") Tj 0 -18 Td "); + } + content.Append("ET"); + var stream = Encoding.Latin1.GetBytes(content.ToString()); + var objects = new[] + { + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + $"<< /Length {stream.Length} >>\nstream\n{Encoding.Latin1.GetString(stream)}\nendstream", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>" + }; + using var output = new MemoryStream(); + using var writer = new StreamWriter(output, Encoding.Latin1, leaveOpen: true) { NewLine = "\n" }; + writer.Write("%PDF-1.4\n"); writer.Flush(); + var offsets = new List { 0 }; + for (var i = 0; i < objects.Length; i++) + { + offsets.Add(output.Position); writer.Write($"{i + 1} 0 obj\n{objects[i]}\nendobj\n"); writer.Flush(); + } + var xref = output.Position; + writer.Write($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n"); + foreach (var offset in offsets.Skip(1)) writer.Write($"{offset:0000000000} 00000 n \n"); + writer.Write($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF"); + writer.Flush(); + return output.ToArray(); + } + private static string BuildKml(JsonElement features, string documentName) { var document = new XElement(Kml + "Document", @@ -97,6 +213,11 @@ public sealed class MapExportService var placemark = new XElement(Kml + "Placemark", new XElement(Kml + "name", FeatureName(properties, category)), new XElement(Kml + "styleUrl", $"#{NormalizeStyleId(category)}")); + var featureStyle = BuildFeatureStyle(properties); + if (featureStyle is not null) + { + placemark.Add(featureStyle); + } var extendedData = BuildExtendedData(properties); if (extendedData is not null) @@ -142,6 +263,37 @@ public sealed class MapExportService new XElement(Kml + "width", 3)), new XElement(Kml + "PolyStyle", new XElement(Kml + "color", fillColor))); + private static XElement? BuildFeatureStyle(JsonElement properties) + { + var color = GetString(properties, "_lmeColor"); + var fillColor = GetString(properties, "_lmeFillColor") ?? color; + if (string.IsNullOrWhiteSpace(color) && string.IsNullOrWhiteSpace(fillColor)) return null; + var weight = GetNumber(properties, "_lmeWeight", 3); + var opacity = GetNumber(properties, "_lmeOpacity", 1); + var fillOpacity = GetNumber(properties, "_lmeFillOpacity", 0.2); + return new XElement(Kml + "Style", + new XElement(Kml + "LineStyle", + new XElement(Kml + "color", ToKmlColor(color ?? "#f97316", opacity)), + new XElement(Kml + "width", weight.ToString("0.##", CultureInfo.InvariantCulture))), + new XElement(Kml + "PolyStyle", + new XElement(Kml + "color", ToKmlColor(fillColor ?? "#fb923c", fillOpacity)))); + } + + private static double GetNumber(JsonElement properties, string name, double fallback) => + properties.ValueKind == JsonValueKind.Object + && properties.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.Number + ? value.GetDouble() + : fallback; + + private static string ToKmlColor(string html, double opacity) + { + var value = html.TrimStart('#'); + if (value.Length != 6) value = "f97316"; + var alpha = (int)Math.Round(Math.Clamp(opacity, 0, 1) * 255); + return $"{alpha:x2}{value[4..6]}{value[2..4]}{value[0..2]}"; + } + private static XElement? BuildExtendedData(JsonElement properties) { if (properties.ValueKind != JsonValueKind.Object) diff --git a/Services/ProjectStorageService.cs b/Services/ProjectStorageService.cs index 0406d72..3c2ff52 100644 --- a/Services/ProjectStorageService.cs +++ b/Services/ProjectStorageService.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using System.IO.Compression; namespace LMEMappeCatastaliApp.Services; @@ -13,8 +14,9 @@ public sealed class ProjectStorageService }; private string ProjectsDirectory => Path.Combine(FileSystem.AppDataDirectory, "Projects"); + private string HistoryDirectory => Path.Combine(ProjectsDirectory, ".history"); - public async Task> GetProjectsAsync() + public async Task> GetProjectsAsync(bool includeArchived = true) { await _gate.WaitAsync(); try @@ -22,6 +24,7 @@ public sealed class ProjectStorageService 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(); @@ -56,14 +59,23 @@ public sealed class ProjectStorageService 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 = 1, + 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() }; @@ -93,6 +105,178 @@ public sealed class ProjectStorageService } } + 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(); @@ -118,6 +302,13 @@ public sealed class ProjectStorageService .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 { @@ -179,19 +370,36 @@ public sealed class ProjectStorageService 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; 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 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); + public MapProjectSummary ToSummary() => new(Id, Name, UpdatedAt, IncludesCadastralParcels, Folder, IsArchived); } } @@ -199,4 +407,8 @@ public sealed record MapProjectSummary( string Id, string Name, DateTimeOffset UpdatedAt, - bool IncludesCadastralParcels); + bool IncludesCadastralParcels, + string Folder, + bool IsArchived); + +public sealed record MapProjectVersion(string Id, DateTimeOffset UpdatedAt); diff --git a/wwwroot/css/app.css b/wwwroot/css/app.css index 8e137cc..b411f0d 100644 --- a/wwwroot/css/app.css +++ b/wwwroot/css/app.css @@ -177,7 +177,7 @@ input[type="checkbox"] { top: 72px; right: 12px; bottom: calc(var(--nav-height) + 8px); - z-index: 940; + z-index: 1080; display: grid; box-sizing: border-box; width: min(480px, 50vw); @@ -263,6 +263,151 @@ input[type="checkbox"] { gap: 4px; } +.layer-selection-toolbar { + position: sticky; + top: -9px; + z-index: 3; + display: grid; + gap: 6px; + padding: 8px 4px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.98); +} + +.layer-selection-toolbar label, +.layer-object-tools > label:not(.layer-manager-suboption), +.layer-style-controls label { + display: grid; + gap: 3px; + font-size: 9px; +} + +.layer-selection-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font-size: 10px; +} + +.layer-selection-summary button, +.layer-batch-actions button, +.layer-compact-actions button, +.layer-object-tools > button { + min-height: 28px; + min-width: 0; + padding: 4px 6px; + font-size: 9px; +} + +.layer-selection-summary button { + max-width: 48%; +} + +.layer-batch-actions, +.layer-compact-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; +} + +.layer-group-tools, +.layer-object-tools { + display: grid; + gap: 6px; + min-width: 0; + padding: 7px; + border: 1px dashed var(--layer-group-border, #cbd5da); + border-radius: 4px; + background: rgba(255, 255, 255, 0.7); +} + +.layer-group-tools { + margin: 0 3px 4px; +} + +.layer-group-totals { + color: var(--muted); + font-size: 9px; +} + +.layer-style-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; +} + +.layer-style-controls input, +.layer-style-controls select, +.layer-object-tools textarea { + width: 100%; + min-width: 0; + min-height: 28px; + padding: 3px; + font-size: 10px; +} + +.layer-style-controls input[type="color"] { + padding: 1px; +} + +.layer-photo-picker { + position: relative; + min-height: 32px; + place-items: center; + border: 1px dashed #8aa2aa; + border-radius: 4px; +} + +.layer-photo-picker input { + position: absolute; + inset: 0; + opacity: 0; +} + +.layer-photo-preview { + display: block; + width: 100%; + max-height: 140px; + object-fit: contain; +} + +.lme-popup-photo { + display: block; + width: min(240px, 60vw); + max-height: 180px; + margin-top: 6px; + object-fit: contain; +} + +.lme-permanent-label { + width: 0 !important; + height: 0 !important; + border: 0 !important; + background: transparent !important; +} + +.lme-permanent-label span { + position: absolute; + transform: translate(-50%, -50%); + padding: 2px 5px; + border: 1px solid #52616c; + border-radius: 3px; + background: rgba(255, 255, 255, 0.92); + color: #17212b; + font-size: 10px; + font-weight: 800; + white-space: nowrap; +} + +.lme-offline-tile { + box-sizing: border-box; + width: 256px; + height: 256px; + border: 1px solid #d8e0e3; + background-color: #f5f7f8; +} + .layer-manager-group, .layer-manager-block, .layer-manager-object { diff --git a/wwwroot/index.html b/wwwroot/index.html index e383e53..4c0e2f9 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -31,6 +31,7 @@ +