Skip to content

Commit 165595f

Browse files
committed
feat: Show Similiar blog posts
1 parent ce04058 commit 165595f

File tree

28 files changed

+512
-53
lines changed

28 files changed

+512
-53
lines changed

.editorconfig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ dotnet_diagnostic.S3875.severity = none # Remove this overload of 'operato
419419
dotnet_diagnostic.IDE0005.severity = none # IDE0005: Using directive is unnecessary
420420
dotnet_diagnostic.IDE0021.severity = suggestion # IDE0021: Use expression body for constructor
421421
dotnet_diagnostic.IDE0022.severity = suggestion # IDE0022: Use expression body for method
422+
dotnet_diagnostic.IDE0055.severity = none # IDE0055: Fix formatting
422423
dotnet_diagnostic.IDE0058.severity = none # IDE0058: Expression value is never used
423424
dotnet_diagnostic.IDE0079.severity = warning # IDE0079: Remove unnecessary suppression
424425
dotnet_diagnostic.IDE0290.severity = none # IDE0290: Use primary constructor
@@ -443,6 +444,7 @@ dotnet_diagnostic.CA1055.severity = none # CA1055: Uri return values should not
443444
dotnet_diagnostic.CA1056.severity = none # CA1056: Uri properties should not be strings
444445
dotnet_diagnostic.CA1812.severity = none # CA1812: Avoid uninstantiated internal classes
445446
dotnet_diagnostic.CA2201.severity = suggestion # CA2201: Do not raise reserved exception types
447+
dotnet_diagnostic.CA2227.severity = suggestion # CA2227: Collection properties should be read only
446448

447449
# SonarAnalyzer.CSharp
448450
# https://rules.sonarsource.com/csharp

LinkDotNet.Blog.sln

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
1919
ProjectSection(SolutionItems) = preProject
2020
Readme.md = Readme.md
2121
.editorconfig = .editorconfig
22+
MIGRATION.md = MIGRATION.md
2223
EndProjectSection
2324
EndProject
2425
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{86FD0EB5-13F9-4F1C-ADA1-072EEFEFF1E9}"

MIGRATION.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Migration Guide
2+
This document describes the changes that need to be made to migrate from one version of the blog to another.
3+
4+
## 8.0 to 9.0
5+
A new `SimilarBlogPost` table is introduced to store similar blog posts.
6+
7+
```sql
8+
CREATE TABLE SimilarBlogPosts
9+
(
10+
Id [NVARCHAR](450) NOT NULL,
11+
SimilarBlogPostIds NVARCHAR(1350) NOT NULL,
12+
)
13+
14+
ALTER TABLE SimilarBlogPosts
15+
ADD CONSTRAINT PK_SimilarBlogPosts PRIMARY KEY (Id)
16+
```
17+
18+
Add the following to the `appsettings.json`:
19+
20+
```json
21+
{
22+
"SimilarBlogPosts": true
23+
}
24+
```
25+
26+
Or `false` if you don't want to use this feature.

