Catalog Lite
Some checks failed
Build Windows Avalonia / build (push) Failing after 1m43s
Build Windows Avalonia / release (push) Has been skipped

This commit is contained in:
Maddo 2026-05-26 21:47:55 +02:00
commit 181229aa41
18 changed files with 1435 additions and 0 deletions

View file

@ -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}"

View file

@ -15,6 +15,7 @@
<Platform Solution="*|x86" Project="x86" />
<Platform Solution="Release|x64" Project="x64" />
</Project>
<Project Path="CatalogLite/CatalogLite.csproj" />
<Project Path="MaddoShared.Benchmarks/MaddoShared.Benchmarks.csproj" />
<Project Path="MaddoShared.ImageSharpTests/MaddoShared.ImageSharpTests.csproj" />
</Solution>

41
CatalogLite/App.axaml Normal file
View file

@ -0,0 +1,41 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="CatalogLite.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="AppBackgroundBrush">#F4F6F8</SolidColorBrush>
<SolidColorBrush x:Key="PanelBackgroundBrush">#FFFFFF</SolidColorBrush>
<SolidColorBrush x:Key="BorderMutedBrush">#D4DAE2</SolidColorBrush>
<SolidColorBrush x:Key="AccentBrush">#1F7A5A</SolidColorBrush>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="AppBackgroundBrush">#20242A</SolidColorBrush>
<SolidColorBrush x:Key="PanelBackgroundBrush">#2A3038</SolidColorBrush>
<SolidColorBrush x:Key="BorderMutedBrush">#46505C</SolidColorBrush>
<SolidColorBrush x:Key="AccentBrush">#4FB286</SolidColorBrush>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme DensityStyle="Compact" />
<Style Selector="Window">
<Setter Property="Background" Value="{DynamicResource AppBackgroundBrush}" />
</Style>
<Style Selector="Button">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="12,5" />
</Style>
<Style Selector="TextBox">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="8,5" />
</Style>
<Style Selector="ProgressBar">
<Setter Property="MinHeight" Value="18" />
</Style>
<StyleInclude Source="avares://IconPacks.Avalonia/Icons.axaml" />
</Application.Styles>
</Application>

29
CatalogLite/App.axaml.cs Normal file
View file

@ -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<MainWindow>();
}
}
base.OnFrameworkInitializationCompleted();
}
}

View file

@ -0,0 +1,42 @@
using System.Windows.Input;
namespace CatalogLite;
public sealed class AsyncCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private bool _isExecuting;
public AsyncCommand(Func<Task> execute, Func<bool>? 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);
}

View file

@ -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<string, string> _values;
private ConfigurationValues(Dictionary<string, string> 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<string, string>(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;
}
}
}

View file

@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>CatalogLite</AssemblyName>
<RootNamespace>CatalogLite</RootNamespace>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
<CatalogLiteExpirationDate Condition="'$(CatalogLiteExpirationDate)' == ''">2026-12-31</CatalogLiteExpirationDate>
</PropertyGroup>
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'">
<OutputType>WinExe</OutputType>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>CatalogLiteExpirationDate</_Parameter1>
<_Parameter2>$(CatalogLiteExpirationDate)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MaddoShared\MaddoShared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.13" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.13" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.13" />
<PackageReference Include="IconPacks.Avalonia" Version="2.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.8" />
</ItemGroup>
<Target Name="ValidateCatalogLiteExpirationDate" BeforeTargets="BeforeCompile">
<PropertyGroup>
<_CatalogLiteExpirationDateIsIso>$([System.Text.RegularExpressions.Regex]::IsMatch('$(CatalogLiteExpirationDate)', '^\d{4}-\d{2}-\d{2}$'))</_CatalogLiteExpirationDateIsIso>
</PropertyGroup>
<Error Condition="'$(_CatalogLiteExpirationDateIsIso)' != 'True'" Text="CatalogLiteExpirationDate must use yyyy-MM-dd format. Current value: $(CatalogLiteExpirationDate)" />
</Target>
</Project>

View file

@ -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; }
}

View file

@ -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<AssemblyMetadataAttribute>()
.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;
}
}

View file

@ -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
}
}
};
}
}

View file

@ -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<ImageProcessingCoordinator> _logger;
public ImageProcessingCoordinator(ImageCreationService imageCreationService, ILogger<ImageProcessingCoordinator> logger)
{
_imageCreationService = imageCreationService;
_logger = logger;
}
public async Task<ImageProcessingRunResult> RunAsync(
ImageCreationService.Options options,
CancellationToken token,
Action<ImageProcessedUpdate> onImageProcessed,
Action<string> onSpeedUpdated)
{
var results = new ConcurrentBag<string>();
var recentDiffs = new Queue<int>();
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<Tuple<string, int>> 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"
};
}
}

View file

