diff --git a/src/Avolutions.Baf.Core.csproj b/src/Avolutions.Baf.Core.csproj
index 087962f..53f898c 100644
--- a/src/Avolutions.Baf.Core.csproj
+++ b/src/Avolutions.Baf.Core.csproj
@@ -6,7 +6,7 @@
enable
Avolutions.Baf.Core
- 0.16.0
+ 0.17.0
Avolutions BAF Core
Avolutions
@@ -34,6 +34,7 @@
+
@@ -47,6 +48,7 @@
+
diff --git a/src/Caching/Abstractions/ICache.cs b/src/Caching/Abstractions/ICache.cs
new file mode 100644
index 0000000..975c6be
--- /dev/null
+++ b/src/Caching/Abstractions/ICache.cs
@@ -0,0 +1,12 @@
+namespace Avolutions.Baf.Core.Caching.Abstractions;
+
+public interface ICache
+{
+ Task RefreshAsync(CancellationToken cancellationToken = default);
+}
+
+public interface ICache : ICache
+{
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/src/Caching/CacheBase.cs b/src/Caching/CacheBase.cs
new file mode 100644
index 0000000..ae204d2
--- /dev/null
+++ b/src/Caching/CacheBase.cs
@@ -0,0 +1,49 @@
+using System.Collections.Concurrent;
+using Avolutions.Baf.Core.Caching.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Avolutions.Baf.Core.Caching;
+
+public abstract class CacheBase : ICache
+{
+ private readonly SemaphoreSlim _loadLock = new(1, 1);
+ private IReadOnlyList _items = [];
+ private ConcurrentDictionary _itemsById = new();
+
+ protected readonly IServiceScopeFactory ScopeFactory;
+
+ protected CacheBase(IServiceScopeFactory scopeFactory)
+ {
+ ScopeFactory = scopeFactory;
+ }
+
+ public Task> GetAllAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(_items);
+ }
+
+ public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ _itemsById.TryGetValue(id, out var item);
+ return Task.FromResult(item);
+ }
+
+ public async Task RefreshAsync(CancellationToken cancellationToken = default)
+ {
+ await _loadLock.WaitAsync(cancellationToken);
+ try
+ {
+ var items = await LoadAsync(cancellationToken);
+ _items = items;
+ _itemsById = new ConcurrentDictionary(items.ToDictionary(GetId));
+ }
+ finally
+ {
+ _loadLock.Release();
+ }
+ }
+
+ protected abstract Task> LoadAsync(CancellationToken cancellationToken);
+
+ protected abstract Guid GetId(T item);
+}
\ No newline at end of file
diff --git a/src/Caching/CacheInitializer.cs b/src/Caching/CacheInitializer.cs
new file mode 100644
index 0000000..22e9384
--- /dev/null
+++ b/src/Caching/CacheInitializer.cs
@@ -0,0 +1,41 @@
+using Avolutions.Baf.Core.Caching.Abstractions;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Avolutions.Baf.Core.Caching;
+
+public class CacheInitializer : IHostedService
+{
+ private readonly IEnumerable _caches;
+ private readonly ILogger _logger;
+
+ public CacheInitializer(IEnumerable caches, ILogger logger)
+ {
+ _caches = caches;
+ _logger = logger;
+ }
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Initializing {Count} caches...", _caches.Count());
+
+ foreach (var cache in _caches)
+ {
+ var cacheName = cache.GetType().Name;
+ try
+ {
+ _logger.LogDebug("Loading cache: {CacheName}", cacheName);
+ await cache.RefreshAsync(cancellationToken);
+ _logger.LogDebug("Cache loaded: {CacheName}", cacheName);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load cache: {CacheName}", cacheName);
+ }
+ }
+
+ _logger.LogInformation("Cache initialization completed");
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
\ No newline at end of file
diff --git a/src/Caching/CachingModule.cs b/src/Caching/CachingModule.cs
new file mode 100644
index 0000000..aa2e01c
--- /dev/null
+++ b/src/Caching/CachingModule.cs
@@ -0,0 +1,12 @@
+using Avolutions.Baf.Core.Module.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Avolutions.Baf.Core.Caching;
+
+public class CachingModule : IFeatureModule
+{
+ public void Register(IServiceCollection services)
+ {
+ services.AddHostedService();
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/Abstractions/IEntityNavigationCache.cs b/src/Entity/Abstractions/IEntityNavigationCache.cs
new file mode 100644
index 0000000..2288b7f
--- /dev/null
+++ b/src/Entity/Abstractions/IEntityNavigationCache.cs
@@ -0,0 +1,14 @@
+using Avolutions.Baf.Core.Caching.Abstractions;
+using Avolutions.Baf.Core.Entity.Models;
+
+namespace Avolutions.Baf.Core.Entity.Abstractions;
+
+public interface IEntityNavigationCache : ICache
+{
+ Task GetNavigationAsync(Guid currentId, CancellationToken cancellationToken = default);
+}
+
+public interface IEntityNavigationCache : IEntityNavigationCache
+ where TEntity : class, IEntity
+{
+}
\ No newline at end of file
diff --git a/src/Entity/Abstractions/IEntityRouteProvider.cs b/src/Entity/Abstractions/IEntityRouteProvider.cs
new file mode 100644
index 0000000..5e6809b
--- /dev/null
+++ b/src/Entity/Abstractions/IEntityRouteProvider.cs
@@ -0,0 +1,9 @@
+namespace Avolutions.Baf.Core.Entity.Abstractions;
+
+public interface IEntityRouteProvider where TEntity : class, IEntity
+{
+ string Index { get; }
+ string Create { get; }
+ string Details(Guid id);
+ string Edit(Guid id);
+}
\ No newline at end of file
diff --git a/src/Entity/Abstractions/INavigable.cs b/src/Entity/Abstractions/INavigable.cs
new file mode 100644
index 0000000..a90113d
--- /dev/null
+++ b/src/Entity/Abstractions/INavigable.cs
@@ -0,0 +1,5 @@
+namespace Avolutions.Baf.Core.Entity.Abstractions;
+
+public interface INavigable
+{
+}
\ No newline at end of file
diff --git a/src/Entity/Abstractions/ITranslatable.cs b/src/Entity/Abstractions/ITranslatable.cs
deleted file mode 100644
index fb2f278..0000000
--- a/src/Entity/Abstractions/ITranslatable.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace Avolutions.Baf.Core.Entity.Abstractions;
-
-public interface ITranslatable
-{
- string Value { get; }
- bool IsDefault { get; set; }
-}
-
-public interface ITranslatable : ITranslatable
- where TTranslation : ITranslation
-{
- ICollection Translations { get; }
-}
\ No newline at end of file
diff --git a/src/Entity/Attributes/EntityNavigationKeyAttribute.cs b/src/Entity/Attributes/EntityNavigationKeyAttribute.cs
new file mode 100644
index 0000000..feaaa04
--- /dev/null
+++ b/src/Entity/Attributes/EntityNavigationKeyAttribute.cs
@@ -0,0 +1,6 @@
+namespace Avolutions.Baf.Core.Entity.Attributes;
+
+[AttributeUsage(AttributeTargets.Property)]
+public class EntityNavigationKeyAttribute : Attribute
+{
+}
\ No newline at end of file
diff --git a/src/Entity/Attributes/EntityRouteAttribute.cs b/src/Entity/Attributes/EntityRouteAttribute.cs
new file mode 100644
index 0000000..598dcc1
--- /dev/null
+++ b/src/Entity/Attributes/EntityRouteAttribute.cs
@@ -0,0 +1,12 @@
+namespace Avolutions.Baf.Core.Entity.Attributes;
+
+[AttributeUsage(AttributeTargets.Class)]
+public class EntityRouteAttribute : Attribute
+{
+ public string BaseUrl { get; }
+
+ public EntityRouteAttribute(string baseUrl)
+ {
+ BaseUrl = baseUrl.TrimEnd('/');
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/Cache/EntityNavigationCache.cs b/src/Entity/Cache/EntityNavigationCache.cs
new file mode 100644
index 0000000..0c95e6f
--- /dev/null
+++ b/src/Entity/Cache/EntityNavigationCache.cs
@@ -0,0 +1,96 @@
+using System.Linq.Expressions;
+using System.Reflection;
+using Avolutions.Baf.Core.Entity.Abstractions;
+using Avolutions.Baf.Core.Entity.Attributes;
+using Avolutions.Baf.Core.Entity.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Avolutions.Baf.Core.Entity.Cache;
+
+public class EntityNavigationCache : IEntityNavigationCache
+ where TEntity : class, IEntity, INavigable
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+ private readonly Expression> _orderByExpression;
+
+ private IReadOnlyList _orderedIds = Array.Empty();
+ private Dictionary _idToIndex = new();
+
+ public EntityNavigationCache(IServiceScopeFactory scopeFactory)
+ {
+ _scopeFactory = scopeFactory;
+ _orderByExpression = BuildOrderByExpression();
+ }
+
+ private static Expression> BuildOrderByExpression()
+ {
+ var property = typeof(TEntity)
+ .GetProperties()
+ .FirstOrDefault(p => p.GetCustomAttribute() != null);
+
+ if (property == null)
+ {
+ throw new InvalidOperationException($"Entity {typeof(TEntity).Name} must have a property with [NavigationKey] attribute.");
+ }
+
+ var parameter = Expression.Parameter(typeof(TEntity), "e");
+ var propertyAccess = Expression.Property(parameter, property);
+ var converted = Expression.Convert(propertyAccess, typeof(object));
+
+ return Expression.Lambda>(converted, parameter);
+ }
+
+ public async Task RefreshAsync(CancellationToken cancellationToken = default)
+ {
+ await _lock.WaitAsync(cancellationToken);
+ try
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+
+ var ordered = await context.Set()
+ .AsNoTracking()
+ .OrderBy(_orderByExpression)
+ .Select(e => e.Id)
+ .ToListAsync(cancellationToken);
+
+ var index = new Dictionary(ordered.Count);
+ for (var i = 0; i < ordered.Count; i++)
+ {
+ index[ordered[i]] = i;
+ }
+
+ _orderedIds = ordered;
+ _idToIndex = index;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public Task GetNavigationAsync(Guid currentId, CancellationToken cancellationToken = default)
+ {
+ var ids = _orderedIds;
+
+ if (ids.Count == 0)
+ {
+ return Task.FromResult(new EntityNavigationResult(null, null, null, null, null, 0));
+ }
+
+ if (!_idToIndex.TryGetValue(currentId, out var currentIndex))
+ {
+ return Task.FromResult(new EntityNavigationResult(ids[0], null, null, ids[^1], null, ids.Count));
+ }
+
+ return Task.FromResult(new EntityNavigationResult(
+ ids[0],
+ currentIndex > 0 ? ids[currentIndex - 1] : null,
+ currentIndex < ids.Count - 1 ? ids[currentIndex + 1] : null,
+ ids[^1],
+ currentIndex,
+ ids.Count));
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/EntityModule.cs b/src/Entity/EntityModule.cs
index a478eda..e1c0f37 100644
--- a/src/Entity/EntityModule.cs
+++ b/src/Entity/EntityModule.cs
@@ -11,7 +11,7 @@ public class EntityModule : IFeatureModule
public void Register(IServiceCollection services)
{
services.AddScoped(typeof(IEntityService<>), typeof(EntityService<>));
- services.AddScoped(typeof(ITranslatableEntityService<,>), typeof(TranslatableEntityService<,>));
services.AddScoped();
+ services.AddSingleton(typeof(IEntityRouteProvider<>), typeof(EntityRouteProvider<>));
}
}
\ No newline at end of file
diff --git a/src/Entity/Extensions/ServiceCollectionExtensions.cs b/src/Entity/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..b3720ed
--- /dev/null
+++ b/src/Entity/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,22 @@
+using Avolutions.Baf.Core.Caching.Abstractions;
+using Avolutions.Baf.Core.Entity.Abstractions;
+using Avolutions.Baf.Core.Entity.Cache;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Avolutions.Baf.Core.Entity.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddEntityNavigationCache(this IServiceCollection services)
+ where TEntity : class, IEntity, INavigable
+ {
+ var entityType = typeof(TEntity);
+ var cacheType = typeof(EntityNavigationCache<>).MakeGenericType(entityType);
+ var interfaceType = typeof(IEntityNavigationCache<>).MakeGenericType(entityType);
+
+ services.AddSingleton(interfaceType, cacheType);
+ services.AddSingleton(typeof(ICache), sp => sp.GetRequiredService(interfaceType));
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/Extensions/TranslationExtensions.cs b/src/Entity/Extensions/TranslationExtensions.cs
deleted file mode 100644
index 85df12f..0000000
--- a/src/Entity/Extensions/TranslationExtensions.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.Globalization;
-using Avolutions.Baf.Core.Entity.Abstractions;
-
-namespace Avolutions.Baf.Core.Entity.Extensions;
-
-public static class TranslationExtensions
-{
- public static string Localized(
- this IEnumerable translations,
- Func selector)
- where TTrans : ITranslation
- {
- var language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName.ToLowerInvariant();
- return translations.FirstOrDefault(t => t.Language.Equals(language, StringComparison.OrdinalIgnoreCase)) is { } translation
- ? selector(translation) ?? string.Empty
- : string.Empty;
- }
-}
\ No newline at end of file
diff --git a/src/Entity/Models/EntityNavigationResult.cs b/src/Entity/Models/EntityNavigationResult.cs
new file mode 100644
index 0000000..1223de9
--- /dev/null
+++ b/src/Entity/Models/EntityNavigationResult.cs
@@ -0,0 +1,15 @@
+namespace Avolutions.Baf.Core.Entity.Models;
+
+public record EntityNavigationResult(
+ Guid? FirstId,
+ Guid? PreviousId,
+ Guid? NextId,
+ Guid? LastId,
+ int? CurrentIndex,
+ int TotalCount)
+{
+ public bool HasFirst => CurrentIndex > 0;
+ public bool HasPrevious => PreviousId.HasValue;
+ public bool HasNext => NextId.HasValue;
+ public bool HasLast => CurrentIndex < TotalCount - 1;
+}
\ No newline at end of file
diff --git a/src/Entity/Models/TranslatableEntity.cs b/src/Entity/Models/TranslatableEntity.cs
deleted file mode 100644
index 1baee9d..0000000
--- a/src/Entity/Models/TranslatableEntity.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System.ComponentModel.DataAnnotations.Schema;
-using System.Globalization;
-using Avolutions.Baf.Core.Entity.Abstractions;
-using Avolutions.Baf.Core.Entity.Extensions;
-
-namespace Avolutions.Baf.Core.Entity.Models;
-
-public abstract class TranslatableEntity
- : EntityBase, ITranslatable
- where TTranslation : TranslationEntity, new()
-{
- protected TranslatableEntity() { }
-
- protected TranslatableEntity(bool createMissingTranslations)
- {
- if (createMissingTranslations)
- {
- CreateMissingTranslations();
- }
- }
-
- public ICollection Translations { get; set; } = new List();
-
- [NotMapped]
- public string Value => Translations.Localized(t => t.Value);
-
- public bool IsDefault { get; set; }
-
- public void CreateMissingTranslations()
- {
- if (Translations.Count > 0)
- {
- return;
- }
-
- var defaultTranslation = new TTranslation
- {
- Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName.ToLowerInvariant()
- };
-
- Translations.Add(defaultTranslation);
- }
-}
\ No newline at end of file
diff --git a/src/Entity/Models/TranslationEntity.cs b/src/Entity/Models/TranslationEntity.cs
deleted file mode 100644
index 1adf477..0000000
--- a/src/Entity/Models/TranslationEntity.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Avolutions.Baf.Core.Entity.Abstractions;
-
-namespace Avolutions.Baf.Core.Entity.Models;
-
-public abstract class TranslationEntity
- : EntityBase, ITranslation
-{
- public Guid ParentId { get; set; }
- public string Language { get; set; } = string.Empty;
- public string Value { get; set; } = string.Empty;
-}
\ No newline at end of file
diff --git a/src/Entity/Services/EntityRouteProvider.cs b/src/Entity/Services/EntityRouteProvider.cs
new file mode 100644
index 0000000..c591002
--- /dev/null
+++ b/src/Entity/Services/EntityRouteProvider.cs
@@ -0,0 +1,24 @@
+using System.Reflection;
+using Avolutions.Baf.Core.Entity.Abstractions;
+using Avolutions.Baf.Core.Entity.Attributes;
+using Humanizer;
+
+namespace Avolutions.Baf.Core.Entity.Services;
+
+public class EntityRouteProvider : IEntityRouteProvider
+ where TEntity : class, IEntity
+{
+ private readonly string _baseUrl;
+
+ public EntityRouteProvider()
+ {
+ var attribute = typeof(TEntity).GetCustomAttribute();
+
+ _baseUrl = attribute?.BaseUrl ?? $"/{typeof(TEntity).Name.Pluralize().Kebaberize()}";
+ }
+
+ public string Index => _baseUrl;
+ public string Create => $"{_baseUrl}/create";
+ public string Details(Guid id) => $"{_baseUrl}/{id}";
+ public string Edit(Guid id) => $"{_baseUrl}/edit/{id}";
+}
\ No newline at end of file
diff --git a/src/Entity/Services/TranslatedEntityService.cs b/src/Entity/Services/TranslatedEntityService.cs
deleted file mode 100644
index 20595ba..0000000
--- a/src/Entity/Services/TranslatedEntityService.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-using System.Globalization;
-using Avolutions.Baf.Core.Entity.Abstractions;
-using Avolutions.Baf.Core.Entity.Exceptions;
-using FluentValidation;
-using Microsoft.EntityFrameworkCore;
-
-namespace Avolutions.Baf.Core.Entity.Services;
-
-public class TranslatableEntityService : EntityService, ITranslatableEntityService
- where T : class, ITranslatable, IEntity
- where TTranslation : class, ITranslation
-{
- public TranslatableEntityService(DbContext context) : base(context)
- {
- }
-
- public TranslatableEntityService(DbContext context, IValidator? validator) : base(context, validator)
- {
- }
-
- public override async Task CreateAsync(T entity, CancellationToken cancellationToken = default)
- {
- if (!await DbSet.AnyAsync(cancellationToken))
- {
- entity.IsDefault = true;
- }
-
- return await base.CreateAsync(entity, cancellationToken);
- }
-
- public override async Task GetByIdAsync(Guid id)
- {
- var lang = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName.ToLowerInvariant();
- return await GetByIdAsync(id, lang);
- }
-
- public async Task GetByIdAsync(Guid id, string language, CancellationToken cancellationToken = default)
- {
- return await DbSet
- .Include(p => p.Translations.Where(t => t.Language == language))
- .AsNoTracking()
- .SingleOrDefaultAsync(p => p.Id == id, cancellationToken: cancellationToken);
- }
-
- public override async Task> GetAllAsync(CancellationToken cancellationToken = default)
- {
- return await DbSet
- .Include(p => p.Translations)
- .AsNoTracking()
- .ToListAsync(cancellationToken);
- }
-
- public async Task> GetAllAsync(string language, CancellationToken cancellationToken = default)
- {
- return await DbSet
- .Include(p => p.Translations.Where(t => t.Language == language))
- .AsNoTracking()
- .ToListAsync(cancellationToken);
- }
-
- public async Task SetDefaultAsync(Guid id, CancellationToken cancellationToken = default)
- {
- var entity = await DbSet.FindAsync([id], cancellationToken);
- if (entity is null)
- {
- throw new EntityNotFoundException(typeof(T), id);
- }
-
- // If it's already the default, nothing to do
- if (entity.IsDefault)
- {
- return;
- }
-
- // Remove default from all units
- await DbSet
- .Where(q => q.IsDefault)
- .ExecuteUpdateAsync(q => q.SetProperty(x => x.IsDefault, false), cancellationToken: cancellationToken);
-
- // Set the new default
- entity.IsDefault = true;
-
- await Context.SaveChangesAsync(cancellationToken);
- }
-
- public Task GetDefaultAsync(CancellationToken cancellationToken = default)
- {
- return DbSet
- .AsNoTracking()
- .SingleAsync(p => p.IsDefault, cancellationToken);
- }
-}
\ No newline at end of file
diff --git a/src/Localization/LocalizationContext.cs b/src/Localization/LocalizationContext.cs
new file mode 100644
index 0000000..cd574b0
--- /dev/null
+++ b/src/Localization/LocalizationContext.cs
@@ -0,0 +1,27 @@
+using System.Globalization;
+using Avolutions.Baf.Core.Localization.Settings;
+
+namespace Avolutions.Baf.Core.Localization;
+
+public static class LocalizationContext
+{
+ private static IReadOnlyList? _availableLanguages;
+ private static string? _defaultLanguage;
+
+ public static IReadOnlyList AvailableLanguages =>
+ _availableLanguages ?? throw new InvalidOperationException("LocalizationContext not initialized");
+
+ public static string DefaultLanguage =>
+ _defaultLanguage ?? throw new InvalidOperationException("LocalizationContext not initialized");
+
+ public static string CurrentLanguage =>
+ CultureInfo.CurrentUICulture.TwoLetterISOLanguageName.ToLowerInvariant();
+
+ public static void Initialize(LocalizationSettings settings)
+ {
+ _availableLanguages = settings.AvailableLanguages
+ .Select(l => l.ToLowerInvariant())
+ .ToList();
+ _defaultLanguage = settings.DefaultLanguage.ToLowerInvariant();
+ }
+}
\ No newline at end of file
diff --git a/src/Localization/LocalizationModule.cs b/src/Localization/LocalizationModule.cs
index 323d442..4f34a39 100644
--- a/src/Localization/LocalizationModule.cs
+++ b/src/Localization/LocalizationModule.cs
@@ -1,7 +1,9 @@
-using Avolutions.Baf.Core.Localization.Settings;
+using System.Globalization;
+using Avolutions.Baf.Core.Localization.Settings;
using Avolutions.Baf.Core.Module.Abstractions;
using Avolutions.Baf.Core.Settings.Abstractions;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.DependencyInjection;
namespace Avolutions.Baf.Core.Localization;
@@ -15,17 +17,39 @@ public void Register(IServiceCollection services)
services.AddOptions()
.Configure>((options, localizationSettings) =>
{
- var cultures = localizationSettings.Value.AvailableLanguages
- .Select(culture => new System.Globalization.CultureInfo(culture)).ToList();
+ var settings = localizationSettings.Value;
+
+ // Ensure ultimate fallback
+ if (settings.AvailableLanguages.Count == 0)
+ {
+ settings.AvailableLanguages = ["en"];
+ }
+
+ if (settings.AvailableCultures.Count == 0)
+ {
+ settings.AvailableCultures = ["en-US"];
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.DefaultLanguage))
+ {
+ settings.DefaultLanguage = "en";
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.DefaultCulture))
+ {
+ settings.DefaultCulture = "en-US";
+ }
+
+ // Initialize static BAF context
+ LocalizationContext.Initialize(settings);
+
+ var cultures = settings.AvailableCultures
+ .Select(c => new CultureInfo(c))
+ .ToList();
- options.SupportedCultures = cultures;
- options.SupportedUICultures = cultures;
-
- var fallback = string.IsNullOrWhiteSpace(localizationSettings.Value.DefaultLanguage)
- ? "en"
- : localizationSettings.Value.DefaultLanguage.ToLowerInvariant();
-
- options.DefaultRequestCulture = new Microsoft.AspNetCore.Localization.RequestCulture(fallback);
+ options.SupportedCultures = cultures;
+ options.SupportedUICultures = cultures;
+ options.DefaultRequestCulture = new RequestCulture(settings.DefaultCulture);
options.FallBackToParentCultures = true;
options.FallBackToParentUICultures = true;
});
diff --git a/src/Localization/Settings/LocalizationSettings.cs b/src/Localization/Settings/LocalizationSettings.cs
index 1af5f42..8e3ccef 100644
--- a/src/Localization/Settings/LocalizationSettings.cs
+++ b/src/Localization/Settings/LocalizationSettings.cs
@@ -6,5 +6,7 @@ namespace Avolutions.Baf.Core.Localization.Settings;
public class LocalizationSettings
{
public List AvailableLanguages { get; set; } = [ "de", "en" ];
+ public List AvailableCultures { get; set; } = ["de-DE", "en-US"];
public string DefaultLanguage { get; set; } = "en";
+ public string DefaultCulture { get; set; } = "en-US";
}
\ No newline at end of file
diff --git a/src/Lookups/Abstractions/ILookup.cs b/src/Lookups/Abstractions/ILookup.cs
new file mode 100644
index 0000000..0925179
--- /dev/null
+++ b/src/Lookups/Abstractions/ILookup.cs
@@ -0,0 +1,13 @@
+namespace Avolutions.Baf.Core.Lookups.Abstractions;
+
+public interface ILookup
+{
+ string Value { get; }
+ bool IsDefault { get; set; }
+}
+
+public interface ILookup : ILookup
+ where TTranslation : ILookupTranslation
+{
+ ICollection Translations { get; }
+}
\ No newline at end of file
diff --git a/src/Lookups/Abstractions/ILookupCache.cs b/src/Lookups/Abstractions/ILookupCache.cs
new file mode 100644
index 0000000..3b57af6
--- /dev/null
+++ b/src/Lookups/Abstractions/ILookupCache.cs
@@ -0,0 +1,11 @@
+using Avolutions.Baf.Core.Caching.Abstractions;
+using Avolutions.Baf.Core.Entity.Abstractions;
+
+namespace Avolutions.Baf.Core.Lookups.Abstractions;
+
+public interface ILookupCache : ICache
+ where T : class, ILookup, IEntity
+{
+ Task GetDefaultAsync(CancellationToken cancellationToken = default);
+ Task GetDefaultIdAsync(CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/src/Entity/Abstractions/ITranslatableEntityService.cs b/src/Lookups/Abstractions/ILookupService.cs
similarity index 60%
rename from src/Entity/Abstractions/ITranslatableEntityService.cs
rename to src/Lookups/Abstractions/ILookupService.cs
index c250ee0..d75bfe3 100644
--- a/src/Entity/Abstractions/ITranslatableEntityService.cs
+++ b/src/Lookups/Abstractions/ILookupService.cs
@@ -1,8 +1,9 @@
-namespace Avolutions.Baf.Core.Entity.Abstractions;
+using Avolutions.Baf.Core.Entity.Abstractions;
-public interface ITranslatableEntityService : IEntityService
- where T : class, ITranslatable, IEntity
- where TTranslation : class, ITranslation
+namespace Avolutions.Baf.Core.Lookups.Abstractions;
+
+public interface ILookupService : IEntityService
+ where T : class, ILookup, IEntity
{
Task GetByIdAsync(Guid id, string language, CancellationToken cancellationToken = default);
Task> GetAllAsync(string language, CancellationToken cancellationToken = default);
diff --git a/src/Entity/Abstractions/ITranslation.cs b/src/Lookups/Abstractions/ILookupTranslation.cs
similarity index 52%
rename from src/Entity/Abstractions/ITranslation.cs
rename to src/Lookups/Abstractions/ILookupTranslation.cs
index e20d836..272ecba 100644
--- a/src/Entity/Abstractions/ITranslation.cs
+++ b/src/Lookups/Abstractions/ILookupTranslation.cs
@@ -1,6 +1,6 @@
-namespace Avolutions.Baf.Core.Entity.Abstractions;
+namespace Avolutions.Baf.Core.Lookups.Abstractions;
-public interface ITranslation
+public interface ILookupTranslation
{
string Language { get; set; }
Guid ParentId { get; set; }
diff --git a/src/Lookups/Cache/LookupCache.cs b/src/Lookups/Cache/LookupCache.cs
new file mode 100644
index 0000000..d587cb0
--- /dev/null
+++ b/src/Lookups/Cache/LookupCache.cs
@@ -0,0 +1,47 @@
+using Avolutions.Baf.Core.Caching;
+using Avolutions.Baf.Core.Entity.Abstractions;
+using Avolutions.Baf.Core.Lookups.Abstractions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Avolutions.Baf.Core.Lookups.Cache;
+
+public class LookupCache : CacheBase, ILookupCache
+ where T : class, ILookup, IEntity
+ where TTranslation : class, ILookupTranslation
+{
+ private T? _default;
+
+ public LookupCache(IServiceScopeFactory scopeFactory) : base(scopeFactory)
+ {
+ }
+
+ public Task GetDefaultAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(
+ _default ?? throw new InvalidOperationException($"No default {typeof(T).Name} configured"));
+ }
+
+ public Task GetDefaultIdAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(
+ _default?.Id ?? throw new InvalidOperationException($"No default {typeof(T).Name} configured"));
+ }
+
+ protected override async Task> LoadAsync(CancellationToken cancellationToken)
+ {
+ using var scope = ScopeFactory.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+
+ var items = await context.Set()
+ .Include(e => e.Translations)
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+
+ _default = items.FirstOrDefault(x => x.IsDefault) ?? items.FirstOrDefault();
+
+ return items;
+ }
+
+ protected override Guid GetId(T item) => item.Id;
+}
\ No newline at end of file
diff --git a/src/Entity/Configurations/TranslatableConfiguration.cs b/src/Lookups/Configurations/LookupConfiguration.cs
similarity index 67%
rename from src/Entity/Configurations/TranslatableConfiguration.cs
rename to src/Lookups/Configurations/LookupConfiguration.cs
index 33dcc4b..47ab74c 100644
--- a/src/Entity/Configurations/TranslatableConfiguration.cs
+++ b/src/Lookups/Configurations/LookupConfiguration.cs
@@ -1,37 +1,37 @@
-using Avolutions.Baf.Core.Entity.Abstractions;
+using Avolutions.Baf.Core.Lookups.Abstractions;
using Avolutions.Baf.Core.Persistence.Abstractions;
using Humanizer;
using Microsoft.EntityFrameworkCore;
-namespace Avolutions.Baf.Core.Entity.Configurations;
+namespace Avolutions.Baf.Core.Lookups.Configurations;
-public class TranslatableConfiguration : IModelConfiguration
+public class LookupConfiguration : IModelConfiguration
{
public void Configure(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes()
- .Where(t => typeof(ITranslatable).IsAssignableFrom(t.ClrType)))
+ .Where(t => typeof(ILookup).IsAssignableFrom(t.ClrType)))
{
var clr = entityType.ClrType;
var translationClr = clr.GetInterfaces()
- .First(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ITranslatable<>))
+ .First(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ILookup<>))
.GetGenericArguments()[0];
var builder = modelBuilder.Entity(clr);
builder.ToTable(clr.Name.Pluralize());
- builder.HasMany(translationClr, navigationName: nameof(ITranslatable.Translations))
+ builder.HasMany(translationClr, navigationName: nameof(ILookup.Translations))
.WithOne()
.HasForeignKey("ParentId")
.IsRequired()
.OnDelete(DeleteBehavior.Cascade);
- builder.HasIndex(nameof(ITranslatable.IsDefault))
+ builder.HasIndex(nameof(ILookup.IsDefault))
.IsUnique()
.HasFilter("\"IsDefault\" = true");
- builder.Navigation(nameof(ITranslatable.Translations))
+ builder.Navigation(nameof(ILookup.Translations))
.AutoInclude();
}
}
diff --git a/src/Entity/Configurations/TranslationConfiguration.cs b/src/Lookups/Configurations/LookupTranslationConfiguration.cs
similarity index 59%
rename from src/Entity/Configurations/TranslationConfiguration.cs
rename to src/Lookups/Configurations/LookupTranslationConfiguration.cs
index 6d459d7..bda1c84 100644
--- a/src/Entity/Configurations/TranslationConfiguration.cs
+++ b/src/Lookups/Configurations/LookupTranslationConfiguration.cs
@@ -1,16 +1,16 @@
-using Avolutions.Baf.Core.Entity.Abstractions;
+using Avolutions.Baf.Core.Lookups.Abstractions;
using Avolutions.Baf.Core.Persistence.Abstractions;
using Humanizer;
using Microsoft.EntityFrameworkCore;
-namespace Avolutions.Baf.Core.Entity.Configurations;
+namespace Avolutions.Baf.Core.Lookups.Configurations;
-public class TranslationConfiguration : IModelConfiguration
+public class LookupTranslationConfiguration : IModelConfiguration
{
public void Configure(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes()
- .Where(t => typeof(ITranslation).IsAssignableFrom(t.ClrType)))
+ .Where(t => typeof(ILookupTranslation).IsAssignableFrom(t.ClrType)))
{
var clr = entityType.ClrType;
var builder = modelBuilder.Entity(clr);
@@ -18,11 +18,11 @@ public void Configure(ModelBuilder modelBuilder)
builder.ToTable(clr.Name.Pluralize());
// Unique one-translation-per-language per parent
- builder.HasIndex(nameof(ITranslation.ParentId), nameof(ITranslation.Language))
+ builder.HasIndex(nameof(ILookupTranslation.ParentId), nameof(ILookupTranslation.Language))
.IsUnique();
// ISO-2 language code, required
- builder.Property(nameof(ITranslation.Language))
+ builder.Property(nameof(ILookupTranslation.Language))
.HasMaxLength(2)
.IsRequired();
}
diff --git a/src/Lookups/Extensions/LookupTranslationExtensions.cs b/src/Lookups/Extensions/LookupTranslationExtensions.cs
new file mode 100644
index 0000000..a57ecee
--- /dev/null
+++ b/src/Lookups/Extensions/LookupTranslationExtensions.cs
@@ -0,0 +1,18 @@
+using Avolutions.Baf.Core.Localization;
+using Avolutions.Baf.Core.Lookups.Abstractions;
+
+namespace Avolutions.Baf.Core.Lookups.Extensions;
+
+public static class LookupTranslationExtensions
+{
+ public static string Localized(
+ this ICollection translations,
+ Func selector)
+ where TTrans : ILookupTranslation
+ {
+ var translation = translations.FirstOrDefault(t => t.Language == LocalizationContext.CurrentLanguage)
+ ?? translations.FirstOrDefault(t => t.Language == LocalizationContext.DefaultLanguage);
+
+ return translation is not null ? selector(translation) ?? string.Empty : string.Empty;
+ }
+}
\ No newline at end of file
diff --git a/src/Lookups/Extensions/ServiceCollectionExtensions.cs b/src/Lookups/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..0bfdc1f
--- /dev/null
+++ b/src/Lookups/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,60 @@
+using Avolutions.Baf.Core.Caching.Abstractions;
+using Avolutions.Baf.Core.Entity.Abstractions;
+using Avolutions.Baf.Core.Lookups.Abstractions;
+using Avolutions.Baf.Core.Lookups.Cache;
+using Avolutions.Baf.Core.Lookups.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Avolutions.Baf.Core.Lookups.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddLookupCache(this IServiceCollection services)
+ where T : class, ILookup, IEntity
+ {
+ var lookupInterface = typeof(T).GetInterfaces()
+ .First(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ILookup<>));
+
+ var lookupType = lookupInterface.GetGenericArguments()[0];
+
+ var cacheType = typeof(LookupCache<,>).MakeGenericType(typeof(T), lookupType);
+ var lookupInterfaceType = typeof(ILookupCache<>).MakeGenericType(typeof(T));
+
+ services.AddSingleton(lookupInterfaceType, cacheType);
+
+ // Also register as ICache so CacheInitializer can find it
+ services.AddSingleton(typeof(ICache), sp => sp.GetRequiredService(lookupInterfaceType));
+
+ return services;
+ }
+
+ public static IServiceCollection AddLookupService(this IServiceCollection services)
+ where T : class, ILookup, IEntity
+ {
+ var lookupInterface = typeof(T).GetInterfaces()
+ .First(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ILookup<>));
+
+ var lookupType = lookupInterface.GetGenericArguments()[0];
+
+ var serviceType = typeof(LookupService<,>).MakeGenericType(typeof(T), lookupType);
+ var entityServiceInterface = typeof(IEntityService<>).MakeGenericType(typeof(T));
+ var lookupServiceInterface = typeof(ILookupService<>).MakeGenericType(typeof(T));
+
+ // Register the concrete service
+ services.AddScoped(serviceType);
+
+ // Register interfaces pointing to the same instance
+ services.AddScoped(entityServiceInterface, sp => sp.GetRequiredService(serviceType));
+ services.AddScoped(lookupServiceInterface, sp => sp.GetRequiredService(serviceType));
+
+ return services;
+ }
+
+ public static IServiceCollection AddLookup(this IServiceCollection services)
+ where T : class, ILookup, IEntity
+ {
+ services.AddLookupService();
+ services.AddLookupCache();
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/src/Lookups/Models/Lookup.cs b/src/Lookups/Models/Lookup.cs
new file mode 100644
index 0000000..369ee43
--- /dev/null
+++ b/src/Lookups/Models/Lookup.cs
@@ -0,0 +1,40 @@
+using System.ComponentModel.DataAnnotations.Schema;
+using Avolutions.Baf.Core.Entity.Models;
+using Avolutions.Baf.Core.Localization;
+using Avolutions.Baf.Core.Lookups.Abstractions;
+using Avolutions.Baf.Core.Lookups.Extensions;
+
+namespace Avolutions.Baf.Core.Lookups.Models;
+
+public abstract class Lookup
+ : EntityBase, ILookup
+ where TTranslation : LookupTranslation, new()
+{
+ protected Lookup() { }
+
+ protected Lookup(bool createMissingTranslations)
+ {
+ if (createMissingTranslations)
+ {
+ CreateMissingTranslations();
+ }
+ }
+
+ public ICollection Translations { get; set; } = new List();
+
+ [NotMapped]
+ public string Value => Translations.Localized(t => t.Value);
+
+ public bool IsDefault { get; set; }
+
+ public void CreateMissingTranslations()
+ {
+ foreach (var language in LocalizationContext.AvailableLanguages)
+ {
+ if (!Translations.Any(t => t.Language == language))
+ {
+ Translations.Add(new TTranslation { Language = language });
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lookups/Models/LookupTranslation.cs b/src/Lookups/Models/LookupTranslation.cs
new file mode 100644
index 0000000..4d577fc
--- /dev/null
+++ b/src/Lookups/Models/LookupTranslation.cs
@@ -0,0 +1,12 @@
+using Avolutions.Baf.Core.Entity.Models;
+using Avolutions.Baf.Core.Lookups.Abstractions;
+
+namespace Avolutions.Baf.Core.Lookups.Models;
+
+public abstract class LookupTranslation
+ : EntityBase, ILookupTranslation
+{
+ public Guid ParentId { get; set; }
+ public string Language { get; set; } = string.Empty;
+ public string Value { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/Lookups/Services/LookupService.cs b/src/Lookups/Services/LookupService.cs
new file mode 100644
index 0000000..623fc09
--- /dev/null
+++ b/src/Lookups/Services/LookupService.cs
@@ -0,0 +1,125 @@
+using Avolutions.Baf.Core.Entity.Abstractions;
+using Avolutions.Baf.Core.Entity.Exceptions;
+using Avolutions.Baf.Core.Entity.Services;
+using Avolutions.Baf.Core.Localization;
+using Avolutions.Baf.Core.Lookups.Abstractions;
+using FluentValidation;
+using Microsoft.EntityFrameworkCore;
+
+namespace Avolutions.Baf.Core.Lookups.Services;
+
+public class LookupService : EntityService, ILookupService
+ where T : class, ILookup, IEntity
+ where TTranslation : class, ILookupTranslation
+{
+ private readonly ILookupCache? _cache;
+
+ public LookupService(
+ DbContext context,
+ ILookupCache? cache = null,
+ IValidator? validator = null) : base(context, validator)
+ {
+ _cache = cache;
+ }
+
+ public override async Task CreateAsync(T entity, CancellationToken cancellationToken = default)
+ {
+ if (!await DbSet.AnyAsync(cancellationToken))
+ {
+ entity.IsDefault = true;
+ }
+
+ var result = await base.CreateAsync(entity, cancellationToken);
+ await RefreshCacheAsync(cancellationToken);
+
+ return result;
+ }
+
+ public override async Task UpdateAsync(T entity, CancellationToken cancellationToken = default)
+ {
+ var result = await base.UpdateAsync(entity, cancellationToken);
+ await RefreshCacheAsync(cancellationToken);
+ return result;
+ }
+
+ public override async Task DeleteAsync(Guid id)
+ {
+ await base.DeleteAsync(id);
+ await RefreshCacheAsync();
+ }
+
+ public override async Task GetByIdAsync(Guid id)
+ {
+ return await GetByIdAsync(id, LocalizationContext.CurrentLanguage);
+ }
+
+ public async Task GetByIdAsync(Guid id, string language, CancellationToken cancellationToken = default)
+ {
+ return await DbSet
+ .Include(p => p.Translations.Where(t => t.Language == language))
+ .AsNoTracking()
+ .SingleOrDefaultAsync(p => p.Id == id, cancellationToken);
+ }
+
+ public override async Task> GetAllAsync(CancellationToken cancellationToken = default)
+ {
+ return await DbSet
+ .Include(p => p.Translations)
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetAllAsync(string language, CancellationToken cancellationToken = default)
+ {
+ return await DbSet
+ .Include(p => p.Translations.Where(t => t.Language == language))
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task SetDefaultAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ var exists = await DbSet.AnyAsync(e => e.Id == id, cancellationToken);
+ if (!exists)
+ {
+ throw new EntityNotFoundException(typeof(T), id);
+ }
+
+ var isAlreadyDefault = await DbSet.AnyAsync(e => e.Id == id && e.IsDefault, cancellationToken);
+ if (isAlreadyDefault)
+ {
+ return;
+ }
+
+ // Clear current default
+ await DbSet
+ .Where(q => q.IsDefault)
+ .ExecuteUpdateAsync(
+ q => q.SetProperty(x => x.IsDefault, false),
+ cancellationToken);
+
+ // Set new default
+ await DbSet
+ .Where(q => q.Id == id)
+ .ExecuteUpdateAsync(
+ q => q.SetProperty(x => x.IsDefault, true),
+ cancellationToken);
+
+ await RefreshCacheAsync(cancellationToken);
+ }
+
+ public Task GetDefaultAsync(CancellationToken cancellationToken = default)
+ {
+ return DbSet
+ .AsNoTracking()
+ .SingleAsync(p => p.IsDefault, cancellationToken);
+ }
+
+ private async Task RefreshCacheAsync(CancellationToken cancellationToken = default)
+ {
+ if (_cache is not null)
+ {
+ await _cache.RefreshAsync(cancellationToken);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Module/Extensions/ApplicationBuilderExtensions.cs b/src/Module/Extensions/ApplicationBuilderExtensions.cs
index 340313f..c34ea3a 100644
--- a/src/Module/Extensions/ApplicationBuilderExtensions.cs
+++ b/src/Module/Extensions/ApplicationBuilderExtensions.cs
@@ -33,6 +33,8 @@ public static WebApplication UseBafCore(this WebApplication app)
.GetResult(); // Blocking is OK here since startup is synchronous
}
+ app.UseRequestLocalization();
+
return app;
}
}
\ No newline at end of file
diff --git a/src/Reports/Services/ReportService.cs b/src/Reports/Services/ReportService.cs
index 43f068b..74dd186 100644
--- a/src/Reports/Services/ReportService.cs
+++ b/src/Reports/Services/ReportService.cs
@@ -1,7 +1,7 @@
using Avolutions.Baf.Core.Entity.Services;
using Avolutions.Baf.Core.Reports.Abstractions;
using Avolutions.Baf.Core.Reports.Models;
-using Avolutions.Baf.Core.Template.Abstractions;
+using Avolutions.Baf.Core.Template.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Playwright;
using NJsonSchema;
@@ -11,9 +11,9 @@ namespace Avolutions.Baf.Core.Reports.Services;
public class ReportService : EntityService
{
- private readonly ITemplateService _templateService;
+ private readonly HandlebarsTemplateService _templateService;
- public ReportService(DbContext context, ITemplateService templateService) : base(context)
+ public ReportService(DbContext context, HandlebarsTemplateService templateService) : base(context)
{
_templateService = templateService;
}
@@ -69,9 +69,9 @@ public async Task RenderPdfAsync(Report report, IReportModel model, Canc
{
ct.ThrowIfCancellationRequested();
- var contentHtml = await _templateService.RenderTemplateAsync(report.ContentHtml, model, ct);
- var headerHtml = await _templateService.RenderTemplateAsync(report.HeaderHtml, model, ct);
- var footerHtml = await _templateService.RenderTemplateAsync(report.FooterHtml, model, ct);
+ var contentHtml = await _templateService.ApplyModelToTemplateAsync(report.ContentHtml, model, ct);
+ var headerHtml = await _templateService.ApplyModelToTemplateAsync(report.HeaderHtml, model, ct);
+ var footerHtml = await _templateService.ApplyModelToTemplateAsync(report.FooterHtml, model, ct);
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
diff --git a/src/Resources/SharedResources.de.Designer.cs b/src/Resources/SharedResources.de.Designer.cs
index a1addd9..6ffdb6a 100644
--- a/src/Resources/SharedResources.de.Designer.cs
+++ b/src/Resources/SharedResources.de.Designer.cs
@@ -68,6 +68,15 @@ internal static string Action_Add {
}
}
+ ///
+ /// Looks up a localized string similar to {0} erfassen.
+ ///
+ internal static string Action_Capture {
+ get {
+ return ResourceManager.GetString("Action.Capture", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to {0} erstellen.
///
diff --git a/src/Resources/SharedResources.de.resx b/src/Resources/SharedResources.de.resx
index 15b0fca..aa60a93 100644
--- a/src/Resources/SharedResources.de.resx
+++ b/src/Resources/SharedResources.de.resx
@@ -57,4 +57,7 @@
ausführen
+
+ {0} erfassen
+
\ No newline at end of file
diff --git a/src/Template/Abstractions/ITemplateService.cs b/src/Template/Abstractions/ITemplateService.cs
index 23ced5a..80276ba 100644
--- a/src/Template/Abstractions/ITemplateService.cs
+++ b/src/Template/Abstractions/ITemplateService.cs
@@ -1,8 +1,6 @@
namespace Avolutions.Baf.Core.Template.Abstractions;
-public interface ITemplateService
+public interface ITemplateService
{
- Task RenderTemplateFileAsync(string templatePath, object model, CancellationToken ct);
-
- Task RenderTemplateAsync(string template, object model, CancellationToken ct);
+ Task ApplyModelToTemplateAsync(TTemplate template, object model, CancellationToken ct);
}
\ No newline at end of file
diff --git a/src/Template/Attributes/TemplateFieldAttribute.cs b/src/Template/Attributes/TemplateFieldAttribute.cs
new file mode 100644
index 0000000..35c17d4
--- /dev/null
+++ b/src/Template/Attributes/TemplateFieldAttribute.cs
@@ -0,0 +1,12 @@
+namespace Avolutions.Baf.Core.Template.Attributes;
+
+[AttributeUsage(AttributeTargets.Property)]
+public sealed class TemplateFieldAttribute : Attribute
+{
+ public string Name { get; }
+
+ public TemplateFieldAttribute(string name)
+ {
+ Name = name;
+ }
+}
\ No newline at end of file
diff --git a/src/Template/Services/HandlebarsTemplateService.cs b/src/Template/Services/HandlebarsTemplateService.cs
new file mode 100644
index 0000000..e313821
--- /dev/null
+++ b/src/Template/Services/HandlebarsTemplateService.cs
@@ -0,0 +1,13 @@
+using HandlebarsDotNet;
+
+namespace Avolutions.Baf.Core.Template.Services;
+
+public class HandlebarsTemplateService : TemplateService
+{
+ protected override Task ApplyValuesToTemplateAsync(string template, IDictionary values, CancellationToken ct)
+ {
+ var compiledTemplate = Handlebars.Compile(template);
+ var result = compiledTemplate(values);
+ return Task.FromResult(result);
+ }
+}
\ No newline at end of file
diff --git a/src/Template/Services/PdfTemplateService.cs b/src/Template/Services/PdfTemplateService.cs
new file mode 100644
index 0000000..0e03127
--- /dev/null
+++ b/src/Template/Services/PdfTemplateService.cs
@@ -0,0 +1,89 @@
+using PdfSharp;
+using PdfSharp.Fonts;
+using PdfSharp.Pdf;
+using PdfSharp.Pdf.AcroForms;
+using PdfSharp.Pdf.IO;
+
+namespace Avolutions.Baf.Core.Template.Services;
+
+public class PdfTemplateService : TemplateService
+{
+ private static bool _fontsConfigured;
+
+ public PdfTemplateService()
+ {
+ if (!_fontsConfigured && Capabilities.Build.IsCoreBuild)
+ {
+ GlobalFontSettings.UseWindowsFontsUnderWindows = true;
+ _fontsConfigured = true;
+ }
+ }
+
+ protected override Task ApplyValuesToTemplateAsync(Stream template, IDictionary values, CancellationToken ct)
+ {
+ using var templateBuffer = new MemoryStream();
+ template.CopyTo(templateBuffer);
+ var templateBytes = templateBuffer.ToArray();
+
+ using var inputStream = new MemoryStream(templateBytes);
+ using var document = PdfReader.Open(inputStream, PdfDocumentOpenMode.Modify);
+
+ var form = document.AcroForm;
+ if (form.Fields.Count == 0)
+ {
+ using var outputNoForm = new MemoryStream();
+ document.Save(outputNoForm);
+
+ return Task.FromResult(outputNoForm.ToArray());
+ }
+
+ if (!form.Elements.ContainsKey("/NeedAppearances"))
+ {
+ form.Elements.Add("/NeedAppearances", new PdfBoolean(true));
+ }
+ else
+ {
+ form.Elements["/NeedAppearances"] = new PdfBoolean(true);
+ }
+
+ ApplyValuesToFields(form, values);
+
+ using var output = new MemoryStream();
+ document.Save(output);
+ return Task.FromResult(output.ToArray());
+ }
+
+ private static void ApplyValuesToFields(PdfAcroForm form, IDictionary values)
+ {
+ var fields = form.Fields;
+
+ foreach (string fieldName in fields.DescendantNames)
+ {
+ if (!values.TryGetValue(fieldName, out var value))
+ {
+ var simpleName = GetSimpleName(fieldName);
+ if (!values.TryGetValue(simpleName, out value))
+ {
+ continue;
+ }
+ }
+
+ var field = fields[fieldName];
+ if (field is PdfTextField textField && !textField.ReadOnly)
+ {
+ textField.Value = new PdfString(value);
+ }
+ }
+ }
+
+ private static string GetSimpleName(string fieldName)
+ {
+ var lastDot = fieldName.LastIndexOf('.');
+ if (lastDot >= 0 && lastDot < fieldName.Length - 1)
+ {
+ return fieldName[(lastDot + 1)..];
+ }
+
+ return fieldName;
+ }
+}
\ No newline at end of file
diff --git a/src/Template/Services/TemplateService.cs b/src/Template/Services/TemplateService.cs
index 534e8e2..8c94104 100644
--- a/src/Template/Services/TemplateService.cs
+++ b/src/Template/Services/TemplateService.cs
@@ -1,33 +1,55 @@
-using Avolutions.Baf.Core.Template.Abstractions;
-using HandlebarsDotNet;
+using System.Reflection;
+using Avolutions.Baf.Core.Template.Abstractions;
+using Avolutions.Baf.Core.Template.Attributes;
namespace Avolutions.Baf.Core.Template.Services;
-public class TemplateService : ITemplateService
+public abstract class TemplateService : ITemplateService
{
- public async Task RenderTemplateFileAsync(string templatePath, object model, CancellationToken ct)
+ public Task ApplyModelToTemplateAsync(TTemplate template, object model, CancellationToken ct = default)
{
- ct.ThrowIfCancellationRequested();
+ ArgumentNullException.ThrowIfNull(template);
+ ArgumentNullException.ThrowIfNull(model);
- if (!Path.IsPathRooted(templatePath))
- {
- templatePath = Path.Combine(AppContext.BaseDirectory, templatePath);
- }
+ var values = BuildValueDictionary(model);
+
+ return ApplyValuesToTemplateAsync(template, values, ct);
+ }
+
+ protected abstract Task ApplyValuesToTemplateAsync(
+ TTemplate template,
+ IDictionary values,
+ CancellationToken ct);
+
+ protected Dictionary BuildValueDictionary(object model)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var type = model.GetType();
- if (!File.Exists(templatePath))
+ foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
- throw new FileNotFoundException($"Template not found: {templatePath}", templatePath);
+ if (!property.CanRead)
+ {
+ continue;
+ }
+
+ var fieldName = GetDocumentFieldName(property);
+ var value = property.GetValue(model);
+
+ result[fieldName] = value?.ToString() ?? string.Empty;
}
- var source = await File.ReadAllTextAsync(templatePath, ct);
-
- return await RenderTemplateAsync(source, model, ct);
+ return result;
}
-
- public async Task RenderTemplateAsync(string template, object model, CancellationToken ct)
+
+ protected static string GetDocumentFieldName(PropertyInfo property)
{
- var compiledTemplate = Handlebars.Compile(template);
-
- return await Task.FromResult(compiledTemplate(model));
+ var attribute = property.GetCustomAttribute();
+ if (attribute != null && !string.IsNullOrWhiteSpace(attribute.Name))
+ {
+ return attribute.Name;
+ }
+
+ return property.Name;
}
}
\ No newline at end of file
diff --git a/src/Template/Services/WordTemplateService.cs b/src/Template/Services/WordTemplateService.cs
new file mode 100644
index 0000000..8fbb727
--- /dev/null
+++ b/src/Template/Services/WordTemplateService.cs
@@ -0,0 +1,137 @@
+using DocumentFormat.OpenXml;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Wordprocessing;
+
+namespace Avolutions.Baf.Core.Template.Services;
+
+public class WordTemplateService : TemplateService
+{
+ protected override Task ApplyValuesToTemplateAsync(Stream template, IDictionary values, CancellationToken ct)
+ {
+ // Copy to a writable, seekable stream
+ var output = new MemoryStream();
+ template.CopyTo(output);
+ output.Position = 0;
+
+ using (var document = WordprocessingDocument.Open(output, true))
+ {
+ var body = document.MainDocumentPart?.Document.Body;
+ if (body != null)
+ {
+ ReplaceMergeFields(body, values);
+ }
+ }
+
+ output.Position = 0;
+
+ return Task.FromResult(output.ToArray());
+ }
+
+ private static void ReplaceMergeFields(OpenXmlElement root, IDictionary values)
+ {
+ foreach (var fieldCode in root.Descendants().ToList())
+ {
+ var instruction = fieldCode.Text;
+ if (string.IsNullOrWhiteSpace(instruction))
+ {
+ continue;
+ }
+
+ if (!instruction.Contains("MERGEFIELD", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var fieldName = ExtractMergeFieldName(instruction);
+ if (fieldName == null)
+ {
+ continue;
+ }
+
+ if (!values.TryGetValue(fieldName, out var replacement))
+ {
+ continue;
+ }
+
+ ReplaceComplexFieldResult(fieldCode, replacement);
+ }
+ }
+
+ private static string? ExtractMergeFieldName(string instruction)
+ {
+ const string tag = "MERGEFIELD";
+ var index = instruction.IndexOf(tag, StringComparison.OrdinalIgnoreCase);
+ if (index < 0)
+ {
+ return null;
+ }
+
+ var after = instruction.Substring(index + tag.Length).Trim();
+
+ // Remove flags like \* MERGEFORMAT, \b, etc.
+ var slashIndex = after.IndexOf('\\');
+ if (slashIndex >= 0)
+ {
+ after = after[..slashIndex];
+ }
+
+ // Cut at first whitespace
+ var spaceIndex = after.IndexOfAny([' ', '\r', '\n', '\t']);
+ if (spaceIndex >= 0)
+ {
+ after = after[..spaceIndex];
+ }
+
+ // Handle MERGEFIELD Name AND MERGEFIELD "Name"
+ after = after.Trim().Trim('"');
+
+ return string.IsNullOrWhiteSpace(after) ? null : after;
+ }
+
+ private static void ReplaceComplexFieldResult(FieldCode fieldCode, string replacement)
+ {
+ var current = fieldCode.Parent;
+ if (current == null)
+ {
+ return;
+ }
+
+ var resultTexts = new List();
+ var inResult = false;
+
+ while ((current = current.NextSibling()) != null)
+ {
+ var fieldChar = current.GetFirstChild();
+ if (fieldChar != null)
+ {
+ if (fieldChar.FieldCharType?.Value == FieldCharValues.Separate)
+ {
+ inResult = true;
+ continue;
+ }
+
+ if (fieldChar.FieldCharType?.Value == FieldCharValues.End)
+ {
+ break;
+ }
+ }
+
+ if (inResult)
+ {
+ resultTexts.AddRange(current.Descendants());
+ }
+ }
+
+ if (resultTexts.Count == 0)
+ {
+ return;
+ }
+
+ resultTexts[0].Text = replacement;
+
+ for (var i = 1; i < resultTexts.Count; i++)
+ {
+ resultTexts[i].Text = string.Empty;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Template/TemplateModule.cs b/src/Template/TemplateModule.cs
index f72a845..7ed5a64 100644
--- a/src/Template/TemplateModule.cs
+++ b/src/Template/TemplateModule.cs
@@ -1,5 +1,4 @@
using Avolutions.Baf.Core.Module.Abstractions;
-using Avolutions.Baf.Core.Template.Abstractions;
using Avolutions.Baf.Core.Template.Services;
using Microsoft.Extensions.DependencyInjection;
@@ -9,6 +8,8 @@ public class TemplateModule : IFeatureModule
{
public void Register(IServiceCollection services)
{
- services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
}
}
\ No newline at end of file