398 lines
14 KiB
C#
398 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,
|
|
LastModifiedUtc = File.GetLastWriteTimeUtc(filePath)
|
|
};
|
|
|
|
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);
|
|
}
|