Wave/Wave/Data/Article.cs
Mia Rose Winter 1d55ab23f0
Implemented client side article editor (#6)
* started implementing article API, missing lots of tests to validate feature

* made tests more pretty

* re-structured tests

* refactored dto contracts

* tested and fixed updating categories

* added permission tests, fixed bug in Permissions system

* added data validation tests for update article

* refactored repository interface

* Added ArticleView dto, fixed bug in requesting articles over repository

* updated dependencies

* optimized program.cs, added repo service

* Removed all interactivity from ArticleEditor, merged files

* added vite, tailwind working, dev server is not, js is not yet

* added fontsource for font management using vite's bundling

* moved vite output to wwwroot/dist
reorganized stuff that will never need processing or needs to be at site root

* fixed heading font weight not being 700 anymore

* implemented react in ArticleEditor

* added article status steps to react component
noticed I need to figure out client side localization

* fixed vite dev server thingies, tailwind and react refresh works now

* added article form skeletton to react

* more editor implementations

* minor typescript fixes

* implemented proper editor functions

* added all missing toolbar buttons

* fixed error, made open article work

* improved article editor structure

* implemented article editor taking id from the url

* Implemented categories endpoint

* implemented categories in article editor

* fixed minor TS issues

* implemented localization in article editor

* completed localization

* implemented loading selected categories

* minor code improvements and maybe a regex fix

* fixed bug with not getting unpublished articles

* implemented form state

* fixed validation issues

* implemented saving (missing creation)

* fixed minor bug with status display

* organized models

* added live markdown preview (incomplete)

* fixed issues in article create api endpoint

* improved article saving, implemented creating

* fixed publish date not being set correctly when creating article

* fixed slugs once more

* added run config for production (without vite dev)

* removed unused code

* updated dockerfile to build Assets

* fixed slug generation

* updated tests to validate new slug generator

* savsdSACAVSD

* fixed validation issues and tests
2024-06-18 09:09:47 +02:00

129 lines
4.4 KiB
C#

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; }
/// <summary>
/// Returns LastModified if it's after the articles PublishDate, otherwise gives you the PublishDate
/// </summary>
public DateTimeOffset LastPublicChange => LastModified > PublishDate ? LastModified.Value : PublishDate;
public IList<Category> Categories { get; } = [];
public IList<ArticleImage> Images { get; } = [];
public IList<ArticleHeading> 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,
@"(?<escape>(%[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("<h(?<Level>[1-6]).*id=\"(?<Anchor>.+)\".*>(?<Label>.+)</h[1-6]>")]
private static partial Regex HeadingsRegex();
}