Files
SteUP_Dotnet/SteUp.Maui/Core/Services/FileManager.cs
T
MarcoEandClaude Opus 5.5 c4c624667d Permette di eliminare le foto di una scheda anche dopo il salvataggio
Finora la foto si poteva togliere solo prima di salvare la scheda.

- La X compare su tutte le foto (non in sola lettura, non mentre la foto e'
  ancora in elaborazione). L'eliminazione avviene al salvataggio: uscendo
  senza salvare le foto restano.
- Una foto gia' inviata si toglie prima dal server (nuovo endpoint
  steup/removeAttachment), poi dal dispositivo e da Scheda.ImageNames. Senza
  rete la X avvisa e non la toglie; se il server non conferma al salvataggio
  la foto resta ovunque e compare un avviso.
- Una foto salvata ma non ancora inviata si elimina dal dispositivo (con la
  rete si prova anche a toglierla dal server, senza bloccare).
- Togliere una foto salvata non la cancellava mai: il flag ToRemove finiva
  su un elemento appena rimosso dall'elenco. Ora le foto tolte stanno in una
  lista a parte fino al salvataggio; ToRemove e' stato eliminato.
- FileManager.GetInspectionFiles: le foto in /toUpload si filtravano per
  nome, ma ImageNames contiene il percorso completo, quindi quelle salvate
  senza rete non ricomparivano mai riaprendo la scheda. Ora si accetta sia il
  percorso sia il nome, e il DTO porta anche i byte del file per l'invio.

Richiede i Servizi con steup/removeAttachment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:39:31 +01:00

401 lines
13 KiB
C#

