diff --git a/.forgejo/workflows/build-catalog-lite.yml b/.forgejo/workflows/build-catalog-lite.yml new file mode 100644 index 0000000..746f735 --- /dev/null +++ b/.forgejo/workflows/build-catalog-lite.yml @@ -0,0 +1,199 @@ +name: Build Catalog Lite + +on: + workflow_dispatch: + inputs: + expiration_date: + description: Catalog Lite expiration date, yyyy-MM-dd + required: true + default: '2026-12-31' + publish_release: + description: Publish Forgejo release when running on a tag ref + required: true + default: 'false' + +env: + DOTNET_VERSION: 10.0.x + PROJECT_PATH: CatalogLite/CatalogLite.csproj + CATALOG_LITE_EXPIRATION_DATE: ${{ inputs.expiration_date }} + +jobs: + build: + runs-on: docker + strategy: + fail-fast: false + matrix: + include: + - runtime: win-x64 + artifact_name: catalog-lite-win-x64 + - runtime: linux-x64 + artifact_name: catalog-lite-linux-x64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Validate expiration date + run: | + set -eu + case "${CATALOG_LITE_EXPIRATION_DATE}" in + ????-??-??) ;; + *) + echo "expiration_date must use yyyy-MM-dd format" + exit 1 + ;; + esac + + - name: Restore + run: dotnet restore "${{ env.PROJECT_PATH }}" -r "${{ matrix.runtime }}" --configfile NuGet.Config + + - name: Publish Catalog Lite + env: + PUBLISH_DIR: artifacts/publish/${{ matrix.runtime }} + run: | + set -eu + dotnet publish "${{ env.PROJECT_PATH }}" \ + -c Release \ + -r "${{ matrix.runtime }}" \ + --self-contained true \ + --no-restore \ + -p:CatalogLiteExpirationDate="${CATALOG_LITE_EXPIRATION_DATE}" \ + -p:PublishSingleFile=true \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -o "${PUBLISH_DIR}" + + - name: Validate published files + env: + PUBLISH_DIR: artifacts/publish/${{ matrix.runtime }} + run: | + set -eu + if [ "${{ matrix.runtime }}" = "win-x64" ]; then + file_count="$(find "${PUBLISH_DIR}" -maxdepth 1 -type f -iname '*.exe' | wc -l | tr -d ' ')" + else + file_count="$(find "${PUBLISH_DIR}" -maxdepth 1 -type f -name 'CatalogLite' | wc -l | tr -d ' ')" + fi + + if [ "${file_count}" -eq 0 ]; then + echo "No Catalog Lite executable produced in ${PUBLISH_DIR}" + exit 1 + fi + + - name: Upload publish artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: artifacts/publish/${{ matrix.runtime }} + if-no-files-found: error + + release: + if: ${{ inputs.publish_release == 'true' && startsWith(github.ref, 'refs/tags/') }} + needs: build + runs-on: docker + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + + steps: + - name: Download Windows artifact + uses: actions/download-artifact@v4 + with: + name: catalog-lite-win-x64 + path: artifacts/release/win-x64 + + - name: Download Linux artifact + uses: actions/download-artifact@v4 + with: + name: catalog-lite-linux-x64 + path: artifacts/release/linux-x64 + + - name: Validate release token + run: | + set -eu + if [ -z "${FORGEJO_TOKEN}" ]; then + echo "secrets.FORGEJO_TOKEN is required for Catalog Lite releases" + exit 1 + fi + + - name: Create or update release + run: | + set -eu + api_base="${GITHUB_SERVER_URL%/}/api/v1/repos/${GITHUB_REPOSITORY}" + tag="${GITHUB_REF_NAME}" + create_payload="$(printf '{"tag_name":"%s","name":"%s","body":"Catalog Lite\\n\\nScadenza build: %s","draft":false,"prerelease":false}' "${tag}" "${tag}" "${CATALOG_LITE_EXPIRATION_DATE}")" + update_payload="$(printf '{"body":"Catalog Lite\\n\\nScadenza build: %s"}' "${CATALOG_LITE_EXPIRATION_DATE}")" + + http_code="$(curl -sS -o release.json -w '%{http_code}' \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${api_base}/releases/tags/${tag}")" + + if [ "${http_code}" = "200" ]; then + release_id="$(sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' release.json | head -n1)" + curl -fsS \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/json" \ + -X PATCH \ + -d "${update_payload}" \ + "${api_base}/releases/${release_id}" \ + -o release.json + elif [ "${http_code}" = "404" ]; then + curl -fsS \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/json" \ + -X POST \ + -d "${create_payload}" \ + "${api_base}/releases" \ + -o release.json + release_id="$(sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' release.json | head -n1)" + else + echo "Unexpected response while loading release for tag ${tag}: ${http_code}" + cat release.json + exit 1 + fi + + if [ -z "${release_id}" ]; then + echo "Unable to resolve Forgejo release id" + cat release.json + exit 1 + fi + + echo "RELEASE_ID=${release_id}" >> "${GITHUB_ENV}" + + - name: Upload release assets + run: | + set -eu + api_base="${GITHUB_SERVER_URL%/}/api/v1/repos/${GITHUB_REPOSITORY}" + short_sha="$(printf '%s' "${GITHUB_SHA}" | cut -c1-12)" + + windows_exe="$(find artifacts/release/win-x64 -maxdepth 1 -type f -iname '*.exe' | head -n1)" + linux_exe="$(find artifacts/release/linux-x64 -maxdepth 1 -type f -name 'CatalogLite' | head -n1)" + + if [ -z "${windows_exe}" ]; then + echo "No Windows executable found in downloaded artifact" + exit 1 + fi + + if [ -z "${linux_exe}" ]; then + echo "No Linux executable found in downloaded artifact" + exit 1 + fi + + linux_archive="CatalogLite-linux-x64-${GITHUB_REF_NAME}-${short_sha}.tar.gz" + tar -czf "${linux_archive}" -C "$(dirname "${linux_exe}")" "$(basename "${linux_exe}")" + + upload_asset() { + asset_path="$1" + asset_name="$2" + curl -fsS \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"${asset_path}" \ + "${api_base}/releases/${RELEASE_ID}/assets?name=${asset_name}" + } + + upload_asset "${windows_exe}" "CatalogLite-win-x64-${GITHUB_REF_NAME}-${short_sha}.exe" + upload_asset "${linux_archive}" "${linux_archive}" \ No newline at end of file diff --git a/Catalog.slnx b/Catalog.slnx index 3c8cf36..e3c90bb 100644 --- a/Catalog.slnx +++ b/Catalog.slnx @@ -15,6 +15,7 @@ + diff --git a/CatalogLite/App.axaml b/CatalogLite/App.axaml new file mode 100644 index 0000000..c01ea2f --- /dev/null +++ b/CatalogLite/App.axaml @@ -0,0 +1,41 @@ + + + + + + #F4F6F8 + #FFFFFF + #D4DAE2 + #1F7A5A + + + #20242A + #2A3038 + #46505C + #4FB286 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CatalogLite/App.axaml.cs b/CatalogLite/App.axaml.cs new file mode 100644 index 0000000..64f294b --- /dev/null +++ b/CatalogLite/App.axaml.cs @@ -0,0 +1,29 @@ +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Microsoft.Extensions.DependencyInjection; + +namespace CatalogLite; + +public partial class App : Avalonia.Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + if (ExpirationGuard.IsExpired(out var expirationDate)) + { + var expiredWindow = new ExpiredWindow(expirationDate); + expiredWindow.Closed += (_, _) => desktop.Shutdown(); + desktop.MainWindow = expiredWindow; + } + else + { + desktop.MainWindow = Program.ServiceProvider.GetRequiredService(); + } + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/CatalogLite/AsyncCommand.cs b/CatalogLite/AsyncCommand.cs new file mode 100644 index 0000000..fcc9fe7 --- /dev/null +++ b/CatalogLite/AsyncCommand.cs @@ -0,0 +1,42 @@ +using System.Windows.Input; + +namespace CatalogLite; + +public sealed class AsyncCommand : ICommand +{ + private readonly Func _execute; + private readonly Func? _canExecute; + private bool _isExecuting; + + public AsyncCommand(Func execute, Func? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) => !_isExecuting && (_canExecute?.Invoke() ?? true); + + public async void Execute(object? parameter) + { + if (!CanExecute(parameter)) + { + return; + } + + try + { + _isExecuting = true; + RaiseCanExecuteChanged(); + await _execute().ConfigureAwait(false); + } + finally + { + _isExecuting = false; + RaiseCanExecuteChanged(); + } + } + + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} \ No newline at end of file diff --git a/CatalogLite/CatalogConfigurationLoader.cs b/CatalogLite/CatalogConfigurationLoader.cs new file mode 100644 index 0000000..966e1dc --- /dev/null +++ b/CatalogLite/CatalogConfigurationLoader.cs @@ -0,0 +1,264 @@ +using System.Drawing; +using System.Globalization; +using System.Xml.Linq; +using MaddoShared; + +namespace CatalogLite; + +public sealed class CatalogConfigurationLoader +{ + public CatalogLiteConfiguration Load(string filePath, PicSettings picSettings) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("Percorso configurazione non valido.", nameof(filePath)); + } + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException("File configurazione non trovato.", filePath); + } + + var values = ConfigurationValues.Load(filePath); + ApplyPicSettings(values, picSettings); + + var sourcePath = LiteCatalogViewModel.NormalizeDirectoryPath(values.GetString("DirSorgente")); + var destinationPath = LiteCatalogViewModel.NormalizeDirectoryPath(values.GetString("DirDestinazione")); + + picSettings.DirectorySorgente = sourcePath; + picSettings.DirectoryDestinazione = destinationPath; + picSettings.DestDir = string.IsNullOrWhiteSpace(destinationPath) + ? new DirectoryInfo(Environment.CurrentDirectory) + : new DirectoryInfo(destinationPath); + + return new CatalogLiteConfiguration + { + FilePath = filePath, + SourcePath = sourcePath, + DestinationPath = destinationPath, + Options = BuildOptions(values, sourcePath, destinationPath) + }; + } + + public static ImageCreationService.Options CloneOptions(ImageCreationService.Options options, string sourcePath, string destinationPath) + { + return new ImageCreationService.Options + { + AggiornaSottodirectory = options.AggiornaSottodirectory, + CreaSottocartelle = options.CreaSottocartelle, + FilePerCartella = options.FilePerCartella, + SuffissoCartelle = options.SuffissoCartelle, + CifreContatore = options.CifreContatore, + NumerazioneType = options.NumerazioneType, + SourcePath = sourcePath, + DestinationPath = destinationPath, + MaxThreads = Math.Max(1, options.MaxThreads), + ChunksSize = options.ChunksSize, + LinearExecution = options.LinearExecution + }; + } + + private static void ApplyPicSettings(ConfigurationValues values, PicSettings settings) + { + settings.DirectorySorgente = values.GetString("DirSorgente"); + settings.DirectoryDestinazione = values.GetString("DirDestinazione"); + settings.TestoFirmaStart = values.GetString("TestoTesto"); + settings.TestoFirmaStartV = values.GetString("TestoVerticale"); + settings.DataPartenza = values.GetDateTime("DataPartenza", DateTime.Now); + settings.TestoOrario = values.GetString("EtichettaOrario"); + settings.DimStandard = values.GetInt("FontDimensione", 20); + settings.DimStandardMiniatura = values.GetInt("FontDimensioneMiniatura", 50); + settings.NomeData = values.GetBool("DataFoto"); + settings.TestoNome = values.GetBool("NumeroFoto"); + settings.UsaOrarioMiniatura = IsThumbnailMode(values, "Time") || values.GetBool("MiniatureAddOrario"); + settings.UsaOrarioTestoApplicare = values.GetBool("Orario"); + settings.UsaTempoGaraTestoApplicare = values.GetBool("TempoGara"); + settings.UsaRotazioneAutomatica = values.GetBool("GeneraleRotazioneAutomatica"); + settings.UsaForzaJpg = values.GetBool("GeneraleForzaJpg"); + settings.LarghezzaSmall = values.GetInt("MiniatureLarghezza", 350); + settings.AltezzaSmall = values.GetInt("MiniatureAltezza", 350); + settings.CreaMiniature = values.GetBool("MiniatureCrea", true); + settings.AggiungiScritteMiniature = IsThumbnailMode(values, "Text") || values.GetBool("MiniatureAddScritta"); + settings.Suffisso = values.GetString("MiniatureSuffisso", "tn_"); + settings.Codice = values.GetString("FotoCodice"); + settings.Trasparenza = values.GetInt("TestoTrasparente", 0); + settings.IlFont = values.GetString("FontNome", "Arial"); + settings.Grassetto = values.GetBool("FontBold"); + settings.Posizione = values.GetString("TestoPosizione", "Basso"); + settings.Allineamento = values.GetString("TestoAllineamento", "Centro"); + settings.Margine = values.GetInt("TestoMargine", 8); + settings.LogoAltezza = values.GetInt("MarchioAltezza", 430); + settings.LogoLarghezza = values.GetInt("MarchioLarghezza", 430); + settings.FontColoreRGB = ParseColor(values.GetString("ColoreTestoRGB", "Yellow"), Color.Yellow); + settings.LogoAggiungi = values.GetBool("MarchioAggiungi"); + settings.LogoNomeFile = values.GetString("MarchioFile"); + settings.LogoTrasparenza = values.GetInt("MarchioTrasparenza", 100).ToString(CultureInfo.InvariantCulture); + settings.LogoMargine = values.GetString("MarchioMargine", "0"); + settings.LogoPosizioneH = values.GetString("MarchioAllOrizzontale", "Destra"); + settings.LogoPosizioneV = values.GetString("MarchioAllVerticale", "Basso"); + settings.TransparentColor = values.GetString("ColoreTrasparente", "#FFFFFF"); + settings.UseTransparentColor = values.GetBool("UsaColoreTrasparente"); + settings.FotoGrandeDimOrigina = values.GetBool("FotoDimOriginali"); + settings.AltezzaBig = values.GetInt("FotoAltezza", 2240); + settings.LarghezzaBig = values.GetInt("FotoLarghezza", 2240); + settings.DimVert = values.GetInt("GrandezzaVerticale", 20); + settings.MargVert = values.GetInt("MargineVerticale", 6); + settings.TestoMin = IsThumbnailMode(values, "FileName") || values.GetBool("NomeMiniatura"); + settings.DimMin = values.GetInt("FontDimensioneMiniatura", 50); + settings.SecretDefault = false; + settings.SecretBig = false; + settings.SecretSmall = false; + settings.SecretPathSmall = string.Empty; + settings.SecretPathBig = string.Empty; + settings.AggTempoGaraMin = IsThumbnailMode(values, "RaceTime") || values.GetBool("TempoSmall"); + settings.AggNumTempMin = IsThumbnailMode(values, "FileNameAndTime") || values.GetBool("NumTempoSmall"); + settings.JpegQuality = values.GetLong("CompressioneJpeg", 85); + settings.JpegQualityMin = values.GetLong("CompressioneJpegMiniatura", 30); + settings.FotoRuotaADestra = false; + settings.FotoRuotaASinistra = false; + settings.TempMinText = string.Empty; + settings.OverwriteFiles = values.GetBool("GeneraleSovrascriviFile"); + settings.ImageCreatorProvider = "ImageSharp"; + } + + private static ImageCreationService.Options BuildOptions(ConfigurationValues values, string sourcePath, string destinationPath) + { + var threads = values.GetInt("ThreadsCount", Environment.ProcessorCount); + if (threads <= 0) + { + threads = Environment.ProcessorCount; + } + + return new ImageCreationService.Options + { + AggiornaSottodirectory = values.GetBool("DirSottoDirectory"), + CreaSottocartelle = values.GetBool("DirCreaSottocartelle", values.GetBool("CreateSubfolders")), + FilePerCartella = Math.Max(1, values.GetInt("DirDividiNumFile", 99)), + SuffissoCartelle = values.GetString("DirDividiSuffisso"), + CifreContatore = Math.Max(1, values.GetInt("DirDividiNumCifre", 2)), + NumerazioneType = values.GetBool("DirNumerazioneProgressiva", true) ? NumerazioneType.Progressiva : NumerazioneType.Files, + SourcePath = sourcePath, + DestinationPath = destinationPath, + MaxThreads = Math.Max(1, threads), + ChunksSize = Math.Max(0, values.GetInt("ChunkSize", 0)), + LinearExecution = values.GetBool("UseSequentialProcessing") + }; + } + + private static bool IsThumbnailMode(ConfigurationValues values, string mode) + { + return string.Equals(values.GetString("MiniatureModalita"), mode, StringComparison.OrdinalIgnoreCase); + } + + private static Color ParseColor(string value, Color fallback) + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + var normalized = value.Trim(); + try + { + if (normalized.StartsWith('#') && normalized.Length == 7) + { + return Color.FromArgb( + Convert.ToInt32(normalized[1..3], 16), + Convert.ToInt32(normalized[3..5], 16), + Convert.ToInt32(normalized[5..7], 16)); + } + + var named = Color.FromName(normalized); + return named.IsKnownColor || named.IsNamedColor ? named : fallback; + } + catch + { + return fallback; + } + } + + private sealed class ConfigurationValues + { + private readonly Dictionary _values; + + private ConfigurationValues(Dictionary values) + { + _values = values; + } + + public static ConfigurationValues Load(string filePath) + { + var document = XDocument.Load(filePath); + var values = document + .Descendants("Setup") + .Where(element => element.Element("Nome") is not null) + .GroupBy(element => element.Element("Nome")!.Value, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.Last().Element("Valore")?.Value ?? string.Empty, + StringComparer.OrdinalIgnoreCase); + + if (values.Count == 0) + { + values = document.Root? + .Descendants() + .Where(element => !element.HasElements) + .GroupBy(element => element.Name.LocalName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Last().Value, StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return new ConfigurationValues(values); + } + + public string GetString(string name, string defaultValue = "") + { + return _values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim() + : defaultValue; + } + + public bool GetBool(string name, bool defaultValue = false) + { + var value = GetString(name); + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + return value.Trim().ToUpperInvariant() switch + { + "TRUE" or "OK" or "SI" or "SÌ" or "1" or "YES" or "VERO" => true, + "FALSE" or "NO" or "0" or "FALSO" => false, + _ => defaultValue + }; + } + + public int GetInt(string name, int defaultValue = 0) + { + return int.TryParse(GetString(name), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : defaultValue; + } + + public long GetLong(string name, long defaultValue = 0) + { + return long.TryParse(GetString(name), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : defaultValue; + } + + public DateTime GetDateTime(string name, DateTime defaultValue) + { + var value = GetString(name); + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var invariantDate)) + { + return invariantDate; + } + + return DateTime.TryParse(value, CultureInfo.CurrentCulture, DateTimeStyles.AssumeLocal, out var localDate) + ? localDate + : defaultValue; + } + } +} \ No newline at end of file diff --git a/CatalogLite/CatalogLite.csproj b/CatalogLite/CatalogLite.csproj new file mode 100644 index 0000000..2573c3d --- /dev/null +++ b/CatalogLite/CatalogLite.csproj @@ -0,0 +1,44 @@ + + + net10.0 + Exe + enable + enable + CatalogLite + CatalogLite + false + 2026-12-31 + + + + WinExe + + + + + <_Parameter1>CatalogLiteExpirationDate + <_Parameter2>$(CatalogLiteExpirationDate) + + + + + + + + + + + + + + + + + + + + <_CatalogLiteExpirationDateIsIso>$([System.Text.RegularExpressions.Regex]::IsMatch('$(CatalogLiteExpirationDate)', '^\d{4}-\d{2}-\d{2}$')) + + + + \ No newline at end of file diff --git a/CatalogLite/CatalogLiteConfiguration.cs b/CatalogLite/CatalogLiteConfiguration.cs new file mode 100644 index 0000000..87569e7 --- /dev/null +++ b/CatalogLite/CatalogLiteConfiguration.cs @@ -0,0 +1,11 @@ +using MaddoShared; + +namespace CatalogLite; + +public sealed class CatalogLiteConfiguration +{ + public required string FilePath { get; init; } + public required string SourcePath { get; init; } + public required string DestinationPath { get; init; } + public required ImageCreationService.Options Options { get; init; } +} \ No newline at end of file diff --git a/CatalogLite/ExpirationGuard.cs b/CatalogLite/ExpirationGuard.cs new file mode 100644 index 0000000..46cbbd2 --- /dev/null +++ b/CatalogLite/ExpirationGuard.cs @@ -0,0 +1,27 @@ +using System.Globalization; +using System.Reflection; + +namespace CatalogLite; + +internal static class ExpirationGuard +{ + private const string MetadataKey = "CatalogLiteExpirationDate"; + + public static bool IsExpired(out DateOnly? expirationDate) + { + expirationDate = TryReadExpirationDate(); + return expirationDate is null || DateOnly.FromDateTime(DateTime.Today) > expirationDate.Value; + } + + private static DateOnly? TryReadExpirationDate() + { + var value = typeof(Program).Assembly + .GetCustomAttributes() + .FirstOrDefault(attribute => string.Equals(attribute.Key, MetadataKey, StringComparison.Ordinal)) + ?.Value; + + return DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) + ? date + : null; + } +} \ No newline at end of file diff --git a/CatalogLite/ExpiredWindow.cs b/CatalogLite/ExpiredWindow.cs new file mode 100644 index 0000000..e3bd88f --- /dev/null +++ b/CatalogLite/ExpiredWindow.cs @@ -0,0 +1,52 @@ +using Avalonia.Controls; +using Avalonia.Layout; + +namespace CatalogLite; + +internal sealed class ExpiredWindow : Window +{ + public ExpiredWindow(DateOnly? expirationDate) + { + Title = "Catalog Lite"; + Width = 430; + Height = 190; + CanResize = false; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + + var message = expirationDate is null + ? "L'applicazione e scaduta e non puo essere utilizzata." + : $"L'applicazione e scaduta il {expirationDate:dd/MM/yyyy} e non puo essere utilizzata."; + + var closeButton = new Button + { + Content = "Chiudi", + HorizontalAlignment = HorizontalAlignment.Right, + MinWidth = 96 + }; + closeButton.Click += (_, _) => Close(); + + Content = new Border + { + Padding = new Avalonia.Thickness(22), + Child = new StackPanel + { + Spacing = 14, + Children = + { + new TextBlock + { + Text = "Applicazione scaduta", + FontSize = 18, + FontWeight = Avalonia.Media.FontWeight.SemiBold + }, + new TextBlock + { + Text = message, + TextWrapping = Avalonia.Media.TextWrapping.Wrap + }, + closeButton + } + } + }; + } +} \ No newline at end of file diff --git a/CatalogLite/ImageProcessingCoordinator.cs b/CatalogLite/ImageProcessingCoordinator.cs new file mode 100644 index 0000000..9ad61b2 --- /dev/null +++ b/CatalogLite/ImageProcessingCoordinator.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using MaddoShared; +using Microsoft.Extensions.Logging; + +namespace CatalogLite; + +public readonly record struct ImageProcessedUpdate(string Status, int Total, int Processed); + +public sealed class ImageProcessingRunResult +{ + public required string FinalSpeedCounter { get; init; } +} + +public sealed class ImageProcessingCoordinator +{ + private readonly ImageCreationService _imageCreationService; + private readonly ILogger _logger; + + public ImageProcessingCoordinator(ImageCreationService imageCreationService, ILogger logger) + { + _imageCreationService = imageCreationService; + _logger = logger; + } + + public async Task RunAsync( + ImageCreationService.Options options, + CancellationToken token, + Action onImageProcessed, + Action onSpeedUpdated) + { + var results = new ConcurrentBag(); + var recentDiffs = new Queue(); + const int recentWindowSize = 5; + + var currentAmount = 0; + var previousAmount = 0; + var processedAtomic = 0; + var speedWatch = Stopwatch.StartNew(); + + using var speedTimer = new System.Threading.Timer(_ => + { + try + { + previousAmount = currentAmount; + currentAmount = Volatile.Read(ref processedAtomic); + var diff = Math.Max(0, currentAmount - previousAmount); + + lock (recentDiffs) + { + recentDiffs.Enqueue(diff); + if (recentDiffs.Count > recentWindowSize) + { + recentDiffs.Dequeue(); + } + } + + double recentAverage; + lock (recentDiffs) + { + recentAverage = recentDiffs.Count == 0 ? 0.0 : recentDiffs.Average(); + } + + var total = Volatile.Read(ref processedAtomic); + var overall = speedWatch.Elapsed.TotalSeconds > 0 ? total / speedWatch.Elapsed.TotalSeconds : 0.0; + var elapsed = speedWatch.Elapsed; + var elapsedText = $"{(int)elapsed.TotalHours}h {elapsed.Minutes}m {elapsed.Seconds}s"; + + onSpeedUpdated($"{recentAverage:0.00} f/s (media: {overall:0.00} f/s) - {elapsedText}{Environment.NewLine}media: {recentAverage * 60.0:0.00} f/m"); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Errore durante l'aggiornamento della velocita"); + } + }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + + EventHandler> onImageProcessedInternal = (_, args) => + { + var processed = Interlocked.Increment(ref processedAtomic); + onImageProcessed(new ImageProcessedUpdate(args.Item1, args.Item2, processed)); + }; + + await _imageCreationService.CreaCatalogoParallel(options, results, onImageProcessedInternal, token).ConfigureAwait(false); + + speedWatch.Stop(); + var finalProcessed = Volatile.Read(ref processedAtomic); + var finalAverage = speedWatch.Elapsed.TotalSeconds > 0 ? finalProcessed / speedWatch.Elapsed.TotalSeconds : 0.0; + var finalElapsed = speedWatch.Elapsed; + + return new ImageProcessingRunResult + { + FinalSpeedCounter = $"{(int)finalElapsed.TotalHours}h {finalElapsed.Minutes}m {finalElapsed.Seconds}s{Environment.NewLine}media: {finalAverage:0.00} f/s{Environment.NewLine}media: {finalAverage * 60.0:0.00} f/m" + }; + } +} \ No newline at end of file diff --git a/CatalogLite/LiteCatalogViewModel.cs b/CatalogLite/LiteCatalogViewModel.cs new file mode 100644 index 0000000..56a87be --- /dev/null +++ b/CatalogLite/LiteCatalogViewModel.cs @@ -0,0 +1,350 @@ +using MaddoShared; +using Microsoft.Extensions.Logging; + +namespace CatalogLite; + +public sealed class LiteCatalogViewModel : ViewModelBase +{ + private readonly CatalogConfigurationLoader _configurationLoader; + private readonly PicSettings _picSettings; + private readonly ImageCreationService _imageCreationService; + private readonly ImageProcessingCoordinator _imageProcessingCoordinator; + private readonly ILogger _logger; + private CatalogLiteConfiguration? _configuration; + private CancellationTokenSource? _processingTokenSource; + private string _configurationPath = string.Empty; + private string _sourcePath = string.Empty; + private string _destinationPath = string.Empty; + private string _processingStatus = "Carica una configurazione XML."; + private string _speedCounter = "-"; + private int _processedImagesCount; + private int _totalImagesCount; + private int _progressBarValue; + private int _progressBarMaximum = 100; + private bool _isProcessing; + + public LiteCatalogViewModel( + CatalogConfigurationLoader configurationLoader, + PicSettings picSettings, + ImageCreationService imageCreationService, + ImageProcessingCoordinator imageProcessingCoordinator, + ILogger logger) + { + _configurationLoader = configurationLoader; + _picSettings = picSettings; + _imageCreationService = imageCreationService; + _imageProcessingCoordinator = imageProcessingCoordinator; + _logger = logger; + + LoadConfigurationCommand = new AsyncCommand(RequestLoadConfigurationAsync, () => !IsProcessing); + SelectSourceFolderCommand = new AsyncCommand(RequestSourceFolderAsync, () => !IsProcessing); + SelectDestinationFolderCommand = new AsyncCommand(RequestDestinationFolderAsync, () => !IsProcessing); + StartProcessingCommand = new AsyncCommand(StartProcessingAsync, CanStartProcessing); + StopProcessingCommand = new AsyncCommand(StopProcessingAsync, () => IsProcessing); + } + + public event EventHandler? LoadConfigurationRequested; + public event EventHandler? SelectSourceFolderRequested; + public event EventHandler? SelectDestinationFolderRequested; + public event EventHandler? ShowMessageRequested; + + public Action? UiInvoker { get; set; } + + public AsyncCommand LoadConfigurationCommand { get; } + public AsyncCommand SelectSourceFolderCommand { get; } + public AsyncCommand SelectDestinationFolderCommand { get; } + public AsyncCommand StartProcessingCommand { get; } + public AsyncCommand StopProcessingCommand { get; } + + public string ConfigurationPath + { + get => _configurationPath; + private set + { + _configurationPath = value; + NotifyPropertyChanged(); + } + } + + public string SourcePath + { + get => _sourcePath; + set + { + _sourcePath = NormalizeDirectoryPath(value); + NotifyPropertyChanged(); + RaiseCommandStates(); + } + } + + public string DestinationPath + { + get => _destinationPath; + set + { + _destinationPath = NormalizeDirectoryPath(value); + NotifyPropertyChanged(); + RaiseCommandStates(); + } + } + + public string ProcessingStatus + { + get => _processingStatus; + private set + { + _processingStatus = value; + NotifyPropertyChanged(); + } + } + + public string SpeedCounter + { + get => _speedCounter; + private set + { + _speedCounter = value; + NotifyPropertyChanged(); + } + } + + public int ProcessedImagesCount + { + get => _processedImagesCount; + private set + { + _processedImagesCount = value; + NotifyPropertyChanged(); + } + } + + public int TotalImagesCount + { + get => _totalImagesCount; + private set + { + _totalImagesCount = value; + NotifyPropertyChanged(); + } + } + + public int ProgressBarValue + { + get => _progressBarValue; + private set + { + _progressBarValue = value; + NotifyPropertyChanged(); + } + } + + public int ProgressBarMaximum + { + get => _progressBarMaximum; + private set + { + _progressBarMaximum = Math.Max(1, value); + NotifyPropertyChanged(); + } + } + + public bool IsProcessing + { + get => _isProcessing; + private set + { + _isProcessing = value; + NotifyPropertyChanged(); + RaiseCommandStates(); + } + } + + public async Task LoadConfigurationFromFileAsync(string filePath) + { + try + { + var configuration = await Task.Run(() => _configurationLoader.Load(filePath, _picSettings)).ConfigureAwait(false); + + RunOnUiThread(() => + { + _configuration = configuration; + ConfigurationPath = configuration.FilePath; + SourcePath = configuration.SourcePath; + DestinationPath = configuration.DestinationPath; + ResetProgress("Configurazione caricata."); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore durante il caricamento della configurazione"); + ShowMessage("Configurazione", $"Impossibile caricare la configurazione: {ex.GetBaseException().Message}"); + } + } + + public static string NormalizeDirectoryPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + var trimmed = path.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return trimmed + Path.DirectorySeparatorChar; + } + + private Task RequestLoadConfigurationAsync() + { + LoadConfigurationRequested?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } + + private Task RequestSourceFolderAsync() + { + SelectSourceFolderRequested?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } + + private Task RequestDestinationFolderAsync() + { + SelectDestinationFolderRequested?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } + + private bool CanStartProcessing() + { + return !IsProcessing + && _configuration is not null + && !string.IsNullOrWhiteSpace(SourcePath) + && !string.IsNullOrWhiteSpace(DestinationPath); + } + + private async Task StartProcessingAsync() + { + if (_configuration is null) + { + ShowMessage("Configurazione", "Carica prima una configurazione XML."); + return; + } + + if (!Directory.Exists(SourcePath)) + { + ShowMessage("Sorgente", "La cartella sorgente non esiste."); + return; + } + + try + { + Directory.CreateDirectory(DestinationPath); + } + catch (Exception ex) + { + ShowMessage("Destinazione", $"Impossibile usare la cartella destinazione: {ex.GetBaseException().Message}"); + return; + } + + _processingTokenSource?.Dispose(); + _processingTokenSource = new CancellationTokenSource(); + var token = _processingTokenSource.Token; + + var options = CatalogConfigurationLoader.CloneOptions(_configuration.Options, SourcePath, DestinationPath); + _picSettings.DirectorySorgente = SourcePath; + _picSettings.DirectoryDestinazione = DestinationPath; + _picSettings.DestDir = new DirectoryInfo(DestinationPath); + _picSettings.ImageCreatorProvider = "ImageSharp"; + + IsProcessing = true; + ResetProgress("Analisi immagini..."); + + try + { + var total = await Task.Run(() => _imageCreationService.GetFilesToProcessPublic(options).Count, token).ConfigureAwait(false); + RunOnUiThread(() => + { + TotalImagesCount = total; + ProgressBarMaximum = Math.Max(1, total); + ProcessingStatus = total == 0 ? "Nessuna immagine trovata." : "Elaborazione in corso..."; + }); + + if (total == 0) + { + return; + } + + var result = await _imageProcessingCoordinator.RunAsync( + options, + token, + update => RunOnUiThread(() => + { + ProcessedImagesCount = update.Processed; + TotalImagesCount = update.Total; + ProgressBarMaximum = update.Total; + ProgressBarValue = update.Processed; + ProcessingStatus = update.Status; + }), + speed => RunOnUiThread(() => SpeedCounter = speed)).ConfigureAwait(false); + + RunOnUiThread(() => + { + SpeedCounter = result.FinalSpeedCounter; + ProcessingStatus = "Finito."; + }); + } + catch (OperationCanceledException) + { + RunOnUiThread(() => ProcessingStatus = "Operazione annullata."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore durante l'elaborazione"); + RunOnUiThread(() => ProcessingStatus = $"Errore: {ex.GetBaseException().Message}"); + } + finally + { + _processingTokenSource?.Dispose(); + _processingTokenSource = null; + RunOnUiThread(() => IsProcessing = false); + } + } + + private Task StopProcessingAsync() + { + _processingTokenSource?.Cancel(); + ProcessingStatus = "Arresto in corso..."; + return Task.CompletedTask; + } + + private void ResetProgress(string status) + { + ProcessingStatus = status; + ProcessedImagesCount = 0; + TotalImagesCount = 0; + ProgressBarValue = 0; + ProgressBarMaximum = 100; + SpeedCounter = "-"; + } + + private void RunOnUiThread(Action action) + { + if (UiInvoker is null) + { + action(); + return; + } + + UiInvoker(action); + } + + private void ShowMessage(string title, string message) + { + RunOnUiThread(() => ShowMessageRequested?.Invoke(this, new LiteMessageEventArgs(title, message))); + } + + private void RaiseCommandStates() + { + LoadConfigurationCommand.RaiseCanExecuteChanged(); + SelectSourceFolderCommand.RaiseCanExecuteChanged(); + SelectDestinationFolderCommand.RaiseCanExecuteChanged(); + StartProcessingCommand.RaiseCanExecuteChanged(); + StopProcessingCommand.RaiseCanExecuteChanged(); + } +} \ No newline at end of file diff --git a/CatalogLite/LiteMessageEventArgs.cs b/CatalogLite/LiteMessageEventArgs.cs new file mode 100644 index 0000000..230c787 --- /dev/null +++ b/CatalogLite/LiteMessageEventArgs.cs @@ -0,0 +1,13 @@ +namespace CatalogLite; + +public sealed class LiteMessageEventArgs : EventArgs +{ + public LiteMessageEventArgs(string title, string message) + { + Title = title; + Message = message; + } + + public string Title { get; } + public string Message { get; } +} \ No newline at end of file diff --git a/CatalogLite/MainWindow.axaml b/CatalogLite/MainWindow.axaml new file mode 100644 index 0000000..48118bd --- /dev/null +++ b/CatalogLite/MainWindow.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CatalogLite/MainWindow.axaml.cs b/CatalogLite/MainWindow.axaml.cs new file mode 100644 index 0000000..7611b3a --- /dev/null +++ b/CatalogLite/MainWindow.axaml.cs @@ -0,0 +1,113 @@ +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; + +namespace CatalogLite; + +public partial class MainWindow : Window +{ + private readonly LiteCatalogViewModel _viewModel; + + public MainWindow() + : this(Program.ServiceProvider.GetRequiredService()) + { + } + + public MainWindow(LiteCatalogViewModel viewModel) + { + InitializeComponent(); + + _viewModel = viewModel; + DataContext = _viewModel; + _viewModel.UiInvoker = action => Dispatcher.UIThread.Invoke(action); + _viewModel.LoadConfigurationRequested += OnLoadConfigurationRequested; + _viewModel.SelectSourceFolderRequested += OnSelectSourceFolderRequested; + _viewModel.SelectDestinationFolderRequested += OnSelectDestinationFolderRequested; + _viewModel.ShowMessageRequested += OnShowMessageRequested; + } + + private async void OnLoadConfigurationRequested(object? sender, EventArgs e) + { + var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Carica configurazione XML", + FileTypeFilter = [new FilePickerFileType("XML") { Patterns = ["*.xml"] }] + }); + + if (files.Count > 0) + { + await _viewModel.LoadConfigurationFromFileAsync(files[0].Path.LocalPath); + } + } + + private async void OnSelectSourceFolderRequested(object? sender, EventArgs e) + { + var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Seleziona cartella sorgente" + }); + + if (folders.Count > 0) + { + _viewModel.SourcePath = LiteCatalogViewModel.NormalizeDirectoryPath(folders[0].Path.LocalPath); + } + } + + private async void OnSelectDestinationFolderRequested(object? sender, EventArgs e) + { + var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Seleziona cartella destinazione" + }); + + if (folders.Count > 0) + { + _viewModel.DestinationPath = LiteCatalogViewModel.NormalizeDirectoryPath(folders[0].Path.LocalPath); + } + } + + private async void OnShowMessageRequested(object? sender, LiteMessageEventArgs e) + { + await ShowMessageDialogAsync(e.Title, e.Message); + } + + private async Task ShowMessageDialogAsync(string title, string message) + { + var closeButton = new Button + { + Content = "OK", + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + MinWidth = 92 + }; + + var dialog = new Window + { + Title = title, + Width = 420, + Height = 180, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = new Border + { + Padding = new Avalonia.Thickness(20), + Child = new StackPanel + { + Spacing = 14, + Children = + { + new TextBlock + { + Text = message, + TextWrapping = Avalonia.Media.TextWrapping.Wrap + }, + closeButton + } + } + } + }; + + closeButton.Click += (_, _) => dialog.Close(); + await dialog.ShowDialog(this); + } +} \ No newline at end of file diff --git a/CatalogLite/Program.cs b/CatalogLite/Program.cs new file mode 100644 index 0000000..ab18009 --- /dev/null +++ b/CatalogLite/Program.cs @@ -0,0 +1,45 @@ +using Avalonia; +using MaddoShared; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CatalogLite; + +internal static class Program +{ + public static IServiceProvider ServiceProvider { get; private set; } = default!; + + [STAThread] + public static int Main(string[] args) + { + var services = new ServiceCollection(); + ConfigureServices(services); + ServiceProvider = services.BuildServiceProvider(); + + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args ?? []); + return 0; + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); + + private static void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(sp => sp.GetRequiredService()); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddLogging(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); + }); + } +} \ No newline at end of file diff --git a/CatalogLite/ViewModelBase.cs b/CatalogLite/ViewModelBase.cs new file mode 100644 index 0000000..7d0647a --- /dev/null +++ b/CatalogLite/ViewModelBase.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace CatalogLite; + +public abstract class ViewModelBase : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected void NotifyPropertyChanged([CallerMemberName] string propertyName = "") + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/README.md b/README.md index aa5f0de..1d4631a 100644 --- a/README.md +++ b/README.md @@ -1 +1,13 @@ Catalog 3 + +## Catalog Lite + +`CatalogLite/CatalogLite.csproj` is a minimal Avalonia frontend for processing images from a saved XML configuration. It references `MaddoShared`, uses only the ImageSharp processor, and exposes only XML loading, source/destination folder selection, start/stop, and progress. + +The build embeds an expiration date from the `CatalogLiteExpirationDate` MSBuild property: + +```powershell +dotnet publish CatalogLite/CatalogLite.csproj -c Release -r win-x64 --self-contained true -p:CatalogLiteExpirationDate=2026-12-31 +``` + +The separate Forgejo workflow is `.forgejo/workflows/build-catalog-lite.yml`; run it manually and set `expiration_date` in `yyyy-MM-dd` format.