using System.ComponentModel.DataAnnotations; using System.Text; using System.Text.RegularExpressions; using Wave.Utilities; namespace Wave.Data; public enum ArticleStatus { Draft = 0, InReview = 1, Published = 2 } public class ArticleHeading { [Key] public int Id { get; set; } public required int Order { get; set; } [MaxLength(128)] public required string Label { get; set; } [MaxLength(256)] public required string Anchor { get; set; } } // TODO:: Add tags for MVP ? // TODO:: Archive System (Notice / Redirect to new content?) (Deprecation date?) public partial class Article : ISoftDelete { [Key] public Guid Id { get; set; } public bool IsDeleted { get; set; } // Computed public bool CanBePublic { get; set; } [MaxLength(256)] public required string Title { get; set; } // ReSharper disable thrice EntityFramework.ModelValidation.UnlimitedStringLength public required string Body { get; set; } public string BodyHtml { get; set; } = string.Empty; public string BodyPlain { get; set; } = string.Empty; [MaxLength(64)] public string Slug { get; set; } = string.Empty; public required ApplicationUser Author { get; set; } public ApplicationUser? Reviewer { get; set; } public ArticleStatus Status { get; set; } public DateTimeOffset CreationDate { get; set; } = DateTimeOffset.Now; public DateTimeOffset PublishDate { get; set; } = DateTimeOffset.MaxValue; public DateTimeOffset? LastModified { get; set; } /// /// Returns LastModified if it's after the articles PublishDate, otherwise gives you the PublishDate /// public DateTimeOffset LastPublicChange => LastModified > PublishDate ? LastModified.Value : PublishDate; public IList Categories { get; } = []; public IList Images { get; } = []; public IList Headings { get; } = []; public void UpdateSlug(string? potentialNewSlug = null) { if (!string.IsNullOrWhiteSpace(potentialNewSlug) && Uri.IsWellFormedUriString(potentialNewSlug, UriKind.Relative)) { if (potentialNewSlug.Length > 64) potentialNewSlug = potentialNewSlug[..64]; Slug = potentialNewSlug; return; } // if (string.IsNullOrWhiteSpace(potentialNewSlug) && !string.IsNullOrWhiteSpace(Slug)) return; string baseSlug = string.IsNullOrWhiteSpace(potentialNewSlug) ? Title.ToLower() : potentialNewSlug; { baseSlug = Regex.Replace(Uri.EscapeDataString(Encoding.ASCII.GetString( Encoding.Convert( Encoding.UTF8, Encoding.GetEncoding( Encoding.ASCII.EncodingName, new EncoderReplacementFallback(string.Empty), new DecoderExceptionFallback()), Encoding.UTF8.GetBytes(baseSlug)) ).Replace("-", "+").Replace(" ", "-")), @"(%[\dA-F]{2})", string.Empty); if (baseSlug.Length > 64) baseSlug = baseSlug[..64]; Slug = baseSlug; return; } baseSlug = baseSlug.ToLowerInvariant()[..Math.Min(64, baseSlug.Length)]; string slug = Uri.EscapeDataString(baseSlug); // I hate my life int escapeTrimOvershoot = 0; if (slug.Length > 64) { // Escape sequences come with a % and two hex digits, there may be up to 3 of such sequences // per character escaping ('?' has %3F, but € has %E2%82%AC), so we need to find the last group // of such an escape parade and see if it's going over by less than 9, because then we need to // remove more characters in the truncation, or we end up with a partial escape sequence.. parade escapeTrimOvershoot = 64 - Regex.Match(slug, @"(?(%[a-fA-F\d][a-fA-F\d])+)", RegexOptions.None | RegexOptions.ExplicitCapture) .Groups.Values.Last(g => g.Index < 64).Index; if (escapeTrimOvershoot > 9) escapeTrimOvershoot = 0; } Slug = slug[..Math.Min(slug.Length, 64 - escapeTrimOvershoot)]; } public void UpdateBody() { BodyHtml = MarkdownUtilities.Parse(Body).Trim(); BodyPlain = HtmlUtilities.GetPlainText(BodyHtml).Trim(); Headings.Clear(); var headings = HeadingsRegex().Matches(BodyHtml); foreach(Match match in headings) { string label = match.Groups["Label"].Value; string anchor = match.Groups["Anchor"].Value; var h = new ArticleHeading { Order = match.Index * 10 + int.Parse(match.Groups["Level"].Value), Label = label[..Math.Min(128, label.Length)], Anchor = anchor[..Math.Min(256, anchor.Length)] }; Headings.Add(h); } } [GeneratedRegex("[1-6]).*id=\"(?.+)\".*>(?