Rilascia gestione completa progetti e aggiornamenti 0.4.0
This commit is contained in:
1 parent
620a4bec26
commit
205a520708
10 files changed
+1366
-65
No files matched your search
@@ -0,0 +1,44 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LMEMappeCatastaliApp.Services;
|
||||
|
||||
public sealed class AppUpdateService
|
||||
{
|
||||
private const string LatestReleaseUrl = "https://gitea.lmcloud.it/api/v1/repos/livio/LMEMappeCatastaliApp/releases/latest";
|
||||
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(20) };
|
||||
|
||||
public static string CurrentVersion =>
|
||||
typeof(AppUpdateService).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion.Split('+')[0]
|
||||
?? AppInfo.Current.VersionString;
|
||||
|
||||
public async Task<AppUpdateResult> CheckAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var release = await _httpClient.GetFromJsonAsync<GiteaRelease>(LatestReleaseUrl, cancellationToken)
|
||||
?? throw new InvalidDataException("Risposta aggiornamenti non valida.");
|
||||
var latestText = (release.TagName ?? string.Empty).TrimStart('v', 'V');
|
||||
_ = Version.TryParse(CurrentVersion, out var current);
|
||||
_ = Version.TryParse(latestText, out var latest);
|
||||
var apk = release.Assets?.FirstOrDefault(item => item.Name?.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) == true);
|
||||
return new(latest is not null && current is not null && latest > current, latestText, release.HtmlUrl ?? string.Empty, apk?.BrowserDownloadUrl ?? release.HtmlUrl ?? string.Empty, release.Body ?? string.Empty);
|
||||
}
|
||||
|
||||
private sealed class GiteaRelease
|
||||
{
|
||||
[JsonPropertyName("tag_name")] public string? TagName { get; set; }
|
||||
[JsonPropertyName("html_url")] public string? HtmlUrl { get; set; }
|
||||
[JsonPropertyName("assets")] public GiteaAsset[]? Assets { get; set; }
|
||||
[JsonPropertyName("body")] public string? Body { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GiteaAsset
|
||||
{
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AppUpdateResult(bool IsAvailable, string Version, string ReleaseUrl, string DownloadUrl, string ReleaseNotes);
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
|
||||
namespace LMEMappeCatastaliApp.Services;
|
||||
|
||||
public sealed class CloudSyncService
|
||||
{
|
||||
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromMinutes(3) };
|
||||
|
||||
public async Task UploadAsync(string endpoint, string userName, string password, string localPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var target = BuildTarget(endpoint);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, target);
|
||||
ApplyCredentials(request, userName, password);
|
||||
request.Content = new StreamContent(File.OpenRead(localPath));
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<string> DownloadAsync(string endpoint, string userName, string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildTarget(endpoint));
|
||||
ApplyCredentials(request, userName, password);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var directory = Path.Combine(FileSystem.CacheDirectory, "CloudSync");
|
||||
Directory.CreateDirectory(directory);
|
||||
var path = Path.Combine(directory, $"LME-cloud-{DateTime.Now:yyyyMMdd-HHmmss}.lmebackup.zip");
|
||||
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var output = File.Create(path);
|
||||
await input.CopyToAsync(output, cancellationToken);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static Uri BuildTarget(string endpoint)
|
||||
{
|
||||
if (!Uri.TryCreate(endpoint?.Trim(), UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
|
||||
{
|
||||
throw new ArgumentException("Inserisci un indirizzo WebDAV http o https valido.");
|
||||
}
|
||||
return uri.AbsolutePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||
? uri
|
||||
: new Uri($"{uri.ToString().TrimEnd('/')}/LMEMappeCatastali-sync.lmebackup.zip");
|
||||
}
|
||||
|
||||
private static void ApplyCredentials(HttpRequestMessage request, string userName, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userName)) return;
|
||||
var value = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userName}:{password}"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", value);
|
||||
}
|
||||
}
|
||||
@@ -135,11 +135,43 @@ public sealed class MapExportService
|
||||
|
||||
private static byte[] BuildPdfReport(JsonElement features, string title)
|
||||
{
|
||||
var paths = features.EnumerateArray()
|
||||
.Where(feature => feature.TryGetProperty("geometry", out _))
|
||||
.SelectMany(feature => PdfCoordinatePaths(feature.GetProperty("geometry")))
|
||||
.Where(path => path.Count > 0)
|
||||
.ToArray();
|
||||
var points = paths.SelectMany(path => path).ToArray();
|
||||
var minLon = points.Length == 0 ? 0 : points.Min(point => point.Lon);
|
||||
var maxLon = points.Length == 0 ? 1 : points.Max(point => point.Lon);
|
||||
var minLat = points.Length == 0 ? 0 : points.Min(point => point.Lat);
|
||||
var maxLat = points.Length == 0 ? 1 : points.Max(point => point.Lat);
|
||||
var lonSpan = Math.Max(0.0000001, maxLon - minLon);
|
||||
var latSpan = Math.Max(0.0000001, maxLat - minLat);
|
||||
const double mapLeft = 48, mapBottom = 438, mapWidth = 499, mapHeight = 330;
|
||||
var scale = Math.Min(mapWidth / lonSpan, mapHeight / latSpan);
|
||||
var drawing = new StringBuilder("0.08 0.44 0.37 RG 0.86 0.94 0.91 rg 1.4 w ");
|
||||
foreach (var path in paths.Take(400))
|
||||
{
|
||||
for (var pointIndex = 0; pointIndex < path.Count; pointIndex++)
|
||||
{
|
||||
var x = mapLeft + (path[pointIndex].Lon - minLon) * scale;
|
||||
var y = mapBottom + (path[pointIndex].Lat - minLat) * scale;
|
||||
drawing.AppendFormat(CultureInfo.InvariantCulture, "{0:0.##} {1:0.##} {2} ", x, y, pointIndex == 0 ? "m" : "l");
|
||||
}
|
||||
drawing.Append("S ");
|
||||
}
|
||||
drawing.Append("0 0 0 RG 1 w 497 730 m 497 758 l S 497 758 m 491 746 l 503 746 l h f ");
|
||||
var middleLatitude = (minLat + maxLat) / 2;
|
||||
var widthMeters = lonSpan * 111_320 * Math.Cos(middleLatitude * Math.PI / 180);
|
||||
var scaleMeters = Math.Max(1, NiceScaleDistance(widthMeters / 4));
|
||||
var scalePixels = widthMeters <= 0 ? 80 : Math.Clamp(scaleMeters / widthMeters * mapWidth, 35, 150);
|
||||
drawing.AppendFormat(CultureInfo.InvariantCulture, "48 422 m {0:0.##} 422 l S 48 418 m 48 426 l S {0:0.##} 418 m {0:0.##} 426 l S ", 48 + scalePixels);
|
||||
var lines = new List<string>
|
||||
{
|
||||
title,
|
||||
$"Rapporto LME Mappe Catastali - {DateTime.Now:dd/MM/yyyy HH:mm}",
|
||||
$"Oggetti: {features.GetArrayLength()}",
|
||||
$"Scala grafica: {scaleMeters:0} m | Nord: alto pagina",
|
||||
""
|
||||
};
|
||||
var index = 1;
|
||||
@@ -148,14 +180,17 @@ public sealed class MapExportService
|
||||
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 sheet = GetString(properties, "foglio") ?? GetString(properties, "FOGLIO");
|
||||
var parcel = GetString(properties, "particella") ?? GetString(properties, "PARTICELLA") ?? GetString(properties, "LABEL");
|
||||
var reference = string.Join(" ", new[] { sheet is null ? null : $"F.{sheet}", parcel is null ? null : $"P.{parcel}" }.Where(item => item is not null));
|
||||
lines.Add($"{index++}. {FeatureName(properties, category)} [{category}, {geometry}] {reference}".TrimEnd());
|
||||
}
|
||||
var content = new StringBuilder("BT /F1 16 Tf 48 790 Td ");
|
||||
foreach (var line in lines.Take(42))
|
||||
var content = drawing.Append("BT /F1 12 Tf 48 810 Td ");
|
||||
foreach (var line in lines.Take(21))
|
||||
{
|
||||
var safe = line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)")
|
||||
.Select(character => character <= 255 ? character : '?').ToArray();
|
||||
content.Append('(').Append(safe).Append(") Tj 0 -18 Td ");
|
||||
content.Append('(').Append(safe).Append(") Tj 0 -16 Td ");
|
||||
}
|
||||
content.Append("ET");
|
||||
var stream = Encoding.Latin1.GetBytes(content.ToString());
|
||||
@@ -183,6 +218,36 @@ public sealed class MapExportService
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static double NiceScaleDistance(double value)
|
||||
{
|
||||
if (value <= 0) return 1;
|
||||
var power = Math.Pow(10, Math.Floor(Math.Log10(value)));
|
||||
var normalized = value / power;
|
||||
var nice = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
|
||||
return nice * power;
|
||||
}
|
||||
|
||||
private static IEnumerable<List<(double Lon, double Lat)>> PdfCoordinatePaths(JsonElement geometry)
|
||||
{
|
||||
if (!geometry.TryGetProperty("type", out var typeElement) || !geometry.TryGetProperty("coordinates", out var coordinates)) yield break;
|
||||
var type = typeElement.GetString();
|
||||
if (type == "Point") { yield return [PdfPoint(coordinates)]; yield break; }
|
||||
if (type == "LineString") { yield return coordinates.EnumerateArray().Select(PdfPoint).ToList(); yield break; }
|
||||
if (type == "Polygon" || type == "MultiLineString")
|
||||
{
|
||||
foreach (var path in coordinates.EnumerateArray()) yield return path.EnumerateArray().Select(PdfPoint).ToList();
|
||||
yield break;
|
||||
}
|
||||
if (type == "MultiPolygon")
|
||||
{
|
||||
foreach (var polygon in coordinates.EnumerateArray())
|
||||
foreach (var path in polygon.EnumerateArray()) yield return path.EnumerateArray().Select(PdfPoint).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private static (double Lon, double Lat) PdfPoint(JsonElement coordinate) =>
|
||||
(coordinate[0].GetDouble(), coordinate[1].GetDouble());
|
||||
|
||||
private static string BuildKml(JsonElement features, string documentName)
|
||||
{
|
||||
var document = new XElement(Kml + "Document",
|
||||
|
||||
@@ -68,7 +68,7 @@ public sealed class ProjectStorageService
|
||||
}
|
||||
var document = new MapProjectDocument
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
SchemaVersion = 3,
|
||||
Id = id,
|
||||
Name = normalizedName,
|
||||
CreatedAt = existing?.Document.CreatedAt ?? now,
|
||||
@@ -76,9 +76,15 @@ public sealed class ProjectStorageService
|
||||
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
|
||||
@@ -131,6 +137,7 @@ public sealed class ProjectStorageService
|
||||
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));
|
||||
@@ -240,7 +247,7 @@ public sealed class ProjectStorageService
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<string> CreateProjectPackageAsync(string projectId)
|
||||
public async Task<string> CreateProjectPackageAsync(string projectId, bool readOnly = false)
|
||||
{
|
||||
await _gate.WaitAsync();
|
||||
try
|
||||
@@ -249,8 +256,22 @@ public sealed class ProjectStorageService
|
||||
?? 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);
|
||||
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(); }
|
||||
@@ -269,6 +290,7 @@ public sealed class ProjectStorageService
|
||||
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));
|
||||
@@ -316,6 +338,30 @@ public sealed class ProjectStorageService
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -397,9 +443,10 @@ public sealed class ProjectStorageService
|
||||
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);
|
||||
public MapProjectSummary ToSummary() => new(Id, Name, UpdatedAt, IncludesCadastralParcels, Folder, IsArchived, AccessMode == "readonly");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +456,7 @@ public sealed record MapProjectSummary(
|
||||
DateTimeOffset UpdatedAt,
|
||||
bool IncludesCadastralParcels,
|
||||
string Folder,
|
||||
bool IsArchived);
|
||||
bool IsArchived,
|
||||
bool IsReadOnly);
|
||||
|
||||
public sealed record MapProjectVersion(string Id, DateTimeOffset UpdatedAt);
|
||||
Reference in new issue
Block a user