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.