@ -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<LiteCatalogViewModel> _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<LiteCatalogViewModel> 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<LiteMessageEventArgs>? ShowMessageRequested;
public Action<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();
}
}

View file

@ -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; }
}

View file

@ -0,0 +1,83 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:iconPacks="https://github.com/MahApps/IconPacks.Avalonia"
x:Class="CatalogLite.MainWindow"
x:CompileBindings="False"
Title="Catalog Lite"
Width="740"
Height="380"
MinWidth="640"
MinHeight="340">
<Grid Margin="16" RowDefinitions="Auto,Auto,Auto,*,Auto" RowSpacing="12">
<Grid ColumnDefinitions="150,*" ColumnSpacing="10">
<Button Command="{Binding LoadConfigurationCommand}" ToolTip.Tip="Carica configurazione XML">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="7">
<iconPacks:PackIconMaterial Kind="FolderUploadOutline" Width="16" Height="16" />
<TextBlock Text="Carica XML" />
</StackPanel>
</Button>
<TextBox Grid.Column="1"
Text="{Binding ConfigurationPath}"
IsReadOnly="True"
Watermark="Nessuna configurazione caricata" />
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="150,*,116" ColumnSpacing="10">
<TextBlock Text="Sorgente" VerticalAlignment="Center" FontWeight="SemiBold" />
<TextBox Grid.Column="1" Text="{Binding SourcePath, Mode=TwoWay}" Watermark="Cartella sorgente" />
<Button Grid.Column="2" Command="{Binding SelectSourceFolderCommand}" ToolTip.Tip="Seleziona cartella sorgente">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="7">
<iconPacks:PackIconMaterial Kind="FolderOpenOutline" Width="16" Height="16" />
<TextBlock Text="Scegli" />
</StackPanel>
</Button>
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="150,*,116" ColumnSpacing="10">
<TextBlock Text="Destinazione" VerticalAlignment="Center" FontWeight="SemiBold" />
<TextBox Grid.Column="1" Text="{Binding DestinationPath, Mode=TwoWay}" Watermark="Cartella destinazione" />
<Button Grid.Column="2" Command="{Binding SelectDestinationFolderCommand}" ToolTip.Tip="Seleziona cartella destinazione">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="7">
<iconPacks:PackIconMaterial Kind="FolderOpenOutline" Width="16" Height="16" />
<TextBlock Text="Scegli" />
</StackPanel>
</Button>
</Grid>
<Border Grid.Row="3"
Background="{DynamicResource PanelBackgroundBrush}"
BorderBrush="{DynamicResource BorderMutedBrush}"
BorderThickness="1"
Padding="14">
<Grid RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="8">
<TextBlock Text="{Binding ProcessingStatus}" TextWrapping="Wrap" />
<ProgressBar Grid.Row="1"
Minimum="0"
Maximum="{Binding ProgressBarMaximum}"
Value="{Binding ProgressBarValue}" />
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6">
<TextBlock Text="Elaborate" FontWeight="SemiBold" />
<TextBlock Text="{Binding ProcessedImagesCount}" />
<TextBlock Text="/" />
<TextBlock Text="{Binding TotalImagesCount}" />
</StackPanel>
<TextBlock Grid.Row="3" Text="{Binding SpeedCounter}" TextWrapping="Wrap" Opacity="0.78" />
</Grid>
</Border>
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
<Button Command="{Binding StartProcessingCommand}" MinWidth="112" ToolTip.Tip="Avvia elaborazione">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="7">
<iconPacks:PackIconMaterial Kind="PlayCircleOutline" Width="16" Height="16" Foreground="{DynamicResource AccentBrush}" />
<TextBlock Text="Avvia" />
</StackPanel>
</Button>
<Button Command="{Binding StopProcessingCommand}" MinWidth="112" ToolTip.Tip="Ferma elaborazione">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="7">
<iconPacks:PackIconMaterial Kind="StopCircleOutline" Width="16" Height="16" Foreground="#B3261E" />
<TextBlock Text="Stop" />
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Window>

View file

@ -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<LiteCatalogViewModel>())
{
}
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);
}
}

45
CatalogLite/Program.cs Normal file
View file

@ -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<App>()
.UsePlatformDetect()
.LogToTrace();
private static void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<PicSettings>();
services.AddSingleton<CatalogConfigurationLoader>();
services.AddTransient<ImageCreatorImageSharp>();
services.AddTransient<IImageCreator>(sp => sp.GetRequiredService<ImageCreatorImageSharp>());
services.AddTransient<ImageCreationService>();
services.AddTransient<ImageProcessingCoordinator>();
services.AddTransient<LiteCatalogViewModel>();
services.AddTransient<MainWindow>();
services.AddLogging(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Information);
});
}
}

View file

@ -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));
}
}

View file

@ -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.