docs/Setup/Configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ The appsettings.json file has a lot of options to customize the content of the b
4848
"KofiToken": "ABC123",
4949
"GithubSponsorName": "your-tag-here",
5050
"ShowReadingIndicator": true,
51-
"PatreonName": "your-tag-here"
51+
"PatreonName": "your-tag-here",
52+
"SimlarBlogPosts": "true"
5253
}
5354
```
5455

@@ -85,3 +86,4 @@ The appsettings.json file has a lot of options to customize the content of the b
8586
| GithubSponsorName | string | Enables the "Github Sponsor" button which redirects to GitHub. Only pass in the user name instead of the url. |
8687
| ShowReadingIndicator | boolean | If set to `true` (default) a circle indicates the progress when a user reads a blog post (without comments). |
8788
| PatreonName | string | Enables the "Become a patreon" button that redirects to patreon.com. Only pass the user name (public profile) as user name. |
89+
| SimilarBlogPosts | boolean | If set to `true` (default) similar blog posts are shown at the end of a blog post. |
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using System.Collections.Generic;
2+
3+
namespace LinkDotNet.Blog.Domain;
4+
5+
public class SimilarBlogPost : Entity
6+
{
7+
public IList<string> SimilarBlogPostIds { get; set; } = [];
8+
}

src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public BlogDbContext(DbContextOptions options)
2424

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

27+
public DbSet<SimilarBlogPost> SimilarBlogPosts { get; set; }
28+
2729
protected override void OnModelCreating(ModelBuilder modelBuilder)
2830
{
2931
ArgumentNullException.ThrowIfNull(modelBuilder);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using LinkDotNet.Blog.Domain;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.EntityFrameworkCore.Metadata.Builders;
4+
5+
namespace LinkDotNet.Blog.Infrastructure.Persistence.Sql.Mapping;
6+
7+
internal sealed class SimilarBlogPostConfiguration : IEntityTypeConfiguration<SimilarBlogPost>
8+
{
9+
public void Configure(EntityTypeBuilder<SimilarBlogPost> builder)
10+
{
11+
builder.HasKey(b => b.Id);
12+
builder.Property(b => b.Id)
13+
.IsUnicode(false)
14+
.ValueGeneratedOnAdd();
15+
builder.Property(b => b.SimilarBlogPostIds).HasMaxLength(450 * 3).IsRequired();
16+
}
17+
}

src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public sealed record ApplicationConfiguration
1717
public bool IsAboutMeEnabled { get; set; }
1818

1919
public bool IsGiscusEnabled { get; set; }
20+
2021
public bool IsDisqusEnabled { get; set; }
2122

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

3435
public bool IsPatreonEnabled => !string.IsNullOrEmpty(PatreonName);
36+
37+
public bool ShowSimilarPosts { get; init; }
3538
}

src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
@using LinkDotNet.Blog.Domain
2+
@using NCronJob
23
@inject IJSRuntime JSRuntime
4+
@inject IInstantJobRegistry InstantJobRegistry
35

46
<div class="container">
57
<h3 class="fw-bold">@Title</h3>
@@ -119,6 +121,7 @@
119121
{
120122
canSubmit = false;
121123
await OnBlogPostCreated.InvokeAsync(model.ToBlogPost());
124+
InstantJobRegistry.RunInstantJob<SimilarBlogPostJob>(parameter: true);
122125
ClearModel();
123126
canSubmit = true;
124127
}

src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ public BlogPostPublisher(IRepository<BlogPost> repository, ICacheInvalidator cac
2525

2626
public async Task RunAsync(JobExecutionContext context, CancellationToken token)
2727
{
28+
ArgumentNullException.ThrowIfNull(context);
29+
2830
LogPublishStarting();
29-
await PublishScheduledBlogPostsAsync();
31+
var publishedPosts = await PublishScheduledBlogPostsAsync();
32+
context.Output = publishedPosts;
3033
LogPublishStopping();
3134
}
3235

33-
private async Task PublishScheduledBlogPostsAsync()
36+
private async Task<int> PublishScheduledBlogPostsAsync()
3437
{
3538
LogCheckingForScheduledBlogPosts();
3639

@@ -46,6 +49,8 @@ private async Task PublishScheduledBlogPostsAsync()
4649
{
4750
cacheInvalidator.Cancel();
4851
}
52+
53+
return blogPostsToPublish.Count;
4954
}
5055

5156
private async Task<IPagedList<BlogPost>> GetScheduledBlogPostsAsync()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
5+
namespace LinkDotNet.Blog.Web.Features.Services.Similiarity;
6+
7+
public static class SimilarityCalculator
8+
{
9+
public static double CosineSimilarity(Dictionary<string, double> vectorA, Dictionary<string, double> vectorB)
10+
{
11+
ArgumentNullException.ThrowIfNull(vectorA);
12+
ArgumentNullException.ThrowIfNull(vectorB);
13+
14+
var dotProduct = 0d;
15+
var magnitudeA = 0d;
16+
17+
foreach (var term in vectorA.Keys)
18+
{
19+
if (vectorB.TryGetValue(term, out var value))
20+
{
21+
dotProduct += vectorA[term] * value;
22+
}
23+
magnitudeA += Math.Pow(vectorA[term], 2);
24+
}
25+
26+
var magnitudeB = vectorB.Values.Sum(value => Math.Pow(value, 2));
27+
28+
return dotProduct / (Math.Sqrt(magnitudeA) * Math.Sqrt(magnitudeB));
29+
}
30+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text.RegularExpressions;
5+
6+
namespace LinkDotNet.Blog.Web.Features.Services.Similiarity;
7+
8+
public static partial class TextProcessor
9+
{
10+
private static readonly char[] Separator = [' '];
11+
12+
public static IReadOnlyCollection<string> TokenizeAndNormalize(IEnumerable<string> texts)
13+
=> texts.SelectMany(TokenizeAndNormalize).ToList();
14+
15+
private static IReadOnlyCollection<string> TokenizeAndNormalize(string text)
16+
{
17+
ArgumentNullException.ThrowIfNull(text);
18+
19+
text = text.ToUpperInvariant();
20+
text = TokenRegex().Replace(text, " ");
21+
return [..text.Split(Separator, StringSplitOptions.RemoveEmptyEntries)];
22+
}
23+
24+
[GeneratedRegex(@"[^a-z0-9\s]")]
25+
private static partial Regex TokenRegex();
26+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
5+
namespace LinkDotNet.Blog.Web.Features.Services.Similiarity;
6+
7+
public class TfIdfVectorizer
8+
{
9+
private readonly IReadOnlyCollection<IReadOnlyCollection<string>> documents;
10+
private readonly Dictionary<string, double> idfScores;
11+
12+
public TfIdfVectorizer(IReadOnlyCollection<IReadOnlyCollection<string>> documents)
13+
{
14+
this.documents = documents;
15+
idfScores = CalculateIdfScores();
16+
}
17+
18+
public Dictionary<string, double> ComputeTfIdfVector(IReadOnlyCollection<string> targetDocument)
19+
{
20+
ArgumentNullException.ThrowIfNull(targetDocument);
21+
22+
var termFrequency = targetDocument.GroupBy(t => t).ToDictionary(g => g.Key, g => g.Count());
23+
var tfidfVector = new Dictionary<string, double>();
24+
25+
foreach (var term in termFrequency.Keys)
26+
{
27+
var tf = termFrequency[term] / (double)targetDocument.Count;
28+
var idf = idfScores.TryGetValue(term, out var score) ? score : 0;
29+
tfidfVector[term] = tf * idf;
30+
}
31+
32+
return tfidfVector;
33+
}
34+
35+
private Dictionary<string, double> CalculateIdfScores()
36+
{
37+
var termDocumentFrequency = new Dictionary<string, int>();
38+
var scores = new Dictionary<string, double>();
39+
40+
foreach (var term in documents.Select(document => document.Distinct()).SelectMany(terms => terms))
41+
{
42+
if (!termDocumentFrequency.TryGetValue(term, out var value))
43+
{
44+
value = 0;
45+
termDocumentFrequency[term] = value;
46+
}
47+
termDocumentFrequency[term] = ++value;
48+
}
49+
50+
foreach (var term in termDocumentFrequency.Keys)
51+
{
52+
scores[term] = Math.Log(documents.Count / (double)termDocumentFrequency[term]);
53+
}
54+
55+
return scores;
56+
}
57+
}
Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
@using LinkDotNet.Blog.Domain
22
@using LinkDotNet.Blog.Infrastructure.Persistence
3+
@using NCronJob
34
@inject NavigationManager NavigationManager
45
@inject IToastService ToastService
56
@inject IRepository<BlogPost> BlogPostRepository
7+
@inject IInstantJobRegistry InstantJobRegistry
68

79
<AuthorizeView>
810
<div class="blogpost-admin">
911
<button id="edit-blogpost" type="button" class="btn btn-primary" @onclick="EditBlogPost" aria-label="edit">
1012
<i class="pencil"></i> Edit Blogpost</button>
11-
<button id="delete-blogpost" type="button" class="btn btn-danger" @onclick="ShowConfirmDialog" aria-label="delete"><i class="bin2"></i> Delete
13+
<button id="delete-blogpost" type="button" class="btn btn-danger" @onclick="ShowConfirmDialog" aria-label="delete"><i class="bin2"></i> Delete
1214
Blogpost</button>
1315
</div>
1416
<ConfirmDialog @ref="ConfirmDialog" Title="Delete Blog Post" Content="Do you want to delete the Blog Post?" OnYesPressed="@DeleteBlogPostAsync">
@@ -18,16 +20,17 @@
1820
@code {
1921
[Parameter]
2022
public string BlogPostId { get; set; }
21-
23+
2224
private ConfirmDialog ConfirmDialog { get; set; }
2325

2426
private async Task DeleteBlogPostAsync()
2527
{
2628
await BlogPostRepository.DeleteAsync(BlogPostId);
29+
InstantJobRegistry.RunInstantJob<SimilarBlogPostJob>(true);
2730
ToastService.ShowSuccess("The Blog Post was successfully deleted");
2831
NavigationManager.NavigateTo("/");
2932
}
30-
33+
3134
private void ShowConfirmDialog()
3235
{
3336
ConfirmDialog.Open();
@@ -37,4 +40,4 @@
3740
{
3841
NavigationManager.NavigateTo($"update/{BlogPostId}");
3942
}
40-
}
43+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
@using LinkDotNet.Blog.Domain
2+
@using LinkDotNet.Blog.Infrastructure.Persistence˘
3+
@inject IRepository<BlogPost> BlogPostRepository
4+
@inject IRepository<SimilarBlogPost> SimilarBlogPostJobRepository
5+
6+
@if (similarBlogPosts.Count > 0)
7+
{
8+
<div class="accordion my-5" id="archiveAccordion">
9+
<div class="accordion-item">
10+
<h2 class="accordion-header" id="headingOne">
11+
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
12+
Want to read more? Check out these related blog posts!
13+
</button>
14+
</h2>
15+
<div id="collapseOne" class="accordion-collapse collapse" aria-labelledby="headingOne" data-bs-parent="#accordionExample">
16+
<div class="row p-4">
17+
@foreach (var relatedBlogPost in similarBlogPosts)
18+
{
19+
<div class="col pt-2">
20+
<div class="card h-100">
21+
<div class="card-body">
22+
<h5 class="card-title fw-bold">@relatedBlogPost.Title</h5>
23+
<p class="card-text">@MarkdownConverter.ToMarkupString(relatedBlogPost.ShortDescription)</p>
24+
</div>
25+
<a href="blogPost/@relatedBlogPost.Id/@relatedBlogPost.Slug" class="stretched-link"></a>
26+
</div>
27+
</div>
28+
}
29+
</div>
30+
</div>
31+
</div>
32+
</div>
33+
}
34+
35+
@code {
36+
[Parameter] public BlogPost BlogPost { get; set; }
37+
38+
private IReadOnlyCollection<BlogPost> similarBlogPosts = [];
39+
40+
protected override async Task OnParametersSetAsync()
41+
{
42+
var similarBlogPostIds = await SimilarBlogPostJobRepository.GetByIdAsync(BlogPost.Id);
43+
if (similarBlogPostIds is not null)
44+
{
45+
similarBlogPosts = await BlogPostRepository.GetAllAsync(
46+
b => similarBlogPostIds.SimilarBlogPostIds.Contains(b.Id));
47+
}
48+
}
49+
}

src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ else
7575
<ShareBlogPost></ShareBlogPost>
7676
</div>
7777
<DonationSection></DonationSection>
78+
@if (AppConfiguration.Value.ShowSimilarPosts)
79+
{
80+
<SimilarBlogPostSection BlogPost="@BlogPost" />
81+
}
7882
<CommentSection></CommentSection>
7983
</div>
8084
</div>
@@ -109,11 +113,7 @@ else
109113
protected override async Task OnAfterRenderAsync(bool firstRender)
110114
{
111115
await JsRuntime.InvokeVoidAsync("hljs.highlightAll");
112-
113-
if (firstRender)
114-
{
115-
_ = UserRecordService.StoreUserRecordAsync();
116-
}
116+
_ = UserRecordService.StoreUserRecordAsync();
117117
}
118118

119119
private async Task UpdateLikes(bool hasLiked)

0 commit comments

Comments
 (0)