using Microsoft.AspNetCore.Hosting; using PlotLine.Models; using System.Globalization; using System.Text; namespace PlotLine.Services; public interface IHelpService { IReadOnlyList GetAllArticles(); HelpArticle? GetByContextKey(string contextKey); HelpArticle? GetByRelativePath(string relativePath); IReadOnlyList GetByCategory(string category); IReadOnlyList GetByCategorySlug(string categorySlug); string? GetCategoryBySlug(string categorySlug); IReadOnlyList GetCategories(); HelpArticle? GetBySlugs(string categorySlug, string articleSlug); IReadOnlyList Search(string query); IReadOnlyList GetRelatedArticles(HelpArticle article, int maxItems = 5); IReadOnlyList GetCoverage(); } public sealed class HelpService(IWebHostEnvironment environment) : IHelpService { private readonly object cacheLock = new(); private IReadOnlyList? cachedArticles; public IReadOnlyList 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 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 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 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 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 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 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 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(StringComparer.OrdinalIgnoreCase); var markdownContent = rawContent; var issues = new List(); if (rawContent.StartsWith("---", StringComparison.Ordinal)) { using var reader = new StringReader(rawContent); _ = reader.ReadLine(); var frontMatterLines = new List(); 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 ParseFrontMatter(IEnumerable lines) { var metadata = new Dictionary(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 Validate(HelpArticle article) { var issues = new List(); 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 metadata, string key) => metadata.TryGetValue(key, out var value) ? value : string.Empty; private static IReadOnlyList SplitTags(string tags) => string.IsNullOrWhiteSpace(tags) ? [] : tags.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); private static int SearchScore(HelpArticle article, IEnumerable 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 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 articles) { foreach (var group in articles.GroupBy(x => x.CategorySlug, StringComparer.OrdinalIgnoreCase)) { var used = new HashSet(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); }