From 0a86721db2e388df48de9a16447708cbd8ea1907 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Thu, 29 Feb 2024 18:06:30 +0000 Subject: [PATCH] feat: save drafts (version history) when post is updated --- OliverBooth/Data/Blog/BlogContext.cs | 7 + OliverBooth/Data/Blog/BlogPostDraft.cs | 126 ++++++++++++++++++ .../BlogPostDraftConfiguration.cs | 36 +++++ OliverBooth/Data/Blog/IBlogPostDraft.cs | 103 ++++++++++++++ OliverBooth/Services/BlogPostService.cs | 14 ++ OliverBooth/Services/IBlogPostService.cs | 8 ++ 6 files changed, 294 insertions(+) create mode 100644 OliverBooth/Data/Blog/BlogPostDraft.cs create mode 100644 OliverBooth/Data/Blog/Configuration/BlogPostDraftConfiguration.cs create mode 100644 OliverBooth/Data/Blog/IBlogPostDraft.cs diff --git a/OliverBooth/Data/Blog/BlogContext.cs b/OliverBooth/Data/Blog/BlogContext.cs index 4d9db4f..1dead2e 100644 --- a/OliverBooth/Data/Blog/BlogContext.cs +++ b/OliverBooth/Data/Blog/BlogContext.cs @@ -25,6 +25,12 @@ internal sealed class BlogContext : DbContext /// The collection of blog posts. public DbSet BlogPosts { get; private set; } = null!; + /// + /// Gets the collection of blog posts drafts in the database. + /// + /// The collection of blog post drafts. + public DbSet BlogPostDrafts { get; private set; } = null!; + /// protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -37,5 +43,6 @@ internal sealed class BlogContext : DbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new BlogPostConfiguration()); + modelBuilder.ApplyConfiguration(new BlogPostDraftConfiguration()); } } diff --git a/OliverBooth/Data/Blog/BlogPostDraft.cs b/OliverBooth/Data/Blog/BlogPostDraft.cs new file mode 100644 index 0000000..3c5a027 --- /dev/null +++ b/OliverBooth/Data/Blog/BlogPostDraft.cs @@ -0,0 +1,126 @@ +using System.ComponentModel.DataAnnotations.Schema; +using SmartFormat; + +namespace OliverBooth.Data.Blog; + +/// +internal sealed class BlogPostDraft : IBlogPostDraft +{ + /// + [NotMapped] + public IBlogAuthor Author { get; internal set; } = null!; + + /// + public string Body { get; set; } = string.Empty; + + /// + public bool EnableComments { get; internal set; } + + /// + public Guid Id { get; private set; } = Guid.NewGuid(); + + /// + public bool IsRedirect { get; internal set; } + + /// + public string? Password { get; internal set; } + + /// + public Uri? RedirectUrl { get; internal set; } + + /// + public string Slug { get; internal set; } = string.Empty; + + /// + public IReadOnlyList Tags { get; internal set; } = ArraySegment.Empty; + + /// + public string Title { get; set; } = string.Empty; + + /// + public DateTimeOffset Updated { get; internal set; } + + /// + public BlogPostVisibility Visibility { get; internal set; } + + /// + public int? WordPressId { get; set; } + + /// + /// Gets or sets the ID of the author of this blog post. + /// + /// The ID of the author of this blog post. + internal Guid AuthorId { get; set; } + + /// + /// Gets or sets the base URL of the Disqus comments for the blog post. + /// + /// The Disqus base URL. + internal string? DisqusDomain { get; set; } + + /// + /// Gets or sets the identifier of the Disqus comments for the blog post. + /// + /// The Disqus identifier. + internal string? DisqusIdentifier { get; set; } + + /// + /// Gets or sets the URL path of the Disqus comments for the blog post. + /// + /// The Disqus URL path. + internal string? DisqusPath { get; set; } + + /// + /// Constructs a by copying values from an existing . + /// + /// The existing . + /// The newly-constructed . + /// is . + public static BlogPostDraft CreateFromBlogPost(BlogPost post) + { + if (post is null) + { + throw new ArgumentNullException(nameof(post)); + } + + return new BlogPostDraft + { + AuthorId = post.AuthorId, + Body = post.Body, + DisqusDomain = post.DisqusDomain, + DisqusIdentifier = post.DisqusIdentifier, + DisqusPath = post.DisqusPath, + EnableComments = post.EnableComments, + IsRedirect = post.IsRedirect, + Password = post.Password, + RedirectUrl = post.RedirectUrl, + Tags = post.Tags, + Title = post.Title, + Visibility = post.Visibility, + WordPressId = post.WordPressId + }; + } + + /// + /// Gets the Disqus domain for the blog post. + /// + /// The Disqus domain. + public string GetDisqusDomain() + { + return string.IsNullOrWhiteSpace(DisqusDomain) + ? "https://oliverbooth.dev/blog" + : Smart.Format(DisqusDomain, this); + } + + /// + public string GetDisqusIdentifier() + { + return string.IsNullOrWhiteSpace(DisqusIdentifier) ? $"post-{Id}" : Smart.Format(DisqusIdentifier, this); + } + + /// + public string GetDisqusPostId() + { + return WordPressId?.ToString() ?? Id.ToString(); + } +} diff --git a/OliverBooth/Data/Blog/Configuration/BlogPostDraftConfiguration.cs b/OliverBooth/Data/Blog/Configuration/BlogPostDraftConfiguration.cs new file mode 100644 index 0000000..9c2f760 --- /dev/null +++ b/OliverBooth/Data/Blog/Configuration/BlogPostDraftConfiguration.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace OliverBooth.Data.Blog.Configuration; + +internal sealed class BlogPostDraftConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("BlogPostDraft"); + builder.HasKey(e => new { e.Id, e.Updated }); + + builder.Property(e => e.Id); + builder.Property(e => e.Updated).IsRequired(); + builder.Property(e => e.WordPressId).IsRequired(false); + builder.Property(e => e.Slug).HasMaxLength(100).IsRequired(); + builder.Property(e => e.AuthorId).IsRequired(); + builder.Property(e => e.Title).HasMaxLength(255).IsRequired(); + builder.Property(e => e.Body).IsRequired(); + builder.Property(e => e.IsRedirect).IsRequired(); + builder.Property(e => e.RedirectUrl).HasConversion().HasMaxLength(255).IsRequired(false); + builder.Property(e => e.EnableComments).IsRequired(); + builder.Property(e => e.DisqusDomain).IsRequired(false); + builder.Property(e => e.DisqusIdentifier).IsRequired(false); + builder.Property(e => e.DisqusPath).IsRequired(false); + builder.Property(e => e.Visibility).HasConversion(new EnumToStringConverter()).IsRequired(); + builder.Property(e => e.Password).HasMaxLength(255).IsRequired(false); + builder.Property(e => e.Tags).IsRequired() + .HasConversion( + tags => string.Join(' ', tags.Select(t => t.Replace(' ', '-'))), + tags => tags.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Replace('-', ' ')).ToArray()); + } +} diff --git a/OliverBooth/Data/Blog/IBlogPostDraft.cs b/OliverBooth/Data/Blog/IBlogPostDraft.cs new file mode 100644 index 0000000..1d66c2b --- /dev/null +++ b/OliverBooth/Data/Blog/IBlogPostDraft.cs @@ -0,0 +1,103 @@ +namespace OliverBooth.Data.Blog; + +/// +/// Represents a draft of a blog post. +/// +public interface IBlogPostDraft +{ + /// + /// Gets the author of the post. + /// + /// The author of the post. + IBlogAuthor Author { get; } + + /// + /// Gets or sets the body of the post. + /// + /// The body of the post. + string Body { get; set; } + + /// + /// Gets a value indicating whether comments are enabled for the post. + /// + /// + /// if comments are enabled for the post; otherwise, . + /// + bool EnableComments { get; } + + /// + /// Gets the ID of the post. + /// + /// The ID of the post. + Guid Id { get; } + + /// + /// Gets a value indicating whether the post redirects to another URL. + /// + /// + /// if the post redirects to another URL; otherwise, . + /// + bool IsRedirect { get; } + + /// + /// Gets the password of the post. + /// + /// The password of the post. + string? Password { get; } + + /// + /// Gets the URL to which the post redirects. + /// + /// The URL to which the post redirects, or if the post does not redirect. + Uri? RedirectUrl { get; } + + /// + /// Gets the slug of the post. + /// + /// The slug of the post. + string Slug { get; } + + /// + /// Gets the tags of the post. + /// + /// The tags of the post. + IReadOnlyList Tags { get; } + + /// + /// Gets or sets the title of the post. + /// + /// The title of the post. + string Title { get; set; } + + /// + /// Gets the date and time the post was last updated. + /// + /// The update date and time. + DateTimeOffset Updated { get; } + + /// + /// Gets the visibility of the post. + /// + /// The visibility of the post. + BlogPostVisibility Visibility { get; } + + /// + /// Gets the WordPress ID of the post. + /// + /// + /// The WordPress ID of the post, or if the post was not imported from WordPress. + /// + int? WordPressId { get; } + + /// + /// Gets the Disqus identifier for the post. + /// + /// The Disqus identifier for the post. + string GetDisqusIdentifier(); + + /// + /// Gets the Disqus post ID for the post. + /// + /// The Disqus post ID for the post. + string GetDisqusPostId(); +} diff --git a/OliverBooth/Services/BlogPostService.cs b/OliverBooth/Services/BlogPostService.cs index ef4705a..afde740 100644 --- a/OliverBooth/Services/BlogPostService.cs +++ b/OliverBooth/Services/BlogPostService.cs @@ -40,6 +40,18 @@ internal sealed class BlogPostService : IBlogPostService return context.BlogPosts.Count(); } + /// + public IReadOnlyList GetDrafts(IBlogPost post) + { + if (post is null) + { + throw new ArgumentNullException(nameof(post)); + } + + using BlogContext context = _dbContextFactory.CreateDbContext(); + return context.BlogPostDrafts.Where(d => d.Id == post.Id).OrderBy(d => d.Updated).ToArray(); + } + /// public IReadOnlyList GetAllBlogPosts(int limit = -1, BlogPostVisibility visibility = BlogPostVisibility.Published) @@ -170,6 +182,8 @@ internal sealed class BlogPostService : IBlogPostService } using BlogContext context = _dbContextFactory.CreateDbContext(); + BlogPost cached = context.BlogPosts.First(p => p.Id == post.Id); + context.BlogPostDrafts.Add(BlogPostDraft.CreateFromBlogPost(cached)); context.Update(post); context.SaveChanges(); } diff --git a/OliverBooth/Services/IBlogPostService.cs b/OliverBooth/Services/IBlogPostService.cs index a8e6d6c..34a9840 100644 --- a/OliverBooth/Services/IBlogPostService.cs +++ b/OliverBooth/Services/IBlogPostService.cs @@ -36,6 +36,14 @@ public interface IBlogPostService /// A collection of blog posts. IReadOnlyList GetBlogPosts(int page, int pageSize = 10); + /// + /// Returns the drafts of this post, sorted by their update timestamp. + /// + /// The post whose drafts to return. + /// The drafts of the . + /// is . + IReadOnlyList GetDrafts(IBlogPost post); + /// /// Returns the next blog post from the specified blog post. ///