feat: save drafts (version history) when post is updated
This commit is contained in:
parent
148e7eb218
commit
0a86721db2
@ -25,6 +25,12 @@ internal sealed class BlogContext : DbContext
|
|||||||
/// <value>The collection of blog posts.</value>
|
/// <value>The collection of blog posts.</value>
|
||||||
public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
|
public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the collection of blog posts drafts in the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The collection of blog post drafts.</value>
|
||||||
|
public DbSet<BlogPostDraft> BlogPostDrafts { get; private set; } = null!;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@ -37,5 +43,6 @@ internal sealed class BlogContext : DbContext
|
|||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
|
modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
|
||||||
|
modelBuilder.ApplyConfiguration(new BlogPostDraftConfiguration());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
126
OliverBooth/Data/Blog/BlogPostDraft.cs
Normal file
126
OliverBooth/Data/Blog/BlogPostDraft.cs
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using SmartFormat;
|
||||||
|
|
||||||
|
namespace OliverBooth.Data.Blog;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
internal sealed class BlogPostDraft : IBlogPostDraft
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[NotMapped]
|
||||||
|
public IBlogAuthor Author { get; internal set; } = null!;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Body { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool EnableComments { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid Id { get; private set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsRedirect { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? Password { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Uri? RedirectUrl { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Slug { get; internal set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<string> Tags { get; internal set; } = ArraySegment<string>.Empty;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTimeOffset Updated { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public BlogPostVisibility Visibility { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int? WordPressId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ID of the author of this blog post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The ID of the author of this blog post.</value>
|
||||||
|
internal Guid AuthorId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the base URL of the Disqus comments for the blog post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The Disqus base URL.</value>
|
||||||
|
internal string? DisqusDomain { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the identifier of the Disqus comments for the blog post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The Disqus identifier.</value>
|
||||||
|
internal string? DisqusIdentifier { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the URL path of the Disqus comments for the blog post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The Disqus URL path.</value>
|
||||||
|
internal string? DisqusPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructs a <see cref="BlogPostDraft" /> by copying values from an existing <see cref="BlogPost" />.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="post">The existing <see cref="BlogPost" />.</param>
|
||||||
|
/// <returns>The newly-constructed <see cref="BlogPostDraft" />.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Disqus domain for the blog post.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The Disqus domain.</returns>
|
||||||
|
public string GetDisqusDomain()
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(DisqusDomain)
|
||||||
|
? "https://oliverbooth.dev/blog"
|
||||||
|
: Smart.Format(DisqusDomain, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetDisqusIdentifier()
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(DisqusIdentifier) ? $"post-{Id}" : Smart.Format(DisqusIdentifier, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetDisqusPostId()
|
||||||
|
{
|
||||||
|
return WordPressId?.ToString() ?? Id.ToString();
|
||||||
|
}
|
||||||
|
}
|
@ -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<BlogPostDraft>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Configure(EntityTypeBuilder<BlogPostDraft> 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<UriToStringConverter>().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<BlogPostVisibility>()).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());
|
||||||
|
}
|
||||||
|
}
|
103
OliverBooth/Data/Blog/IBlogPostDraft.cs
Normal file
103
OliverBooth/Data/Blog/IBlogPostDraft.cs
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
namespace OliverBooth.Data.Blog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a draft of a blog post.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBlogPostDraft
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the author of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The author of the post.</value>
|
||||||
|
IBlogAuthor Author { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the body of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The body of the post.</value>
|
||||||
|
string Body { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether comments are enabled for the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// <see langword="true" /> if comments are enabled for the post; otherwise, <see langword="false" />.
|
||||||
|
/// </value>
|
||||||
|
bool EnableComments { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the ID of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The ID of the post.</value>
|
||||||
|
Guid Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the post redirects to another URL.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// <see langword="true" /> if the post redirects to another URL; otherwise, <see langword="false" />.
|
||||||
|
/// </value>
|
||||||
|
bool IsRedirect { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the password of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The password of the post.</value>
|
||||||
|
string? Password { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the URL to which the post redirects.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The URL to which the post redirects, or <see langword="null" /> if the post does not redirect.</value>
|
||||||
|
Uri? RedirectUrl { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the slug of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The slug of the post.</value>
|
||||||
|
string Slug { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tags of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The tags of the post.</value>
|
||||||
|
IReadOnlyList<string> Tags { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the title of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The title of the post.</value>
|
||||||
|
string Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the date and time the post was last updated.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The update date and time.</value>
|
||||||
|
DateTimeOffset Updated { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the visibility of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The visibility of the post.</value>
|
||||||
|
BlogPostVisibility Visibility { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the WordPress ID of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// The WordPress ID of the post, or <see langword="null" /> if the post was not imported from WordPress.
|
||||||
|
/// </value>
|
||||||
|
int? WordPressId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Disqus identifier for the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The Disqus identifier for the post.</returns>
|
||||||
|
string GetDisqusIdentifier();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Disqus post ID for the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The Disqus post ID for the post.</returns>
|
||||||
|
string GetDisqusPostId();
|
||||||
|
}
|
@ -40,6 +40,18 @@ internal sealed class BlogPostService : IBlogPostService
|
|||||||
return context.BlogPosts.Count();
|
return context.BlogPosts.Count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<IBlogPostDraft> 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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1,
|
public IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1,
|
||||||
BlogPostVisibility visibility = BlogPostVisibility.Published)
|
BlogPostVisibility visibility = BlogPostVisibility.Published)
|
||||||
@ -170,6 +182,8 @@ internal sealed class BlogPostService : IBlogPostService
|
|||||||
}
|
}
|
||||||
|
|
||||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
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.Update(post);
|
||||||
context.SaveChanges();
|
context.SaveChanges();
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,14 @@ public interface IBlogPostService
|
|||||||
/// <returns>A collection of blog posts.</returns>
|
/// <returns>A collection of blog posts.</returns>
|
||||||
IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10);
|
IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the drafts of this post, sorted by their update timestamp.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="post">The post whose drafts to return.</param>
|
||||||
|
/// <returns>The drafts of the <paramref name="post" />.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
|
||||||
|
IReadOnlyList<IBlogPostDraft> GetDrafts(IBlogPost post);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the next blog post from the specified blog post.
|
/// Returns the next blog post from the specified blog post.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
Loading…
Reference in New Issue
Block a user