QrRapido/Services/MarkdownService.cs
Ricardo Carneiro 8b3da7cb0a
All checks were successful
Deploy QR Rapido / test (push) Successful in 3m59s
Deploy QR Rapido / build-and-push (push) Successful in 12m31s
Deploy QR Rapido / deploy-staging (push) Has been skipped
Deploy QR Rapido / deploy-production (push) Successful in 2m17s
feat: Criação de tutoriais e remoçaõ de anuncios.
2025-10-18 23:18:12 -03:00

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;
}
}
}
}