268 lines
10 KiB
C#
268 lines
10 KiB
C#
using Markdig;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using QRRapidoApp.Models;
|
|
using QRRapidoApp.Models.ViewModels;
|
|
using YamlDotNet.Serialization;
|
|
using YamlDotNet.Serialization.NamingConventions;
|
|
|
|
namespace QRRapidoApp.Services
|
|
{
|
|
public class MarkdownService : IMarkdownService
|
|
{
|
|
private readonly IMemoryCache _cache;
|
|
private readonly IWebHostEnvironment _env;
|
|
private readonly ILogger<MarkdownService> _logger;
|
|
private readonly MarkdownPipeline _pipeline;
|
|
private readonly IDeserializer _yamlDeserializer;
|
|
private const string CACHE_KEY_PREFIX = "Tutorial_";
|
|
private const string CACHE_KEY_ALL = "AllTutorials_";
|
|
private const int CACHE_DURATION_HOURS = 1;
|
|
|
|
public MarkdownService(IMemoryCache cache, IWebHostEnvironment env, ILogger<MarkdownService> logger)
|
|
{
|
|
_cache = cache;
|
|
_env = env;
|
|
_logger = logger;
|
|
|
|
// Configure Markdig pipeline for advanced markdown features
|
|
_pipeline = new MarkdownPipelineBuilder()
|
|
.UseAdvancedExtensions()
|
|
.UseAutoLinks()
|
|
.UseEmojiAndSmiley()
|
|
.Build();
|
|
|
|
// Configure YAML deserializer
|
|
_yamlDeserializer = new DeserializerBuilder()
|
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
|
.IgnoreUnmatchedProperties()
|
|
.Build();
|
|
}
|
|
|
|
public async Task<ArticleViewModel?> GetArticleAsync(string slug, string culture)
|
|
{
|
|
var cacheKey = $"{CACHE_KEY_PREFIX}{culture}_{slug}";
|
|
|
|
// Try get from cache
|
|
if (_cache.TryGetValue(cacheKey, out ArticleViewModel? cachedArticle))
|
|
{
|
|
_logger.LogInformation("Article served from cache: {Slug} ({Culture})", slug, culture);
|
|
return cachedArticle;
|
|
}
|
|
|
|
try
|
|
{
|
|
var contentPath = Path.Combine(_env.ContentRootPath, "Content", "Tutoriais");
|
|
var fileName = $"{slug}.{culture}.md";
|
|
var filePath = Path.Combine(contentPath, fileName);
|
|
|
|
if (!File.Exists(filePath))
|
|
{
|
|
_logger.LogWarning("Article file not found: {FilePath}", filePath);
|
|
return null;
|
|
}
|
|
|
|
var fileContent = await File.ReadAllTextAsync(filePath);
|
|
var article = ParseMarkdownWithFrontmatter(fileContent, slug);
|
|
|
|
if (article == null)
|
|
{
|
|
_logger.LogError("Failed to parse article: {Slug} ({Culture})", slug, culture);
|
|
return null;
|
|
}
|
|
|
|
// Set file metadata
|
|
var fileInfo = new FileInfo(filePath);
|
|
article.LastModified = fileInfo.LastWriteTimeUtc;
|
|
article.Metadata.Culture = culture;
|
|
|
|
// Cache the article
|
|
var cacheOptions = new MemoryCacheEntryOptions()
|
|
.SetAbsoluteExpiration(TimeSpan.FromHours(CACHE_DURATION_HOURS));
|
|
|
|
_cache.Set(cacheKey, article, cacheOptions);
|
|
|
|
_logger.LogInformation("Article loaded and cached: {Slug} ({Culture})", slug, culture);
|
|
return article;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading article: {Slug} ({Culture})", slug, culture);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<List<ArticleMetadata>> GetAllArticlesAsync(string culture)
|
|
{
|
|
var cacheKey = $"{CACHE_KEY_ALL}{culture}";
|
|
|
|
// Try get from cache
|
|
if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cachedList))
|
|
{
|
|
_logger.LogInformation("Articles list served from cache ({Culture})", culture);
|
|
return cachedList ?? new List<ArticleMetadata>();
|
|
}
|
|
|
|
try
|
|
{
|
|
var articles = new List<ArticleMetadata>();
|
|
var contentPath = Path.Combine(_env.ContentRootPath, "Content", "Tutoriais");
|
|
|
|
if (!Directory.Exists(contentPath))
|
|
{
|
|
_logger.LogWarning("Tutoriais directory not found: {Path}", contentPath);
|
|
return articles;
|
|
}
|
|
|
|
var pattern = $"*.{culture}.md";
|
|
var files = Directory.GetFiles(contentPath, pattern);
|
|
|
|
foreach (var file in files)
|
|
{
|
|
try
|
|
{
|
|
var fileContent = await File.ReadAllTextAsync(file);
|
|
var slug = Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(file));
|
|
var article = ParseMarkdownWithFrontmatter(fileContent, slug);
|
|
|
|
if (article?.Metadata != null)
|
|
{
|
|
article.Metadata.Culture = culture;
|
|
article.Metadata.Slug = slug;
|
|
articles.Add(article.Metadata);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error parsing article file: {File}", file);
|
|
}
|
|
}
|
|
|
|
// Sort by date (newest first)
|
|
articles = articles.OrderByDescending(a => a.Date).ToList();
|
|
|
|
// Cache the list
|
|
var cacheOptions = new MemoryCacheEntryOptions()
|
|
.SetAbsoluteExpiration(TimeSpan.FromHours(CACHE_DURATION_HOURS));
|
|
|
|
_cache.Set(cacheKey, articles, cacheOptions);
|
|
|
|
_logger.LogInformation("Loaded {Count} articles for culture {Culture}", articles.Count, culture);
|
|
return articles;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading articles list for culture {Culture}", culture);
|
|
return new List<ArticleMetadata>();
|
|
}
|
|
}
|
|
|
|
public async Task<List<ArticleMetadata>> GetAllArticlesForSitemapAsync()
|
|
{
|
|
try
|
|
{
|
|
var allArticles = new List<ArticleMetadata>();
|
|
var contentPath = Path.Combine(_env.ContentRootPath, "Content", "Tutoriais");
|
|
|
|
if (!Directory.Exists(contentPath))
|
|
{
|
|
_logger.LogWarning("Tutoriais directory not found for sitemap: {Path}", contentPath);
|
|
return allArticles;
|
|
}
|
|
|
|
var files = Directory.GetFiles(contentPath, "*.md");
|
|
|
|
foreach (var file in files)
|
|
{
|
|
try
|
|
{
|
|
var fileContent = await File.ReadAllTextAsync(file);
|
|
var fileName = Path.GetFileName(file);
|
|
|
|
// Extract slug and culture from filename (e.g., "como-criar-qr.pt-BR.md")
|
|
var parts = fileName.Replace(".md", "").Split('.');
|
|
if (parts.Length < 2) continue;
|
|
|
|
var slug = parts[0];
|
|
var culture = parts[1];
|
|
|
|
var article = ParseMarkdownWithFrontmatter(fileContent, slug);
|
|
|
|
if (article?.Metadata != null)
|
|
{
|
|
article.Metadata.Culture = culture;
|
|
article.Metadata.Slug = slug;
|
|
var fileInfo = new FileInfo(file);
|
|
article.Metadata.LastMod = fileInfo.LastWriteTimeUtc;
|
|
allArticles.Add(article.Metadata);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error parsing article for sitemap: {File}", file);
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Loaded {Count} total articles for sitemap", allArticles.Count);
|
|
return allArticles;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading articles for sitemap");
|
|
return new List<ArticleMetadata>();
|
|
}
|
|
}
|
|
|
|
private ArticleViewModel? ParseMarkdownWithFrontmatter(string content, string slug)
|
|
{
|
|
try
|
|
{
|
|
// Extract frontmatter
|
|
if (!content.StartsWith("---"))
|
|
{
|
|
_logger.LogWarning("Article missing frontmatter: {Slug}", slug);
|
|
return null;
|
|
}
|
|
|
|
var endOfFrontmatter = content.IndexOf("---", 3);
|
|
if (endOfFrontmatter == -1)
|
|
{
|
|
_logger.LogWarning("Article frontmatter not closed: {Slug}", slug);
|
|
return null;
|
|
}
|
|
|
|
var frontmatterYaml = content.Substring(3, endOfFrontmatter - 3).Trim();
|
|
var markdownContent = content.Substring(endOfFrontmatter + 3).Trim();
|
|
|
|
// Parse YAML frontmatter
|
|
var metadata = _yamlDeserializer.Deserialize<ArticleMetadata>(frontmatterYaml);
|
|
|
|
if (metadata == null)
|
|
{
|
|
_logger.LogWarning("Failed to deserialize frontmatter: {Slug}", slug);
|
|
return null;
|
|
}
|
|
|
|
// Calculate reading time (average 200 words per minute)
|
|
var wordCount = markdownContent.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).Length;
|
|
metadata.ReadingTimeMinutes = Math.Max(1, (int)Math.Ceiling(wordCount / 200.0));
|
|
|
|
// Convert Markdown to HTML
|
|
var htmlContent = Markdown.ToHtml(markdownContent, _pipeline);
|
|
|
|
return new ArticleViewModel
|
|
{
|
|
Metadata = metadata,
|
|
HtmlContent = htmlContent,
|
|
Slug = slug,
|
|
LastModified = metadata.LastMod
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error parsing markdown for {Slug}", slug);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
}
|