From 1e10d41cada8fdeea7a51a679a40c14ccfd609cf Mon Sep 17 00:00:00 2001 From: Mia Winter Date: Wed, 28 Feb 2024 12:20:22 +0100 Subject: [PATCH] Implemented custom slugs for articles --- Wave/Components/ArticleLink.razor | 6 +- Wave/Components/Pages/ArticleView.razor | 6 +- .../Pages/Partials/ArticleEditorPartial.razor | 37 +- Wave/Data/ApplicationDbContext.cs | 1 + Wave/Data/Article.cs | 3 + ...40228110154_ArticleCustomSlugs.Designer.cs | 637 ++++++++++++++++++ .../20240228110154_ArticleCustomSlugs.cs | 26 + .../ApplicationDbContextModelSnapshot.cs | 7 + Wave/Utilities/ArticleUtilities.cs | 7 +- 9 files changed, 718 insertions(+), 12 deletions(-) create mode 100644 Wave/Data/Migrations/postgres/20240228110154_ArticleCustomSlugs.Designer.cs create mode 100644 Wave/Data/Migrations/postgres/20240228110154_ArticleCustomSlugs.cs diff --git a/Wave/Components/ArticleLink.razor b/Wave/Components/ArticleLink.razor index 29daf7c..8e3db03 100644 --- a/Wave/Components/ArticleLink.razor +++ b/Wave/Components/ArticleLink.razor @@ -1,6 +1,7 @@ @using Wave.Data @using System.Net @using System.Web +@using Wave.Utilities @ChildContent @@ -13,10 +14,7 @@ public RenderFragment? ChildContent { get; set; } private string TitleEncoded => Uri.EscapeDataString(Article.Title.ToLowerInvariant()).Replace("-", "+").Replace("%20", "-"); - private string Link => - Article.PublishDate.Year >= 9999 - ? $"/article/{Article.Id}" - : $"/{Article.PublishDate.Year}/{Article.PublishDate.Month:D2}/{Article.PublishDate.Day:D2}/{TitleEncoded}"; + private string Link => ArticleUtilities.GenerateArticleLink(Article, null); [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } diff --git a/Wave/Components/Pages/ArticleView.razor b/Wave/Components/Pages/ArticleView.razor index a8982bf..ac991aa 100644 --- a/Wave/Components/Pages/ArticleView.razor +++ b/Wave/Components/Pages/ArticleView.razor @@ -5,6 +5,7 @@ @using System.Security.Claims @using System.Diagnostics.CodeAnalysis @using System.Globalization +@using System.Net @using Microsoft.AspNetCore.Identity @using Microsoft.Extensions.Options @using Wave.Services @@ -176,12 +177,13 @@ .FirstOrDefault(a => a.Id == Id); } else if (Date is { } date && Title is { } title) { using var context = ContextFactory.CreateDbContext(); - Article = context.Set
() + string? slug = TitleEncoded == null ? null : Uri.EscapeDataString(TitleEncoded); + Article ??= context.Set
() .IgnoreQueryFilters().Where(a => !a.IsDeleted) .Include(a => a.Author) .Include(a => a.Reviewer) .Include(a => a.Categories) - .FirstOrDefault(a => a.PublishDate.Date == date.Date && a.Title.ToLower() == title); + .FirstOrDefault(a => a.PublishDate.Date == date.Date && (slug != null && a.Slug == slug || a.Title.ToLower() == title)); } } diff --git a/Wave/Components/Pages/Partials/ArticleEditorPartial.razor b/Wave/Components/Pages/Partials/ArticleEditorPartial.razor index 75b0dce..c548aa0 100644 --- a/Wave/Components/Pages/Partials/ArticleEditorPartial.razor +++ b/Wave/Components/Pages/Partials/ArticleEditorPartial.razor @@ -1,6 +1,7 @@ @using Wave.Data @using System.ComponentModel.DataAnnotations @using System.Diagnostics.CodeAnalysis +@using System.Net @using Microsoft.AspNetCore.Identity @using Microsoft.EntityFrameworkCore @using Wave.Utilities @@ -24,11 +25,22 @@ - - -
+ + + + + + @if (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) { + + } else { + + } + + @if (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) { @if (Article.Id != Guid.Empty) { - + @Localizer["ViewArticle_Label"] } @@ -247,6 +259,7 @@ if (article is not null) { Model.Id ??= article.Id; Model.Title ??= article.Title; + Model.Slug ??= article.Slug; Model.Body ??= article.Body; Model.PublishDate ??= article.PublishDate.LocalDateTime; Model.Categories ??= article.Categories.Select(c => c.Id).ToArray(); @@ -267,6 +280,18 @@ if (Model.PublishDate is not null && (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow)) Article.PublishDate = Model.PublishDate.Value; + if (Article.Status is ArticleStatus.Published && Article.PublishDate < DateTimeOffset.Now) { + // can't change slugs when the article is public + } else if (!string.IsNullOrWhiteSpace(Model.Slug)) { + Article.Slug = WebUtility.UrlEncode(Model.Slug); + } else if (string.IsNullOrWhiteSpace(Article.Slug)) { + Article.Slug = Uri.EscapeDataString(Article.Title.ToLowerInvariant()) + .Replace("-", "+") + .Replace("%20", "-"); + Article.Slug = Article.Slug[..Math.Min(64, Article.Slug.Length)]; + Model.Slug = Article.Slug; + } + Article.LastModified = DateTimeOffset.UtcNow; Article.BodyHtml = MarkdownUtilities.Parse(Article.Body); Article.BodyPlain = HtmlUtilities.GetPlainText(Article.BodyHtml); @@ -361,6 +386,8 @@ [Required(AllowEmptyStrings = false), MaxLength(256)] public string? Title { get; set; } + [MaxLength(64)] + public string? Slug { get; set; } [Required(AllowEmptyStrings = false)] public string? Body { get; set; } diff --git a/Wave/Data/ApplicationDbContext.cs b/Wave/Data/ApplicationDbContext.cs index f58ee3a..a94554a 100644 --- a/Wave/Data/ApplicationDbContext.cs +++ b/Wave/Data/ApplicationDbContext.cs @@ -37,6 +37,7 @@ public class ApplicationDbContext(DbContextOptions options article.HasKey(a => a.Id); article.Property(a => a.Title) .IsRequired().HasMaxLength(256); + article.Property(a => a.Slug).HasMaxLength(64).IsRequired().HasDefaultValue(""); article.HasOne(a => a.Author).WithMany(a => a.Articles) .IsRequired().OnDelete(DeleteBehavior.Cascade); diff --git a/Wave/Data/Article.cs b/Wave/Data/Article.cs index 237803e..be6b496 100644 --- a/Wave/Data/Article.cs +++ b/Wave/Data/Article.cs @@ -22,6 +22,9 @@ public class Article : ISoftDelete { 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; } diff --git a/Wave/Data/Migrations/postgres/20240228110154_ArticleCustomSlugs.Designer.cs b/Wave/Data/Migrations/postgres/20240228110154_ArticleCustomSlugs.Designer.cs new file mode 100644 index 0000000..55c5d71 --- /dev/null +++ b/Wave/Data/Migrations/postgres/20240228110154_ArticleCustomSlugs.Designer.cs @@ -0,0 +1,637 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wave.Data; + +#nullable disable + +namespace Wave.Data.Migrations.postgres +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240228110154_ArticleCustomSlugs")] + partial class ArticleCustomSlugs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:default-case-insensitive", "und-u-kf-upper-ks-level1,und-u-kf-upper-ks-level1,icu,False") + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Wave.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AboutTheAuthor") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Biography") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("BiographyHtml") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContactPhoneBusiness") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContactWebsite") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Wave.Data.Article", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyHtml") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyPlain") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("CreationDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastModified") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("PublishDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerId") + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasDefaultValue(""); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("ReviewerId"); + + b.ToTable("Articles", (string)null); + }); + + modelBuilder.Entity("Wave.Data.ArticleCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArticleId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ArticleId"); + + b.HasIndex("CategoryId"); + + b.ToTable("ArticleCategories", (string)null); + }); + + modelBuilder.Entity("Wave.Data.ArticleImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArticleId") + .HasColumnType("uuid"); + + b.Property("ImageDescription") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("ArticleId"); + + b.ToTable("Images", (string)null); + }); + + modelBuilder.Entity("Wave.Data.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(25); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .UseCollation("default-case-insensitive"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Categories", (string)null); + }); + + modelBuilder.Entity("Wave.Data.EmailNewsletter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArticleId") + .HasColumnType("uuid"); + + b.Property("DistributionDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSend") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("ArticleId") + .IsUnique(); + + b.ToTable("Newsletter", (string)null); + }); + + modelBuilder.Entity("Wave.Data.EmailSubscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("default-case-insensitive"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Unsubscribed") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Unsubscribed"); + + b.ToTable("NewsletterSubscribers", (string)null); + }); + + modelBuilder.Entity("Wave.Data.ProfilePicture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationUserId") + .HasColumnType("text"); + + b.Property("ImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId") + .IsUnique(); + + b.ToTable("ProfilePictures", (string)null); + }); + + modelBuilder.Entity("Wave.Data.UserLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationUserId") + .HasColumnType("text"); + + b.Property("UrlString") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.ToTable("UserLink"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Wave.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Wave.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wave.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Wave.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wave.Data.Article", b => + { + b.HasOne("Wave.Data.ApplicationUser", "Author") + .WithMany("Articles") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wave.Data.ApplicationUser", "Reviewer") + .WithMany() + .HasForeignKey("ReviewerId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Author"); + + b.Navigation("Reviewer"); + }); + + modelBuilder.Entity("Wave.Data.ArticleCategory", b => + { + b.HasOne("Wave.Data.Article", "Article") + .WithMany() + .HasForeignKey("ArticleId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Wave.Data.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Article"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Wave.Data.ArticleImage", b => + { + b.HasOne("Wave.Data.Article", null) + .WithMany("Images") + .HasForeignKey("ArticleId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Wave.Data.EmailNewsletter", b => + { + b.HasOne("Wave.Data.Article", "Article") + .WithOne() + .HasForeignKey("Wave.Data.EmailNewsletter", "ArticleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Article"); + }); + + modelBuilder.Entity("Wave.Data.ProfilePicture", b => + { + b.HasOne("Wave.Data.ApplicationUser", null) + .WithOne("ProfilePicture") + .HasForeignKey("Wave.Data.ProfilePicture", "ApplicationUserId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Wave.Data.UserLink", b => + { + b.HasOne("Wave.Data.ApplicationUser", null) + .WithMany("Links") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Wave.Data.ApplicationUser", b => + { + b.Navigation("Articles"); + + b.Navigation("Links"); + + b.Navigation("ProfilePicture"); + }); + + modelBuilder.Entity("Wave.Data.Article", b => + { + b.Navigation("Images"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Wave/Data/Migrations/postgres/20240228110154_ArticleCustomSlugs.cs b/Wave/Data/Migrations/postgres/20240228110154_ArticleCustomSlugs.cs new file mode 100644 index 0000000..fd35fe2 --- /dev/null +++ b/Wave/Data/Migrations/postgres/20240228110154_ArticleCustomSlugs.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Wave.Data.Migrations.postgres; + +/// +public partial class ArticleCustomSlugs : Migration { + /// + protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.AddColumn( + name: "Slug", + table: "Articles", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropColumn( + name: "Slug", + table: "Articles"); + } +} \ No newline at end of file diff --git a/Wave/Data/Migrations/postgres/ApplicationDbContextModelSnapshot.cs b/Wave/Data/Migrations/postgres/ApplicationDbContextModelSnapshot.cs index 29ca383..8682ffe 100644 --- a/Wave/Data/Migrations/postgres/ApplicationDbContextModelSnapshot.cs +++ b/Wave/Data/Migrations/postgres/ApplicationDbContextModelSnapshot.cs @@ -300,6 +300,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ReviewerId") .HasColumnType("text"); + b.Property("Slug") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasDefaultValue(""); + b.Property("Status") .HasColumnType("integer"); diff --git a/Wave/Utilities/ArticleUtilities.cs b/Wave/Utilities/ArticleUtilities.cs index f6d47be..86e9da3 100644 --- a/Wave/Utilities/ArticleUtilities.cs +++ b/Wave/Utilities/ArticleUtilities.cs @@ -7,10 +7,15 @@ public static class ArticleUtilities { string link; if (article.PublishDate.Year >= 9999) { link = $"/article/{article.Id}"; + } else if (!string.IsNullOrWhiteSpace(article.Slug)) { + link = $"/{article.PublishDate.Year}/{article.PublishDate.Month:D2}/{article.PublishDate.Day:D2}/{article.Slug}"; } else { string titleEncoded = Uri.EscapeDataString(article.Title.ToLowerInvariant()).Replace("-", "+").Replace("%20", "-"); link = $"/{article.PublishDate.Year}/{article.PublishDate.Month:D2}/{article.PublishDate.Day:D2}/{titleEncoded}"; } - return (host != null ? new Uri(host, link) : new Uri(link)).AbsoluteUri; + + if (host is null) return link; + + return new Uri(host, link).AbsoluteUri; } } \ No newline at end of file