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.
///