using Microsoft.Extensions.Logging;
using SteUp.Data.LocalDb;
using SteUp.Shared.Core.Dto;
using SteUp.Shared.Core.Entities;
using SteUp.Shared.Core.Helpers;
using SteUp.Shared.Core.Interface.System;
namespace SteUp.Maui.Core.Services;
public class FileManager(
IDbPathProvider dbPathProvider,
ILogger<FileManager> logger) : IFileManager
{
private static string AttachedRoot =>
Path.Combine(FileSystem.CacheDirectory, "attached");
private async Task<AttachedDto> ConvertToDto(FileInfo file, AttachedDto.TypeAttached type, bool isFromToUpload)
{
var bytes = await File.ReadAllBytesAsync(file.FullName);
var (origUrl, thumbUrl) = await SaveAndCreateThumbAsync(bytes, file.Name);
return new AttachedDto
{
Name = file.Name,
Path = file.FullName,
// Servono per inviare le foto rimaste in /toUpload (salvate senza rete).
FileBytes = bytes,
TempPath = origUrl,
ThumbPath = thumbUrl,
Type = type,
SavedOnAppData = true,
ToUpload = isFromToUpload
};
}
private const string ToUploadFolderName = "toUpload";
private string GetInspectionBaseDir(Ispezione ispezione)
{
var baseDir = FileSystem.AppDataDirectory;
return Path.Combine(baseDir, "attached", $"inspection_{GetInspectionKey(ispezione)}");
}
private string GetInspectionToUploadDir(Ispezione ispezione)
=> Path.Combine(GetInspectionBaseDir(ispezione), ToUploadFolderName);
public string GetFileToUploadDir(Ispezione ispezione, string fileName) =>
Path.Combine(GetInspectionToUploadDir(ispezione), fileName);
private string GetInspectionFinalDir(Ispezione ispezione)
=> GetInspectionBaseDir(ispezione);
/// <summary>
/// Ritorna i file dell'ispezione filtrati per nome.
/// Per default include sia "final" sia "toUpload" (utile per UI).
/// </summary>
public async Task<List<AttachedDto>?> GetInspectionFiles(
Ispezione ispezione,
List<string> fileNameFilter,
bool includeToUpload,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ispezione);
ArgumentNullException.ThrowIfNull(fileNameFilter);
var baseDir = GetInspectionBaseDir(ispezione);
if (!Directory.Exists(baseDir)) return null;
var result = new List<AttachedDto>();
var finalDir = GetInspectionFinalDir(ispezione);
if (Directory.Exists(finalDir))
{
var finalFiles = new DirectoryInfo(finalDir)
.GetFiles("*", SearchOption.TopDirectoryOnly);
foreach (var file in finalFiles)
{
if (file.Directory?.Name == ToUploadFolderName)
continue;
if (!fileNameFilter.Contains(file.FullName))
continue;
ct.ThrowIfCancellationRequested();
result.Add(await ConvertToDto(
file,
AttachedDto.TypeAttached.Image,
isFromToUpload: false));
}
}
if (!includeToUpload) return result;
var toUploadDir = GetInspectionToUploadDir(ispezione);
if (!Directory.Exists(toUploadDir)) return result;
var toUploadFiles = new DirectoryInfo(toUploadDir)
.GetFiles("*", SearchOption.TopDirectoryOnly);
foreach (var file in toUploadFiles)
{
// Scheda.ImageNames contiene il percorso completo restituito da SaveInspectionFile:
// confrontando solo il nome le foto salvate e non ancora inviate non comparivano mai.
if (!fileNameFilter.Contains(file.FullName) && !fileNameFilter.Contains(file.Name))
continue;
ct.ThrowIfCancellationRequested();
result.Add(await ConvertToDto(
file,
AttachedDto.TypeAttached.Image,
isFromToUpload: true));
}
return result;
}
/// <summary>
/// Salva SEMPRE in /toUpload.
/// </summary>
public async Task<string?> SaveInspectionFile(
Ispezione ispezione,
byte[] file,
string fileName,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ispezione);
ArgumentNullException.ThrowIfNull(file);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
var toUploadDir = GetInspectionToUploadDir(ispezione);
Directory.CreateDirectory(toUploadDir);
var filePath = Path.Combine(toUploadDir, fileName);
await File.WriteAllBytesAsync(filePath, file, ct);
return filePath;
}
public Task<string?> MoveInspectionFileFromToUploadToFinal(
Ispezione ispezione,
string fileName,
bool overwrite,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ispezione);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
ct.ThrowIfCancellationRequested();
var toUploadDir = GetInspectionToUploadDir(ispezione);
var finalDir = GetInspectionFinalDir(ispezione);
if (!Directory.Exists(toUploadDir)) return Task.FromResult<string?>(null);
var sourcePath = Path.Combine(toUploadDir, fileName);
if (!File.Exists(sourcePath)) return Task.FromResult<string?>(null);
Directory.CreateDirectory(finalDir);
var destPath = Path.Combine(finalDir, fileName);
if (File.Exists(destPath))
{
if (!overwrite) return Task.FromResult<string?>(null);
File.Delete(destPath);
}
File.Move(sourcePath, destPath);
// Pulizia: se /toUpload resta vuota la elimino
CleanupDirectoriesIfEmpty(ispezione);
return Task.FromResult<string?>(destPath);
}
/// <summary>
/// Rimuove un file cercandolo prima in /toUpload e poi in final (o viceversa).
/// Default: prova a cancellare ovunque.
/// </summary>
public bool RemoveInspectionFile(
Ispezione ispezione,
string fileName,
bool removeAlsoFromFinal,
bool removeAlsoFromToUpload)
{
ArgumentNullException.ThrowIfNull(ispezione);
if (string.IsNullOrWhiteSpace(fileName)) return false;
var removed = false;
if (removeAlsoFromToUpload)
{
var toUploadPath = Path.Combine(GetInspectionToUploadDir(ispezione), fileName);
if (File.Exists(toUploadPath))
{
File.Delete(toUploadPath);
removed = true;
}
}
if (removeAlsoFromFinal)
{
var finalPath = Path.Combine(GetInspectionFinalDir(ispezione), fileName);
if (File.Exists(finalPath))
{
File.Delete(finalPath);
removed = true;
}
}
if (removed)
CleanupDirectoriesIfEmpty(ispezione);
return removed;
}
public bool RemoveInspection(Ispezione ispezione, bool removeAlsoFromFinal, bool removeAlsoFromToUpload)
{
ArgumentNullException.ThrowIfNull(ispezione);
var removedAnything = false;
if (removeAlsoFromToUpload)
TryDeleteDirectory(GetInspectionToUploadDir(ispezione));
if (removeAlsoFromFinal)
TryDeleteDirectory(GetInspectionFinalDir(ispezione));
try
{
CleanupDirectoriesIfEmpty(ispezione);
}
catch(Exception e)
{
logger.LogError(e, e.Message);
SentrySdk.CaptureException(e);
}
return removedAnything;
void TryDeleteDirectory(string? dir)
{
if (string.IsNullOrWhiteSpace(dir)) return;
try
{
if (!Directory.Exists(dir)) return;
Directory.Delete(dir, recursive: true);
removedAnything = true;
}
catch(Exception e)
{
logger.LogError(e, e.Message);
SentrySdk.CaptureException(e);
}
}
}
private void CleanupDirectoriesIfEmpty(Ispezione ispezione)
{
var baseDir = GetInspectionBaseDir(ispezione);
var toUploadDir = GetInspectionToUploadDir(ispezione);
// 1) se /toUpload esiste e vuota => delete
if (Directory.Exists(toUploadDir) && !Directory.EnumerateFileSystemEntries(toUploadDir).Any())
Directory.Delete(toUploadDir);
// 2) se base dir vuota (attenzione: dopo delete toUpload) => delete
if (Directory.Exists(baseDir) && !Directory.EnumerateFileSystemEntries(baseDir).Any())
Directory.Delete(baseDir);
}
public async Task<string> SaveToTempStorage(Stream file, string fileName, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(file);
if (file.CanSeek)
file.Position = 0;
fileName = Path.GetFileName(fileName);
var dir = FileSystem.CacheDirectory;
var filePath = Path.Combine(dir, fileName);
await using var fileStream = File.Create(filePath);
await file.CopyToAsync(fileStream, ct);
return filePath;
}
public Task CleanTempStorageAsync(CancellationToken ct = default)
{
return Task.Run(() =>
{
if (Directory.Exists(AttachedRoot))
Directory.Delete(AttachedRoot, true);
}, ct);
}
public Task OpenFile(string fileName, string filePath)
{
#if IOS
throw new NotImplementedException();
#else
return Launcher.OpenAsync(new OpenFileRequest
{
Title = "Apri file",
File = new ReadOnlyFile(filePath)
});
#endif
}
public async Task<(string originalUrl, string thumbUrl)> SaveAndCreateThumbAsync(
byte[] bytes, string fileName, CancellationToken ct = default)
{
Directory.CreateDirectory(AttachedRoot);
var id = Guid.NewGuid().ToString("N");
var safeName = SanitizeFileName(fileName);
var originalFile = $"{id}_{safeName}";
var thumbFile = $"{id}_thumb.jpg";
var originalPath = Path.Combine(AttachedRoot, originalFile);
await File.WriteAllBytesAsync(originalPath, bytes, ct);
var thumbPath = Path.Combine(AttachedRoot, thumbFile);
await ImageThumb.CreateThumbnailAsync(originalPath, thumbPath, maxSide: 320, quality: 70, ct);
return ($"https://localfiles/attached/{originalFile}",
$"https://localfiles/attached/{thumbFile}");
}
private static string SanitizeFileName(string fileName)
{
var name = Path.GetFileName(fileName);
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
}
private static string GetInspectionKey(Ispezione ispezione) =>
$"{ispezione.CodMdep}_{ispezione.Data:ddMMyyyy}_{ispezione.Rilevatore.ToLower()}";
public Task<long> GetLogExportImagesSizeAsync(CancellationToken ct = default) =>
Task.Run(() => RetrieveAttachedImages().Sum(x => x.File.Length), ct);
public Task<ILogExport> PrepareLogExportAsync(bool includeImages, CancellationToken ct = default)
{
return Task.Run<ILogExport>(() =>
{
var workDir = Path.Combine(FileSystem.CacheDirectory, "log_export");
// Residui di un invio interrotto (app chiusa a metà): si riparte da zero.
if (Directory.Exists(workDir))
Directory.Delete(workDir, true);
Directory.CreateDirectory(workDir);
var logFiles = RetrieveLogFiles()
.Select(f => new LogExport.ZipItem(f, f.Name))
.ToList();
// Col journal WAL le modifiche recenti stanno ancora nel file -wal: senza, il database
// inviato può non avere le ultime ispezioni.
var dbPath = dbPathProvider.GetDbPath();
var dbFiles = new[] { dbPath, $"{dbPath}-wal" }
.Select(p => new FileInfo(p))
.Where(f => f.Exists)
.Select(f => new LogExport.ZipItem(f, f.Name))
.ToList();
var images = includeImages ? RetrieveAttachedImages() : [];
return new LogExport(workDir, logFiles, dbFiles, images, logger);
}, ct);
}
private static List<FileInfo> RetrieveLogFiles()
{
var directory = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "logs"));
return directory.Exists ? directory.GetFiles().ToList() : [];
}
private static List<LogExport.ZipItem> RetrieveAttachedImages()
{
var root = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "attached"));
if (!root.Exists) return [];
// Ordine per percorso: le foto della stessa ispezione finiscono nella stessa parte.
// Il percorso relativo mantiene le cartelle dentro lo zip (separatore "/" per compatibilità).
return root
.EnumerateFiles("*", SearchOption.AllDirectories)
.OrderBy(f => f.FullName, StringComparer.Ordinal)
.Select(f => new LogExport.ZipItem(f,
Path.GetRelativePath(root.FullName, f.FullName).Replace('\\', '/')))
.ToList();
}
}