PlotDirector/PlotLine/Services/HelpService.cs
2026-06-24 10:09:07 +01:00

397 lines
14 KiB
C#

using Microsoft.AspNetCore.Hosting;
using PlotLine.Models;
using System.Globalization;
using System.Text;
namespace PlotLine.Services;
public interface IHelpService
{
IReadOnlyList<HelpArticle> GetAllArticles();
HelpArticle? GetByContextKey(string contextKey);
HelpArticle? GetByRelativePath(string relativePath);
IReadOnlyList<HelpArticle> GetByCategory(string category);
IReadOnlyList<HelpArticle> GetByCategorySlug(string categorySlug);
string? GetCategoryBySlug(string categorySlug);
IReadOnlyList<string> GetCategories();
HelpArticle? GetBySlugs(string categorySlug, string articleSlug);
IReadOnlyList<HelpArticle> Search(string query);
IReadOnlyList<HelpArticle> GetRelatedArticles(HelpArticle article, int maxItems = 5);
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<HelpArticle> GetByCategory(string category)
{
if (string.IsNullOrWhiteSpace(category))
{
return [];
}
return GetAllArticles()
.Where(x => string.Equals(x.Category, category, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.Title)
.ToList();
}
public IReadOnlyList<HelpArticle> GetByCategorySlug(string categorySlug)
{
if (string.IsNullOrWhiteSpace(categorySlug))
{
return [];
}
return GetAllArticles()
.Where(x => string.Equals(x.CategorySlug, categorySlug, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.Title)
.ToList();
}
public string? GetCategoryBySlug(string categorySlug)
{
if (string.IsNullOrWhiteSpace(categorySlug))
{
return null;
}
return GetAllArticles()
.Select(x => x.Category)
.FirstOrDefault(category => string.Equals(Slugify(category), categorySlug, StringComparison.OrdinalIgnoreCase));
}
public IReadOnlyList<string> GetCategories() =>
GetAllArticles()
.Where(x => !string.IsNullOrWhiteSpace(x.Category))
.Select(x => x.Category)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
.ToList();
public HelpArticle? GetBySlugs(string categorySlug, string articleSlug)
{
if (string.IsNullOrWhiteSpace(categorySlug) || string.IsNullOrWhiteSpace(articleSlug))
{
return null;
}
return GetAllArticles().FirstOrDefault(x =>
string.Equals(x.CategorySlug, categorySlug, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Slug, articleSlug, StringComparison.OrdinalIgnoreCase));
}
public IReadOnlyList<HelpArticle> Search(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
var terms = query.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
return GetAllArticles()
.Select(article => new { Article = article, Score = SearchScore(article, terms) })
.Where(x => x.Score > 0)
.OrderByDescending(x => x.Score)
.ThenBy(x => x.Article.Title)
.Select(x => x.Article)
.ToList();
}
public IReadOnlyList<HelpArticle> GetRelatedArticles(HelpArticle article, int maxItems = 5)
{
var tags = article.Tags.Select(x => x.ToLowerInvariant()).ToHashSet();
return GetAllArticles()
.Where(x => !string.Equals(x.ContextKey, article.ContextKey, StringComparison.OrdinalIgnoreCase))
.Select(x => new
{
Article = x,
Score = (string.Equals(x.Category, article.Category, StringComparison.OrdinalIgnoreCase) ? 2 : 0)
+ x.Tags.Count(tag => tags.Contains(tag.ToLowerInvariant()))
})
.Where(x => x.Score > 0)
.OrderByDescending(x => x.Score)
.ThenBy(x => x.Article.Title)
.Take(maxItems)
.Select(x => x.Article)
.ToList();
}
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 [];
}
var articles = Directory
.EnumerateFiles(articleRoot, "*.md", SearchOption.AllDirectories)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.Select(path => ParseArticle(articleRoot, path))
.ToList();
EnsureUniqueArticleSlugs(articles);
return articles;
}
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"),
CategorySlug = Slugify(GetValue(metadata, "category")),
Tags = SplitTags(GetValue(metadata, "tags")),
ContextKey = GetValue(metadata, "contextKey"),
Slug = GetArticleSlug(metadata, relativePath),
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.");
if (string.IsNullOrWhiteSpace(article.CategorySlug)) issues.Add("Category slug could not be generated.");
if (string.IsNullOrWhiteSpace(article.Slug)) issues.Add("Article slug could not be generated.");
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);
private static int SearchScore(HelpArticle article, IEnumerable<string> terms)
{
var score = 0;
foreach (var term in terms)
{
if (Contains(article.Title, term)) score += 8;
if (Contains(article.Category, term)) score += 4;
if (article.Tags.Any(tag => Contains(tag, term))) score += 5;
if (Contains(article.QuickSummary, term)) score += 3;
if (Contains(article.MarkdownContent, term)) score += 1;
}
return score;
}
private static string GetArticleSlug(IReadOnlyDictionary<string, string> metadata, string relativePath)
{
var explicitSlug = Slugify(GetValue(metadata, "slug"));
if (!string.IsNullOrWhiteSpace(explicitSlug))
{
return explicitSlug;
}
var titleSlug = Slugify(GetValue(metadata, "title"));
if (!string.IsNullOrWhiteSpace(titleSlug))
{
return titleSlug;
}
var contextKey = GetValue(metadata, "contextKey");
var finalContextSegment = contextKey.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).LastOrDefault();
var contextSlug = Slugify(finalContextSegment ?? string.Empty);
if (!string.IsNullOrWhiteSpace(contextSlug))
{
return contextSlug;
}
return Slugify(Path.GetFileNameWithoutExtension(relativePath));
}
private static void EnsureUniqueArticleSlugs(IReadOnlyList<HelpArticle> articles)
{
foreach (var group in articles.GroupBy(x => x.CategorySlug, StringComparer.OrdinalIgnoreCase))
{
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var article in group.OrderBy(x => x.RelativePath, StringComparer.OrdinalIgnoreCase))
{
var baseSlug = string.IsNullOrWhiteSpace(article.Slug) ? "article" : article.Slug;
var slug = baseSlug;
if (used.Contains(slug))
{
var contextSuffix = Slugify(article.ContextKey.Replace('.', ' '));
slug = string.IsNullOrWhiteSpace(contextSuffix) ? baseSlug : $"{baseSlug}-{contextSuffix}";
}
var sequence = 2;
var uniqueSlug = slug;
while (!used.Add(uniqueSlug))
{
uniqueSlug = $"{slug}-{sequence}";
sequence++;
}
article.Slug = uniqueSlug;
}
}
}
public static string Slugify(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalised = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder();
var pendingSeparator = false;
foreach (var character in normalised)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
if (char.IsLetterOrDigit(character))
{
if (pendingSeparator && builder.Length > 0)
{
builder.Append('-');
}
builder.Append(char.ToLowerInvariant(character));
pendingSeparator = false;
continue;
}
pendingSeparator = builder.Length > 0;
}
return builder.ToString();
}
private static bool Contains(string? source, string value) =>
!string.IsNullOrWhiteSpace(source)
&& source.Contains(value, StringComparison.OrdinalIgnoreCase);
}