PlotDirector/PlotLine/Services/HelpService.cs
2026-06-04 20:00:13 +01:00

180 lines
5.9 KiB
C#

using Microsoft.AspNetCore.Hosting;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IHelpService
{
IReadOnlyList<HelpArticle> GetAllArticles();
HelpArticle? GetByContextKey(string contextKey);
HelpArticle? GetByRelativePath(string relativePath);
IReadOnlyList<HelpCoverageItem> GetCoverage();
}
public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
{
private readonly object cacheLock = new();
private IReadOnlyList<HelpArticle>? cachedArticles;
public IReadOnlyList<HelpArticle> GetAllArticles()
{
if (cachedArticles is not null)
{
return cachedArticles;
}
lock (cacheLock)
{
cachedArticles ??= LoadArticles();
return cachedArticles;
}
}
public HelpArticle? GetByContextKey(string contextKey)
{
if (string.IsNullOrWhiteSpace(contextKey))
{
return null;
}
return GetAllArticles().FirstOrDefault(x => string.Equals(x.ContextKey, contextKey, StringComparison.OrdinalIgnoreCase));
}
public HelpArticle? GetByRelativePath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return null;
}
var normalisedPath = relativePath.Replace('\\', '/');
return GetAllArticles().FirstOrDefault(x => string.Equals(x.RelativePath, normalisedPath, StringComparison.OrdinalIgnoreCase));
}
public IReadOnlyList<HelpCoverageItem> GetCoverage() =>
GetAllArticles()
.Select(x => new HelpCoverageItem
{
ContextKey = x.ContextKey,
FilePath = x.RelativePath,
IsValid = x.IsValid,
ValidationIssues = x.ValidationIssues
})
.OrderBy(x => x.ContextKey)
.ThenBy(x => x.FilePath)
.ToList();
private IReadOnlyList<HelpArticle> LoadArticles()
{
var articleRoot = Path.Combine(environment.ContentRootPath, "Help", "Articles");
if (!Directory.Exists(articleRoot))
{
return [];
}
return Directory
.EnumerateFiles(articleRoot, "*.md", SearchOption.AllDirectories)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.Select(path => ParseArticle(articleRoot, path))
.ToList();
}
private static HelpArticle ParseArticle(string articleRoot, string filePath)
{
var rawContent = File.ReadAllText(filePath);
var relativePath = Path.GetRelativePath(articleRoot, filePath).Replace('\\', '/');
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var markdownContent = rawContent;
var issues = new List<string>();
if (rawContent.StartsWith("---", StringComparison.Ordinal))
{
using var reader = new StringReader(rawContent);
_ = reader.ReadLine();
var frontMatterLines = new List<string>();
string? line;
var frontMatterClosed = false;
while ((line = reader.ReadLine()) is not null)
{
if (line.Trim() == "---")
{
frontMatterClosed = true;
break;
}
frontMatterLines.Add(line);
}
if (frontMatterClosed)
{
metadata = ParseFrontMatter(frontMatterLines);
markdownContent = reader.ReadToEnd().TrimStart();
}
else
{
issues.Add("Front matter was opened but not closed.");
}
}
else
{
issues.Add("Front matter is missing.");
}
var article = new HelpArticle
{
Title = GetValue(metadata, "title"),
Category = GetValue(metadata, "category"),
Tags = SplitTags(GetValue(metadata, "tags")),
ContextKey = GetValue(metadata, "contextKey"),
Micro = GetValue(metadata, "micro"),
QuickTitle = GetValue(metadata, "quickTitle"),
QuickSummary = GetValue(metadata, "quickSummary"),
Screenshot = GetValue(metadata, "screenshot"),
MarkdownContent = markdownContent,
RelativePath = relativePath
};
issues.AddRange(Validate(article));
article.ValidationIssues = issues;
return article;
}
private static Dictionary<string, string> ParseFrontMatter(IEnumerable<string> lines)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines)
{
var separatorIndex = line.IndexOf(':');
if (separatorIndex <= 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim().Trim('"');
metadata[key] = value;
}
return metadata;
}
private static IReadOnlyList<string> Validate(HelpArticle article)
{
var issues = new List<string>();
if (string.IsNullOrWhiteSpace(article.Title)) issues.Add("Title is required.");
if (string.IsNullOrWhiteSpace(article.ContextKey)) issues.Add("ContextKey is required.");
if (string.IsNullOrWhiteSpace(article.QuickTitle)) issues.Add("QuickTitle is required.");
if (string.IsNullOrWhiteSpace(article.QuickSummary)) issues.Add("QuickSummary is required.");
return issues;
}
private static string GetValue(IReadOnlyDictionary<string, string> metadata, string key) =>
metadata.TryGetValue(key, out var value) ? value : string.Empty;
private static IReadOnlyList<string> SplitTags(string tags) =>
string.IsNullOrWhiteSpace(tags)
? []
: tags.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
}