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