Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ee3f4d4
Feat: Introduce World Rules management module
AnnaSasDev Jan 23, 2026
7e65445
Remove Article and World Rules management components.
AnnaSasDev Jan 23, 2026
4773c9c
Add interactive tab selection and tag change notifications
AnnaSasDev Jan 23, 2026
44cb718
Localize World Rules and Tags Editor components.
AnnaSasDev Jan 23, 2026
096b69a
Add `no-hover` class to `MudPaper` components and CSS for hover effec…
AnnaSasDev Jan 23, 2026
6fc8192
Add locale-specific draft saving and content loading to `LocalizedMar…
AnnaSasDev Jan 23, 2026
6466317
Localize `ArticleListTests` unit tests
AnnaSasDev Jan 23, 2026
414fa30
Update ArticleEditor.razor
AnnaSasDev Jan 24, 2026
e20c4d2
Migrate Articles and World Rules sections on the homepage to modular …
AnnaSasDev Jan 24, 2026
56e5015
Improve loading states for Articles and World Rules sections
AnnaSasDev Jan 24, 2026
4a7fb78
Refactor initialization logic for Articles and World Rules sections
AnnaSasDev Jan 24, 2026
fe4c04f
Refactor repository pattern for Articles and World Rules
AnnaSasDev Jan 24, 2026
7ef8c2f
Add `ContentBase` class and `IContentRepository` interface for unifie…
AnnaSasDev Jan 24, 2026
722cb4b
Refactor `CachedJsonRepository` to `ContentRepository` with enhanced …
AnnaSasDev Jan 24, 2026
c0cc10f
Add `IDevFileSystemCategoryManager` and support for file categorizati…
AnnaSasDev Jan 24, 2026
f6f94f2
Extend `Article` and `WorldRule` classes from `ContentBase` for share…
AnnaSasDev Jan 24, 2026
2d92ba0
Migrate `ArticleRepository` and `WorldRuleRepository` to `ContentRepo…
AnnaSasDev Jan 24, 2026
b11d479
Migrate `IArticleRepository` and `IWorldRuleRepository` to `IContentR…
AnnaSasDev Jan 24, 2026
a6ba3e3
Update `Article` and `WorldRule` models to replace `File` with `Markd…
AnnaSasDev Jan 24, 2026
59f33c5
Update repositories and components to use `Guid` for IDs and replace …
AnnaSasDev Jan 24, 2026
bf6b6ba
Refactor tests and validation rules to replace `File` with `MarkdownF…
AnnaSasDev Jan 24, 2026
95b35fb
Refactor file system services to replace `DevFileSystem*` components …
AnnaSasDev Jan 24, 2026
962d636
Remove `sealed` modifiers for multiple classes and adjust minor incon…
AnnaSasDev Jan 24, 2026
f19c459
Migrate and restructure tests to align with refactored file system an…
AnnaSasDev Jan 24, 2026
7e00692
Update `ServiceCollectionExtensionsTests` to include assertions for `…
AnnaSasDev Jan 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ indent_size = 4
csharp_indent_braces = false
csharp_new_line_before_members_in_object_initializers = false
csharp_new_line_before_open_brace = none
csharp_preferred_modifier_order = private, internal, protected, public, required, static, file, new, abstract, override, sealed, virtual, readonly, extern, volatile, unsafe, async:suggestion
csharp_preferred_modifier_order = private, internal, protected, public, required, static, file, new, abstract, override, , virtual, readonly, extern, volatile, unsafe, async:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_var_elsewhere = false:none
csharp_style_var_for_built_in_types = false:suggestion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace DirectiveAthena.Website.Services.Articles;
// Code
// ---------------------------------------------------------------------------------------------------------------------
[InjectableTransient<IValidator<IEnumerable<Article>>>]
public sealed class ArticleCollectionValidator : AbstractValidator<IEnumerable<Article>> {
public class ArticleCollectionValidator : AbstractValidator<IEnumerable<Article>> {
public ArticleCollectionValidator(IValidator<Article> articleValidator) {
RuleFor(articles => articles)
.NotNull();
Expand All @@ -20,24 +20,13 @@ public ArticleCollectionValidator(IValidator<Article> articleValidator) {
RuleFor(articles => articles)
.Must(HasUniqueIds)
.WithMessage("Duplicate IDs found!");

RuleFor(articles => articles)
.Must(HasUniqueFiles)
.WithMessage("Duplicate files found!");
}

// -----------------------------------------------------------------------------------------------------------------
// Methods
// -----------------------------------------------------------------------------------------------------------------
private static bool HasUniqueIds(IEnumerable<Article> articles) {
HashSet<string> ids = new(StringComparer.Ordinal);
HashSet<Guid> ids = [];
return articles.All(article => ids.Add(article.Id));

}

private static bool HasUniqueFiles(IEnumerable<Article> articles) {
HashSet<string> files = new(StringComparer.OrdinalIgnoreCase);
return articles.All(article => files.Add(article.File));

}
}
30 changes: 16 additions & 14 deletions src/DirectiveAthena.Website.Services/Articles/ArticleManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ namespace DirectiveAthena.Website.Services.Articles;
[InjectableScoped<IArticleManager>]
public class ArticleManager(
ILocalizationProvider localizationProvider,
IDevFileSystemManager devFs,
IContentStorageFactory storageFactory,
IResourceStorage resourceStorage,
HttpClient http,
IValidator<IEnumerable<Article>> validator,
IDevFileSystemPaths devFsPaths
IValidator<IEnumerable<Article>> validator
) : IArticleManager {
private readonly IContentStorage _storage = storageFactory.ForCategory(ContentCategory.Articles);

public string GetLocalizedTitle(Article article)
=> GetLocalizedValue(article.Title);

Expand All @@ -35,20 +37,21 @@ private string GetLocalizedValue(Dictionary<string, string> values) {

public string GetLocalizedFilePath(Article article) {
LocalizationInfo localization = localizationProvider.GetCurrentLocalization();
return $"content/articles/{localization.Code}/{article.File}";
return _storage.GetMarkdownContentPath(localization.Code, article.MarkdownFileName);
}

public async Task<string> GetRawMarkdownContentAsync(Article article, string locale, CancellationToken ct = default) {
try {
return await http.GetStringAsync($"content/articles/{locale}/{article.File}", ct);
string path = _storage.GetMarkdownContentPath(locale, article.MarkdownFileName);
return await http.GetStringAsync(path, ct);
}
catch {
return string.Empty;
}
}

public Article NewArticle() {
string id = Guid.NewGuid().ToString();
var id = Guid.CreateVersion7();
IReadOnlyCollection<LocalizationInfo> locals = localizationProvider.GetSupportedLocalizations();

Dictionary<string, string> titles = locals.ToDictionary(c => c.Code, _ => "New Post");
Expand All @@ -59,7 +62,6 @@ public Article NewArticle() {
Date = DateTime.Now.ToString("yyyy-MM-dd"),
Title = titles,
Summary = summaries,
File = $"{id}.md",
Tags = [],
Hidden = false
};
Expand All @@ -82,25 +84,25 @@ public async Task<Dictionary<string, string>> GenerateStubsAsync(Article article
c => c.Code,
c => $"# {article.Title.GetValueOrDefault(c.Code)}");

if (!writeToDisk || !devFs.IsLocalhost || !await devFs.HasAccessAsync() || !await devFs.VerifyPermissionAsync()) return stubs;
if (!writeToDisk || !_storage.IsLocalhost || !await _storage.HasAccessAsync() || !await _storage.VerifyPermissionAsync()) return stubs;

foreach (KeyValuePair<string, string> stub in stubs) {
await devFs.WriteFileAsync(devFsPaths.GetMarkdownPath(stub.Key, article.File), stub.Value);
string path = _storage.GetMarkdownDiskPath(stub.Key, article.MarkdownFileName);
await _storage.WriteFileAsync(path, stub.Value);
}

return stubs;
}

public async Task EnsureResxAsync(CancellationToken ct = default) {
if (!devFs.IsLocalhost || !await devFs.HasAccessAsync()) return;
if (!resourceStorage.IsLocalhost || !await resourceStorage.HasAccessAsync()) return;

foreach (string path in localizationProvider.GetSupportedLocalizations()
.Select(culture => devFsPaths.GetSharedResxPath(culture.Code))) {
string? content = await devFs.ReadFileAsync(path);
foreach (LocalizationInfo culture in localizationProvider.GetSupportedLocalizations()) {
string? content = await resourceStorage.ReadSharedResxAsync(culture.Code, ct);
if (content is not null) continue;

XDocument newResx = CreateNewResx();
await devFs.WriteFileAsync(path, newResx.ToString());
await resourceStorage.WriteSharedResxAsync(culture.Code, newResx.ToString(), ct);
}
}

Expand Down
73 changes: 9 additions & 64 deletions src/DirectiveAthena.Website.Services/Articles/ArticleRepository.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
// ---------------------------------------------------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------------------------------------------------
using System.Text.Json;
using CodeOfChaos.Extensions.DependencyInjection;
using System.Net.Http.Json;
using DirectiveAthena.Website.Services.FileSystem;
using DirectiveAthena.Website.Services.Localization;

namespace DirectiveAthena.Website.Services.Articles;
// ---------------------------------------------------------------------------------------------------------------------
Expand All @@ -14,70 +11,18 @@ namespace DirectiveAthena.Website.Services.Articles;
[InjectableScoped<IArticleRepository>]
public class ArticleRepository(
HttpClient http,
IDevFileSystemManager devFs,
ILocalizationProvider localizationProvider,
IDevFileSystemPaths devFsPaths
) : IArticleRepository {
private readonly SemaphoreSlim _lock = new(1, 1);
private Article[]? _articles;

private static readonly JsonSerializerOptions Options = new() {
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
IContentStorageFactory storageFactory
) : ContentRepository<Article>(
http,
storageFactory.ForCategory(ContentCategory.Articles)
), IArticleRepository {
protected override string IndexPath => Storage.IndexContentPath;

// -----------------------------------------------------------------------------------------------------------------
// Methods
// -----------------------------------------------------------------------------------------------------------------
public async ValueTask<IEnumerable<Article>> GetPostsAsync(bool includeHidden = false, CancellationToken ct = default) {
if (_articles is not null) return includeHidden ? _articles : _articles.Where(p => !p.Hidden);

await _lock.WaitAsync(ct);
try {
if (_articles is not null) return includeHidden ? _articles : _articles.Where(p => !p.Hidden);

_articles = await http.GetFromJsonAsync<Article[]>("content/articles/index.json", ct);
_articles ??= []; // if it is still null, set to an empty array
}
catch {
_articles = [];
}
finally {
_lock.Release();
}

return includeHidden ? _articles : _articles.Where(p => !p.Hidden);
}

public async ValueTask<Article?> GetPostByIdAsync(string id, CancellationToken ct = default) {
IEnumerable<Article> articles = await GetPostsAsync(includeHidden: true, ct);
return articles.FirstOrDefault(p => p.Id == id);
}

public string AsJsonString(IEnumerable<Article> articles)
=> JsonSerializer.Serialize(articles, Options);

public async Task<bool> SaveAsync(IEnumerable<Article> articles, CancellationToken ct = default) {
if (!devFs.IsLocalhost) return false;
if (!await devFs.VerifyPermissionAsync()) return false;

string json = AsJsonString(articles);
return await devFs.WriteFileAsync(devFsPaths.GetIndexPath(), json);
}

public async Task<bool> DeleteAsync(Article article, IEnumerable<Article> articles, CancellationToken ct = default) {
if (!devFs.IsLocalhost || !await devFs.HasAccessAsync()) return false;
if (!await devFs.VerifyPermissionAsync()) return false;

bool allDeleted = true;
foreach (string path in localizationProvider.GetSupportedLocalizations()
.Select(culture => devFsPaths.GetMarkdownPath(culture.Code, article.File))) {
bool success = await devFs.DeleteFileAsync(path);
if (!success) allDeleted = false;
}

if (!allDeleted) return false;

return await SaveAsync(articles, ct);
public async ValueTask<Article[]> GetAllWithoutHiddenAsync(CancellationToken ct = default) {
await EnsureCacheAsync(ct);
return ItemsById.Values.Where(p => !p.Hidden).ToArray();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,15 @@ namespace DirectiveAthena.Website.Services.Articles;
// Code
// ---------------------------------------------------------------------------------------------------------------------
[InjectableTransient<IValidator<Article>>]
public sealed class ArticleValidator : AbstractValidator<Article> {
public class ArticleValidator : AbstractValidator<Article> {
private readonly IReadOnlyCollection<LocalizationInfo> _localizations;

public ArticleValidator(ILocalizationProvider localizationProvider) {
_localizations = localizationProvider.GetSupportedLocalizations();

RuleFor(article => article.Id)
.NotEmpty()
.WithMessage("Some posts have missing Id or File!");

RuleFor(article => article.File)
.NotEmpty()
.WithMessage("Some posts have missing Id or File!");
.NotEqual(Guid.Empty)
.WithMessage("Some posts have missing Id!");

RuleFor(article => article)
.Must(HasLocalizedTitles)
Expand Down
145 changes: 145 additions & 0 deletions src/DirectiveAthena.Website.Services/ContentRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// ---------------------------------------------------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------------------------------------------------
using DirectiveAthena.Website.Services.FileSystem;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;

namespace DirectiveAthena.Website.Services;
// ---------------------------------------------------------------------------------------------------------------------
// Code
// ---------------------------------------------------------------------------------------------------------------------
public abstract class ContentRepository<T>(HttpClient http, IContentStorage contentStorage) : IContentRepository<T>
where T : ContentBase {
private readonly SemaphoreSlim _lock = new(1, 1);
protected IContentStorage Storage { get; } = contentStorage;
protected ImmutableDictionary<Guid, T> ItemsById { get; private set; } = ImmutableDictionary<Guid, T>.Empty;
private bool _hasLoaded;
private EntityTagHeaderValue? _etag;
private DateTimeOffset? _lastModifiedUtc;
private DateTimeOffset? _lastRefreshUtc;
private readonly TimeSpan CacheRefreshWindow = TimeSpan.FromMinutes(5);
private readonly TimeSpan DevRefreshWindow = TimeSpan.FromSeconds(5);

protected abstract string IndexPath { get; }

private readonly JsonSerializerOptions _jsonSerializerOptions = new() {
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

// -----------------------------------------------------------------------------------------------------------------
// CRUD Methods
// -----------------------------------------------------------------------------------------------------------------
public async ValueTask<T[]> GetAllAsync(CancellationToken ct = default) {
await EnsureCacheAsync(ct);
return ItemsById.Values.ToArray();
}

public async ValueTask<T?> GetByIdAsync(Guid id, CancellationToken ct = default) {
await EnsureCacheAsync(ct);
return ItemsById.GetValueOrDefault(id);
}

public async ValueTask<bool> SaveAsync(IEnumerable<T> items, CancellationToken ct = default) {
if (!Storage.IsLocalhost) return false;
if (!await Storage.VerifyPermissionAsync(ct)) return false;

string json = await AsJsonStringAsync(items, ct);
bool success = await Storage.WriteIndexAsync(json, ct);
return success;
}

public async ValueTask<bool> DeleteByIdAsync(Guid id, CancellationToken ct = default) {
_ = ct;
if (!Storage.IsLocalhost || !await Storage.HasAccessAsync(ct)) return false;
if (!await Storage.VerifyPermissionAsync(ct)) return false;

await EnsureCacheAsync(ct);
if (!ItemsById.TryGetValue(id, out T? item)) return false;

bool allDeleted = await Storage.DeleteLocalizedFilesAsync(item.MarkdownFileName, ct);
if (!allDeleted) return false;

T[] updatedItems = ItemsById.Remove(id).Values.ToArray();
return await SaveAsync(updatedItems, ct);
}

public async ValueTask<string> GetAsJsonStringAsync(CancellationToken ct = default) {
await EnsureCacheAsync(ct);
return await AsJsonStringAsync(ItemsById.Values, ct);
}

// -----------------------------------------------------------------------------------------------------------------
// Methods
// -----------------------------------------------------------------------------------------------------------------
private async ValueTask<string> AsJsonStringAsync(IEnumerable<T> items, CancellationToken ct = default) {
await using MemoryStream stream = new();
await JsonSerializer.SerializeAsync(stream, items, _jsonSerializerOptions, ct);
return Encoding.UTF8.GetString(stream.ToArray());
}

protected async Task EnsureCacheAsync(CancellationToken ct) {
DateTimeOffset now = DateTimeOffset.UtcNow;
if (_hasLoaded && !ShouldRefresh(now)) return;

await _lock.WaitAsync(ct);
try {
now = DateTimeOffset.UtcNow;
if (_hasLoaded && !ShouldRefresh(now)) return;

await RefreshCacheAsync(now, ct);
}
catch {
ItemsById = ImmutableDictionary<Guid, T>.Empty;
_hasLoaded = true;
_etag = null;
_lastModifiedUtc = null;
_lastRefreshUtc = now;
}
finally {
_lock.Release();
}
}

private bool ShouldRefresh(DateTimeOffset now)
=> !_hasLoaded || _lastRefreshUtc is null || now - _lastRefreshUtc.Value > GetRefreshWindow();

private TimeSpan GetRefreshWindow()
=> Storage.IsLocalhost ? DevRefreshWindow : CacheRefreshWindow;

private async Task RefreshCacheAsync(DateTimeOffset now, CancellationToken ct) {
using HttpRequestMessage request = new(HttpMethod.Get, IndexPath);
if (_etag is not null) request.Headers.IfNoneMatch.Add(_etag);
else if (_lastModifiedUtc is not null) request.Headers.IfModifiedSince = _lastModifiedUtc;

using HttpResponseMessage response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
if (response.StatusCode == HttpStatusCode.NotModified && _hasLoaded) {
_lastRefreshUtc = now;
if (response.Headers.ETag is not null) _etag = response.Headers.ETag;
if (response.Content.Headers.LastModified is not null) _lastModifiedUtc = response.Content.Headers.LastModified;
return;
}

response.EnsureSuccessStatusCode();
T[]? items = await response.Content.ReadFromJsonAsync<T[]>(cancellationToken: ct);
ItemsById = BuildIndex(items ?? []);
_hasLoaded = true;
_etag = response.Headers.ETag;
_lastModifiedUtc = response.Content.Headers.LastModified;
_lastRefreshUtc = now;
}

private static ImmutableDictionary<Guid, T> BuildIndex(IEnumerable<T> items) {
ImmutableDictionary<Guid, T>.Builder builder = ImmutableDictionary.CreateBuilder<Guid, T>();
foreach (T item in items) {
builder[item.Id] = item;
}

return builder.ToImmutable();
}
}
Loading