From 3a493853a01bd6cb7d48321506a2011ff35adebd Mon Sep 17 00:00:00 2001 From: Marcos Kirchner <38567816+marcoskirchner@users.noreply.github.com> Date: Tue, 9 Nov 2021 11:11:22 -0300 Subject: [PATCH] Add support for tags (#168) * Rename tag to category in edit page. * Add support for tags. --- src/Constants.cs | 2 + src/Controllers/BlogController.cs | 31 +++++++++++ src/Controllers/RobotsController.cs | 4 ++ src/Miniblog.Core.csproj | 4 +- src/Models/Post.cs | 2 + src/Services/FileBlogService.cs | 48 +++++++++++++++++ src/Services/IBlogService.cs | 4 ++ src/Services/InMemoryBlogServiceBase.cs | 30 +++++++++++ src/Services/MetaWeblogService.cs | 20 ++++++- src/Views/Blog/Edit.cshtml | 21 ++++++-- src/Views/Blog/Post.cshtml | 70 +++++++++++++++++-------- src/wwwroot/css/belowthefold.scss | 5 +- src/wwwroot/js/admin.js | 44 ++++++++++++++-- src/wwwroot/wlwmanifest.xml | 4 +- 14 files changed, 252 insertions(+), 37 deletions(-) diff --git a/src/Constants.cs b/src/Constants.cs index 1eb170bf..2796aa3d 100644 --- a/src/Constants.cs +++ b/src/Constants.cs @@ -5,7 +5,9 @@ namespace Miniblog.Core public static class Constants { public static readonly string AllCats = "AllCats"; + public static readonly string AllTags = "AllTags"; public static readonly string categories = "categories"; + public static readonly string tags = "tags"; public static readonly string Dash = "-"; public static readonly string Description = "Description"; public static readonly string Head = "Head"; diff --git a/src/Controllers/BlogController.cs b/src/Controllers/BlogController.cs index 4bea750c..6e49f017 100644 --- a/src/Controllers/BlogController.cs +++ b/src/Controllers/BlogController.cs @@ -89,6 +89,27 @@ public async Task Category(string category, int page = 0) return this.View("~/Views/Blog/Index.cshtml", filteredPosts.AsAsyncEnumerable()); } + [Route("/blog/tag/{tag}/{page:int?}")] + [OutputCache(Profile = "default")] + public async Task Tag(string tag, int page = 0) + { + // get posts for the selected tag. + var posts = this.blog.GetPostsByTag(tag); + + // apply paging filter. + var filteredPosts = posts.Skip(this.settings.Value.PostsPerPage * page).Take(this.settings.Value.PostsPerPage); + + // set the view option + this.ViewData["ViewOption"] = this.settings.Value.ListView; + + this.ViewData[Constants.TotalPostCount] = await posts.CountAsync().ConfigureAwait(true); + this.ViewData[Constants.Title] = $"{this.manifest.Name} {tag}"; + this.ViewData[Constants.Description] = $"Articles posted in the {tag} tag"; + this.ViewData[Constants.prev] = $"/blog/tag/{tag}/{page + 1}/"; + this.ViewData[Constants.next] = $"/blog/tag/{tag}/{(page <= 1 ? null : page - 1 + "/")}"; + return this.View("~/Views/Blog/Index.cshtml", filteredPosts.AsAsyncEnumerable()); + } + [Route("/blog/comment/{postId}/{commentId}")] [Authorize] public async Task DeleteComment(string postId, string commentId) @@ -135,6 +156,10 @@ public async Task Edit(string? id) categories.Sort(); this.ViewData[Constants.AllCats] = categories; + var tags = await this.blog.GetTags().ToListAsync(); + tags.Sort(); + this.ViewData[Constants.AllTags] = tags; + if (string.IsNullOrEmpty(id)) { return this.View(new Post()); @@ -198,12 +223,18 @@ public async Task UpdatePost(Post post) var existing = await this.blog.GetPostById(post.ID).ConfigureAwait(false) ?? post; string categories = this.Request.Form[Constants.categories]; + string tags = this.Request.Form[Constants.tags]; existing.Categories.Clear(); categories.Split(",", StringSplitOptions.RemoveEmptyEntries) .Select(c => c.Trim().ToLowerInvariant()) .ToList() .ForEach(existing.Categories.Add); + existing.Tags.Clear(); + tags.Split(",", StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Trim().ToLowerInvariant()) + .ToList() + .ForEach(existing.Tags.Add); existing.Title = post.Title.Trim(); existing.Slug = !string.IsNullOrWhiteSpace(post.Slug) ? post.Slug.Trim() : Models.Post.CreateSlug(post.Title); existing.IsPublished = post.IsPublished; diff --git a/src/Controllers/RobotsController.cs b/src/Controllers/RobotsController.cs index 3f53de90..d144f0fb 100644 --- a/src/Controllers/RobotsController.cs +++ b/src/Controllers/RobotsController.cs @@ -111,6 +111,10 @@ public async Task Rss(string type) { item.AddCategory(new SyndicationCategory(category)); } + foreach (var tag in post.Tags) + { + item.AddCategory(new SyndicationCategory(tag)); + } item.AddContributor(new SyndicationPerson("test@example.com", this.settings.Value.Owner)); item.AddLink(new SyndicationLink(new Uri(item.Id))); diff --git a/src/Miniblog.Core.csproj b/src/Miniblog.Core.csproj index 89aff0ba..34259670 100644 --- a/src/Miniblog.Core.csproj +++ b/src/Miniblog.Core.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.1 @@ -23,7 +23,7 @@ - + diff --git a/src/Models/Post.cs b/src/Models/Post.cs index 5f01d6d4..00409533 100644 --- a/src/Models/Post.cs +++ b/src/Models/Post.cs @@ -12,6 +12,8 @@ public class Post { public IList Categories { get; } = new List(); + public IList Tags { get; } = new List(); + public IList Comments { get; } = new List(); [Required] diff --git a/src/Services/FileBlogService.cs b/src/Services/FileBlogService.cs index 365003c4..1045efb2 100644 --- a/src/Services/FileBlogService.cs +++ b/src/Services/FileBlogService.cs @@ -84,6 +84,22 @@ public virtual IAsyncEnumerable GetCategories() .ToAsyncEnumerable(); } + [SuppressMessage( + "Globalization", + "CA1308:Normalize strings to uppercase", + Justification = "Consumer preference.")] + public virtual IAsyncEnumerable GetTags() + { + var isAdmin = this.IsAdmin(); + + return this.cache + .Where(p => p.IsPublished || isAdmin) + .SelectMany(post => post.Tags) + .Select(tag => tag.ToLowerInvariant()) + .Distinct() + .ToAsyncEnumerable(); + } + public virtual Task GetPostById(string id) { var isAdmin = this.IsAdmin(); @@ -143,6 +159,18 @@ where p.Categories.Contains(category, StringComparer.OrdinalIgnoreCase) return posts.ToAsyncEnumerable(); } + public IAsyncEnumerable GetPostsByTag(string tag) + { + var isAdmin = this.IsAdmin(); + + var posts = from p in this.cache + where p.PubDate <= DateTime.UtcNow && (p.IsPublished || isAdmin) + where p.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase) + select p; + + return posts.ToAsyncEnumerable(); + } + [SuppressMessage( "Usage", "SecurityIntelliSenseCS:MS Security rules violation", @@ -193,6 +221,7 @@ public async Task SavePost(Post post) new XElement("content", post.Content), new XElement("ispublished", post.IsPublished), new XElement("categories", string.Empty), + new XElement("tags", string.Empty), new XElement("comments", string.Empty) )); @@ -202,6 +231,12 @@ public async Task SavePost(Post post) categories.Add(new XElement("category", category)); } + var tags = doc.XPathSelectElement("post/tags"); + foreach (var tag in post.Tags) + { + tags.Add(new XElement("tag", tag)); + } + var comments = doc.XPathSelectElement("post/comments"); foreach (var comment in post.Comments) { @@ -263,6 +298,18 @@ private static void LoadCategories(Post post, XElement doc) categories.Elements("category").Select(node => node.Value).ToList().ForEach(post.Categories.Add); } + private static void LoadTags(Post post, XElement doc) + { + var tags = doc.Element("tags"); + if (tags is null) + { + return; + } + + post.Tags.Clear(); + tags.Elements("tag").Select(node => node.Value).ToList().ForEach(post.Tags.Add); + } + private static void LoadComments(Post post, XElement doc) { var comments = doc.Element("comments"); @@ -342,6 +389,7 @@ private void LoadPosts() }; LoadCategories(post, doc); + LoadTags(post, doc); LoadComments(post, doc); this.cache.Add(post); } diff --git a/src/Services/IBlogService.cs b/src/Services/IBlogService.cs index eb14c6a7..36d771fd 100644 --- a/src/Services/IBlogService.cs +++ b/src/Services/IBlogService.cs @@ -11,6 +11,8 @@ public interface IBlogService IAsyncEnumerable GetCategories(); + IAsyncEnumerable GetTags(); + Task GetPostById(string id); Task GetPostBySlug(string slug); @@ -21,6 +23,8 @@ public interface IBlogService IAsyncEnumerable GetPostsByCategory(string category); + IAsyncEnumerable GetPostsByTag(string tag); + Task SaveFile(byte[] bytes, string fileName, string? suffix = null); Task SavePost(Post post); diff --git a/src/Services/InMemoryBlogServiceBase.cs b/src/Services/InMemoryBlogServiceBase.cs index 764beb4a..42926cc4 100644 --- a/src/Services/InMemoryBlogServiceBase.cs +++ b/src/Services/InMemoryBlogServiceBase.cs @@ -38,6 +38,24 @@ public virtual IAsyncEnumerable GetCategories() return categories; } + [SuppressMessage( + "Globalization", + "CA1308:Normalize strings to uppercase", + Justification = "Consumer preference.")] + public virtual IAsyncEnumerable GetTags() + { + var isAdmin = this.IsAdmin(); + + var tags = this.Cache + .Where(p => p.IsPublished || isAdmin) + .SelectMany(post => post.Tags) + .Select(tag => tag.ToLowerInvariant()) + .Distinct() + .ToAsyncEnumerable(); + + return tags; + } + public virtual Task GetPostById(string id) { var isAdmin = this.IsAdmin(); @@ -92,6 +110,18 @@ where p.Categories.Contains(category, StringComparer.OrdinalIgnoreCase) return posts.ToAsyncEnumerable(); } + public virtual IAsyncEnumerable GetPostsByTag(string tag) + { + var isAdmin = this.IsAdmin(); + + var posts = from p in this.Cache + where p.IsVisible() || isAdmin + where p.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase) + select p; + + return posts.ToAsyncEnumerable(); + } + public abstract Task SaveFile(byte[] bytes, string fileName, string? suffix = null); public abstract Task SavePost(Post post); diff --git a/src/Services/MetaWeblogService.cs b/src/Services/MetaWeblogService.cs index 2f8383ea..b4f14c1b 100644 --- a/src/Services/MetaWeblogService.cs +++ b/src/Services/MetaWeblogService.cs @@ -66,6 +66,7 @@ public async Task AddPostAsync(string blogid, string username, string pa }; post.categories.ToList().ForEach(newPost.Categories.Add); + post.mt_keywords.Split(',').ToList().ForEach(newPost.Tags.Add); if (post.dateCreated != DateTime.MinValue) { @@ -123,6 +124,8 @@ public async Task EditPostAsync(string postid, string username, string pas existing.IsPublished = publish; existing.Categories.Clear(); post.categories.ToList().ForEach(existing.Categories.Add); + existing.Tags.Clear(); + post.mt_keywords.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList().ForEach(existing.Tags.Add); if (post.dateCreated != DateTime.MinValue) { @@ -176,6 +179,20 @@ public async Task GetRecentPostsAsync(string blogid, string username, st .ToArrayAsync(); } + public async Task GetTagsAsync(string blogid, string username, string password) + { + this.ValidateUser(username, password); + + return await this.blog.GetTags() + .Select( + tag => + new Tag + { + name = tag + }) + .ToArrayAsync(); + } + public Task GetUserInfoAsync(string key, string username, string password) { this.ValidateUser(username, password); @@ -231,7 +248,8 @@ private Post ToMetaWebLogPost(Models.Post post) dateCreated = post.PubDate, mt_excerpt = post.Excerpt, description = post.Content, - categories = post.Categories.ToArray() + categories = post.Categories.ToArray(), + mt_keywords = string.Join(',', post.Tags) }; } diff --git a/src/Views/Blog/Edit.cshtml b/src/Views/Blog/Edit.cshtml index 870f1662..884a869e 100644 --- a/src/Views/Blog/Edit.cshtml +++ b/src/Views/Blog/Edit.cshtml @@ -1,9 +1,10 @@ -@model Post +@model Post @{ var isNew = string.IsNullOrEmpty(Model.Title); ViewData[Constants.Title] = "Edit " + (Model.Title ?? "new post"); var host = Context.Request.Host.ToString(); var allCats = ViewData[Constants.AllCats] as List ?? new List(); + var allTags = ViewData[Constants.AllTags] as List ?? new List(); } @section Head { @@ -24,11 +25,11 @@ The part of the URL that identifies this blog post
- - + + Select, or build a comma separated list of keywords - to remove double the keyword - + @foreach (var cat in allCats) {
+ + + + Select, or build a comma separated list of keywords - to remove double the keyword + + @foreach (var tag in allTags) + { + +
+ A brief description of the content of the post diff --git a/src/Views/Blog/Post.cshtml b/src/Views/Blog/Post.cshtml index 180d228c..14675f52 100644 --- a/src/Views/Blog/Post.cshtml +++ b/src/Views/Blog/Post.cshtml @@ -1,4 +1,4 @@ -@model Post +@model Post @inject IOptionsSnapshot settings @{ ViewData[Constants.Title] = Model.Title; @@ -33,29 +33,53 @@ } - @if (showFullPost) diff --git a/src/wwwroot/css/belowthefold.scss b/src/wwwroot/css/belowthefold.scss index 3891aa8d..1e9e205c 100644 --- a/src/wwwroot/css/belowthefold.scss +++ b/src/wwwroot/css/belowthefold.scss @@ -50,15 +50,16 @@ noscript p { } } -.categories { +.categories, .tags { list-style: none; display: inline; padding: 0; + margin-right: 20px; li { display: inline-block; - &:not(:first-child):not(:last-child):after { + &:not(:last-child):after { content: ", "; } } diff --git a/src/wwwroot/js/admin.js b/src/wwwroot/js/admin.js index c777990a..65576308 100644 --- a/src/wwwroot/js/admin.js +++ b/src/wwwroot/js/admin.js @@ -119,10 +119,48 @@ } } + // Category input enhancement - using autocomplete input + var selectcat = document.getElementById("selectcat"); + var categories = document.getElementById("categories"); + if (selectcat && categories) { + + selectcat.onchange = function () { + + var phv = selectcat.placeholder; + var val = selectcat.value.toLowerCase(); + + var phv_array = phv.split(",").map(function (item) { + return removeEmpty(item); + }); + + var val_array = val.split(",").map(function (item) { + return removeEmpty(item); + }); + + for (var j = val_array.length - 1; j >= 0; j--) { + var v = val_array[j]; + var flag = false; + for (var i = phv_array.length - 1; i >= 0; i--) { + if (phv_array[i] === v) { + phv_array.splice(i, 1); + flag = true; + } + } + if (!flag) { + phv_array.push(v); + } + } + + selectcat.placeholder = phv_array.join(", "); + categories.value = selectcat.placeholder; + selectcat.value = ""; + }; + } + // Tag input enhancement - using autocomplete input var selecttag = document.getElementById("selecttag"); - var categories = document.getElementById("categories"); - if (selecttag && categories) { + var tags = document.getElementById("tags"); + if (selecttag && tags) { selecttag.onchange = function () { @@ -152,7 +190,7 @@ } selecttag.placeholder = phv_array.join(", "); - categories.value = selecttag.placeholder; + tags.value = selecttag.placeholder; selecttag.value = ""; }; } diff --git a/src/wwwroot/wlwmanifest.xml b/src/wwwroot/wlwmanifest.xml index 6c46eadd..069668c8 100644 --- a/src/wwwroot/wlwmanifest.xml +++ b/src/wwwroot/wlwmanifest.xml @@ -3,7 +3,7 @@ Metaweblog Yes - No + Yes Yes Yes No @@ -12,7 +12,7 @@ No No No - No + Yes No No Yes