Skip to content

Commit

Permalink
feat: Show Similiar blog posts
Browse files Browse the repository at this point in the history
  • Loading branch information
linkdotnet committed Jun 24, 2024
1 parent ce04058 commit 165595f
Show file tree
Hide file tree
Showing 28 changed files with 512 additions and 53 deletions.
2 changes: 2 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ dotnet_diagnostic.S3875.severity = none # Remove this overload of 'operato
dotnet_diagnostic.IDE0005.severity = none # IDE0005: Using directive is unnecessary
dotnet_diagnostic.IDE0021.severity = suggestion # IDE0021: Use expression body for constructor
dotnet_diagnostic.IDE0022.severity = suggestion # IDE0022: Use expression body for method
dotnet_diagnostic.IDE0055.severity = none # IDE0055: Fix formatting
dotnet_diagnostic.IDE0058.severity = none # IDE0058: Expression value is never used
dotnet_diagnostic.IDE0079.severity = warning # IDE0079: Remove unnecessary suppression
dotnet_diagnostic.IDE0290.severity = none # IDE0290: Use primary constructor
Expand All @@ -443,6 +444,7 @@ dotnet_diagnostic.CA1055.severity = none # CA1055: Uri return values should not
dotnet_diagnostic.CA1056.severity = none # CA1056: Uri properties should not be strings
dotnet_diagnostic.CA1812.severity = none # CA1812: Avoid uninstantiated internal classes
dotnet_diagnostic.CA2201.severity = suggestion # CA2201: Do not raise reserved exception types
dotnet_diagnostic.CA2227.severity = suggestion # CA2227: Collection properties should be read only

# SonarAnalyzer.CSharp
# https://rules.sonarsource.com/csharp
Expand Down
1 change: 1 addition & 0 deletions LinkDotNet.Blog.sln
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
ProjectSection(SolutionItems) = preProject
Readme.md = Readme.md
.editorconfig = .editorconfig
MIGRATION.md = MIGRATION.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{86FD0EB5-13F9-4F1C-ADA1-072EEFEFF1E9}"
Expand Down
26 changes: 26 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Migration Guide
This document describes the changes that need to be made to migrate from one version of the blog to another.

## 8.0 to 9.0
A new `SimilarBlogPost` table is introduced to store similar blog posts.

```sql
CREATE TABLE SimilarBlogPosts
(
Id [NVARCHAR](450) NOT NULL,
SimilarBlogPostIds NVARCHAR(1350) NOT NULL,
)

ALTER TABLE SimilarBlogPosts
ADD CONSTRAINT PK_SimilarBlogPosts PRIMARY KEY (Id)
```

Add the following to the `appsettings.json`:

```json
{
"SimilarBlogPosts": true
}
```

Or `false` if you don't want to use this feature.
4 changes: 3 additions & 1 deletion docs/Setup/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ The appsettings.json file has a lot of options to customize the content of the b
"KofiToken": "ABC123",
"GithubSponsorName": "your-tag-here",
"ShowReadingIndicator": true,
"PatreonName": "your-tag-here"
"PatreonName": "your-tag-here",
"SimlarBlogPosts": "true"
}
```

Expand Down Expand Up @@ -85,3 +86,4 @@ The appsettings.json file has a lot of options to customize the content of the b
| GithubSponsorName | string | Enables the "Github Sponsor" button which redirects to GitHub. Only pass in the user name instead of the url. |
| ShowReadingIndicator | boolean | If set to `true` (default) a circle indicates the progress when a user reads a blog post (without comments). |
| PatreonName | string | Enables the "Become a patreon" button that redirects to patreon.com. Only pass the user name (public profile) as user name. |
| SimilarBlogPosts | boolean | If set to `true` (default) similar blog posts are shown at the end of a blog post. |
8 changes: 8 additions & 0 deletions src/LinkDotNet.Blog.Domain/SimilarBlogPost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Collections.Generic;

namespace LinkDotNet.Blog.Domain;

public class SimilarBlogPost : Entity
{
public IList<string> SimilarBlogPostIds { get; set; } = [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public BlogDbContext(DbContextOptions options)

public DbSet<BlogPostRecord> BlogPostRecords { get; set; }

public DbSet<SimilarBlogPost> SimilarBlogPosts { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
ArgumentNullException.ThrowIfNull(modelBuilder);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using LinkDotNet.Blog.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace LinkDotNet.Blog.Infrastructure.Persistence.Sql.Mapping;

internal sealed class SimilarBlogPostConfiguration : IEntityTypeConfiguration<SimilarBlogPost>
{
public void Configure(EntityTypeBuilder<SimilarBlogPost> builder)
{
builder.HasKey(b => b.Id);
builder.Property(b => b.Id)
.IsUnicode(false)
.ValueGeneratedOnAdd();
builder.Property(b => b.SimilarBlogPostIds).HasMaxLength(450 * 3).IsRequired();
}
}
3 changes: 3 additions & 0 deletions src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public sealed record ApplicationConfiguration
public bool IsAboutMeEnabled { get; set; }

public bool IsGiscusEnabled { get; set; }

public bool IsDisqusEnabled { get; set; }

public string KofiToken { get; init; }
Expand All @@ -32,4 +33,6 @@ public sealed record ApplicationConfiguration
public string PatreonName { get; init; }

public bool IsPatreonEnabled => !string.IsNullOrEmpty(PatreonName);

public bool ShowSimilarPosts { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@using LinkDotNet.Blog.Domain
@using NCronJob
@inject IJSRuntime JSRuntime
@inject IInstantJobRegistry InstantJobRegistry

<div class="container">
<h3 class="fw-bold">@Title</h3>
Expand Down Expand Up @@ -119,6 +121,7 @@
{
canSubmit = false;
await OnBlogPostCreated.InvokeAsync(model.ToBlogPost());
InstantJobRegistry.RunInstantJob<SimilarBlogPostJob>(parameter: true);
ClearModel();
canSubmit = true;
}
Expand Down
9 changes: 7 additions & 2 deletions src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ public BlogPostPublisher(IRepository<BlogPost> repository, ICacheInvalidator cac

public async Task RunAsync(JobExecutionContext context, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(context);

LogPublishStarting();
await PublishScheduledBlogPostsAsync();
var publishedPosts = await PublishScheduledBlogPostsAsync();
context.Output = publishedPosts;
LogPublishStopping();
}

private async Task PublishScheduledBlogPostsAsync()
private async Task<int> PublishScheduledBlogPostsAsync()
{
LogCheckingForScheduledBlogPosts();

Expand All @@ -46,6 +49,8 @@ private async Task PublishScheduledBlogPostsAsync()
{
cacheInvalidator.Cancel();
}

return blogPostsToPublish.Count;
}

private async Task<IPagedList<BlogPost>> GetScheduledBlogPostsAsync()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace LinkDotNet.Blog.Web.Features.Services.Similiarity;

public static class SimilarityCalculator
{
public static double CosineSimilarity(Dictionary<string, double> vectorA, Dictionary<string, double> vectorB)
{
ArgumentNullException.ThrowIfNull(vectorA);
ArgumentNullException.ThrowIfNull(vectorB);

var dotProduct = 0d;
var magnitudeA = 0d;

foreach (var term in vectorA.Keys)
{
if (vectorB.TryGetValue(term, out var value))
{
dotProduct += vectorA[term] * value;
}
magnitudeA += Math.Pow(vectorA[term], 2);
}

var magnitudeB = vectorB.Values.Sum(value => Math.Pow(value, 2));

return dotProduct / (Math.Sqrt(magnitudeA) * Math.Sqrt(magnitudeB));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace LinkDotNet.Blog.Web.Features.Services.Similiarity;

public static partial class TextProcessor
{
private static readonly char[] Separator = [' '];

public static IReadOnlyCollection<string> TokenizeAndNormalize(IEnumerable<string> texts)
=> texts.SelectMany(TokenizeAndNormalize).ToList();

private static IReadOnlyCollection<string> TokenizeAndNormalize(string text)
{
ArgumentNullException.ThrowIfNull(text);

text = text.ToUpperInvariant();
text = TokenRegex().Replace(text, " ");
return [..text.Split(Separator, StringSplitOptions.RemoveEmptyEntries)];
}

[GeneratedRegex(@"[^a-z0-9\s]")]
private static partial Regex TokenRegex();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace LinkDotNet.Blog.Web.Features.Services.Similiarity;

public class TfIdfVectorizer
{
private readonly IReadOnlyCollection<IReadOnlyCollection<string>> documents;
private readonly Dictionary<string, double> idfScores;

public TfIdfVectorizer(IReadOnlyCollection<IReadOnlyCollection<string>> documents)
{
this.documents = documents;
idfScores = CalculateIdfScores();
}

public Dictionary<string, double> ComputeTfIdfVector(IReadOnlyCollection<string> targetDocument)
{
ArgumentNullException.ThrowIfNull(targetDocument);

var termFrequency = targetDocument.GroupBy(t => t).ToDictionary(g => g.Key, g => g.Count());
var tfidfVector = new Dictionary<string, double>();

foreach (var term in termFrequency.Keys)
{
var tf = termFrequency[term] / (double)targetDocument.Count;
var idf = idfScores.TryGetValue(term, out var score) ? score : 0;
tfidfVector[term] = tf * idf;
}

return tfidfVector;
}

private Dictionary<string, double> CalculateIdfScores()
{
var termDocumentFrequency = new Dictionary<string, int>();
var scores = new Dictionary<string, double>();

foreach (var term in documents.Select(document => document.Distinct()).SelectMany(terms => terms))
{
if (!termDocumentFrequency.TryGetValue(term, out var value))
{
value = 0;
termDocumentFrequency[term] = value;
}
termDocumentFrequency[term] = ++value;
}

foreach (var term in termDocumentFrequency.Keys)
{
scores[term] = Math.Log(documents.Count / (double)termDocumentFrequency[term]);
}

return scores;
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
@using LinkDotNet.Blog.Domain
@using LinkDotNet.Blog.Infrastructure.Persistence
@using NCronJob
@inject NavigationManager NavigationManager
@inject IToastService ToastService
@inject IRepository<BlogPost> BlogPostRepository
@inject IInstantJobRegistry InstantJobRegistry

<AuthorizeView>
<div class="blogpost-admin">
<button id="edit-blogpost" type="button" class="btn btn-primary" @onclick="EditBlogPost" aria-label="edit">
<i class="pencil"></i> Edit Blogpost</button>
<button id="delete-blogpost" type="button" class="btn btn-danger" @onclick="ShowConfirmDialog" aria-label="delete"><i class="bin2"></i> Delete
<button id="delete-blogpost" type="button" class="btn btn-danger" @onclick="ShowConfirmDialog" aria-label="delete"><i class="bin2"></i> Delete
Blogpost</button>
</div>
<ConfirmDialog @ref="ConfirmDialog" Title="Delete Blog Post" Content="Do you want to delete the Blog Post?" OnYesPressed="@DeleteBlogPostAsync">
Expand All @@ -18,16 +20,17 @@
@code {
[Parameter]
public string BlogPostId { get; set; }

private ConfirmDialog ConfirmDialog { get; set; }

private async Task DeleteBlogPostAsync()
{
await BlogPostRepository.DeleteAsync(BlogPostId);
InstantJobRegistry.RunInstantJob<SimilarBlogPostJob>(true);
ToastService.ShowSuccess("The Blog Post was successfully deleted");
NavigationManager.NavigateTo("/");
}

private void ShowConfirmDialog()
{
ConfirmDialog.Open();
Expand All @@ -37,4 +40,4 @@
{
NavigationManager.NavigateTo($"update/{BlogPostId}");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
@using LinkDotNet.Blog.Domain
@using LinkDotNet.Blog.Infrastructure.Persistence˘
@inject IRepository<BlogPost> BlogPostRepository
@inject IRepository<SimilarBlogPost> SimilarBlogPostJobRepository

@if (similarBlogPosts.Count > 0)
{
<div class="accordion my-5" id="archiveAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="headingOne">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
Want to read more? Check out these related blog posts!
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse" aria-labelledby="headingOne" data-bs-parent="#accordionExample">
<div class="row p-4">
@foreach (var relatedBlogPost in similarBlogPosts)
{
<div class="col pt-2">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title fw-bold">@relatedBlogPost.Title</h5>
<p class="card-text">@MarkdownConverter.ToMarkupString(relatedBlogPost.ShortDescription)</p>
</div>
<a href="blogPost/@relatedBlogPost.Id/@relatedBlogPost.Slug" class="stretched-link"></a>
</div>
</div>
}
</div>
</div>
</div>
</div>
}

@code {
[Parameter] public BlogPost BlogPost { get; set; }

private IReadOnlyCollection<BlogPost> similarBlogPosts = [];

protected override async Task OnParametersSetAsync()
{
var similarBlogPostIds = await SimilarBlogPostJobRepository.GetByIdAsync(BlogPost.Id);
if (similarBlogPostIds is not null)
{
similarBlogPosts = await BlogPostRepository.GetAllAsync(
b => similarBlogPostIds.SimilarBlogPostIds.Contains(b.Id));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ else
<ShareBlogPost></ShareBlogPost>
</div>
<DonationSection></DonationSection>
@if (AppConfiguration.Value.ShowSimilarPosts)
{
<SimilarBlogPostSection BlogPost="@BlogPost" />
}
<CommentSection></CommentSection>
</div>
</div>
Expand Down Expand Up @@ -109,11 +113,7 @@ else
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JsRuntime.InvokeVoidAsync("hljs.highlightAll");

if (firstRender)
{
_ = UserRecordService.StoreUserRecordAsync();
}
_ = UserRecordService.StoreUserRecordAsync();
}

private async Task UpdateLikes(bool hasLiked)
Expand Down
Loading

0 comments on commit 165595f

Please sign in to comment.