Skip to content
Permalink
Browse files

fix: Update duplicate slug handling logic for categories, tags and bl…

…og posts #274
  • Loading branch information...
FanrayMedia committed Nov 4, 2018
1 parent b61ead8 commit e9cda3993dfb19c24d763e9553a1a8162bda65b1
@@ -1,9 +1,7 @@
using AutoMapper;
using Fan.Blog.Models;
using Fan.Helpers;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Fan.Blog.Helpers
{
@@ -18,38 +16,26 @@ public class BlogUtil
/// <remarks>
/// This method makes sure the result slug
/// - not to exceed max len;
/// - if <see cref="Util.FormatSlug(string)"/> returns empty string, it generates a random one;
/// - if <see cref="Util.Slugify(string)"/> returns empty string, it generates a random one;
/// - a unique value if its a duplicate with existings slugs;
/// - if '#' char is present, I swap it to 's'
/// </remarks>
public static string FormatTaxonomySlug(string title, int maxlen, IEnumerable<string> existingSlugs = null)
public static string SlugifyTaxonomy(string title, int maxlen, IEnumerable<string> existingSlugs = null)
{
// if user input exceeds max len, we trim
if (title.Length > maxlen)
{
title = title.Substring(0, maxlen);
}

title = title.Replace('#', 's'); // preserve # as s before format to slug
var slug = Util.FormatSlug(title); // remove/replace odd char, lower case etc
// preserve # as s before format to slug
title = title.Replace('#', 's');

// slug from title could be empty, e.g. the title is in Chinese
// then we generate a random string of 6 chars
if (slug.IsNullOrEmpty())
{
slug = Util.RandomString(6);
}
// make slug
var slug = Util.Slugify(title, randomCharCountOnEmpty: 6);

// make sure slug is unique
int i = 2;
if (existingSlugs != null)
{
while (existingSlugs.Contains(slug))
{
slug = $"{slug}-{i}";
i++;
}
}
slug = Util.UniquefySlug(slug, existingSlugs);

return slug;
}
@@ -41,7 +41,7 @@ public partial class BlogPostService : IBlogPostService
ILogger<BlogPostService> logger,
IMapper mapper,
IShortcodeService shortcodeService,
IMediator mediator)
IMediator mediator)
{
_settingSvc = settingService;
_postRepo = postRepo;
@@ -90,7 +90,8 @@ public async Task<BlogPost> CreateAsync(BlogPost blogPost)
var post = await PrepPostAsync(blogPost, ECreateOrUpdate.Create);

// before create
await _mediator.Publish(new BlogPostBeforeCreate {
await _mediator.Publish(new BlogPostBeforeCreate
{
CategoryTitle = blogPost.CategoryTitle,
TagTitles = blogPost.TagTitles
});
@@ -123,7 +124,8 @@ public async Task<BlogPost> UpdateAsync(BlogPost blogPost)
var post = await PrepPostAsync(blogPost, ECreateOrUpdate.Update);

// before update
await _mediator.Publish(new BlogPostBeforeUpdate {
await _mediator.Publish(new BlogPostBeforeUpdate
{
CategoryTitle = blogPost.CategoryTitle,
TagTitles = blogPost.TagTitles,
CurrentPost = await QueryPostAsync(blogPost.Id, EPostType.BlogPost),
@@ -371,7 +373,7 @@ private async Task<Post> PrepPostAsync(BlogPost blogPost, ECreateOrUpdate create
//var coreSettings = await _settingSvc.GetSettingsAsync<CoreSettings>();

// CreatedOn
if (createOrUpdate == ECreateOrUpdate.Create)
if (createOrUpdate == ECreateOrUpdate.Create)
{
// post time will be min value if user didn't set a time
post.CreatedOn = (blogPost.CreatedOn <= DateTimeOffset.MinValue) ? DateTimeOffset.UtcNow : blogPost.CreatedOn.ToUniversalTime();
@@ -433,7 +435,7 @@ private async Task<BlogPost> GetBlogPostAsync(Post post, bool parseShortcode)
if (blogPost.UpdatedOn.HasValue)
{
blogPost.UpdatedOnDisplay =
Util.ConvertTime(blogPost.UpdatedOn.Value, coreSettings.TimeZoneId).ToString("MM/dd/yyyy");
Util.ConvertTime(blogPost.UpdatedOn.Value, coreSettings.TimeZoneId).ToString("MM/dd/yyyy");
}

// Title
@@ -471,47 +473,38 @@ private async Task<BlogPost> GetBlogPostAsync(Post post, bool parseShortcode)
/// <param name="blogPostId">Used for making sure slug is unique when updating.</param>
/// <returns></returns>
/// <remarks>
/// If input is slug, either this is update or a create with user inputted slug, then <see cref="Util.FormatSlug(string)"/>
/// If input is slug, either this is update or a create with user inputted slug, then <see cref="Util.Slugify(string)"/>
/// will not alter it. This is very important for SEO as updating slug on an existing post will
/// break links in search results. On the other hand, if user deliberately updated the slug
/// when doing an update on post, then it will alter it accordingly. Please see the test case
/// on this method.
/// </remarks>
internal async Task<string> GetBlogPostSlugAsync(string input, DateTimeOffset createdOn, ECreateOrUpdate createOrUpdate, int blogPostId)
internal async Task<string> GetBlogPostSlugAsync(string input, DateTimeOffset createdOn, ECreateOrUpdate createOrUpdate, int blogPostId)
{
// when user manually inputted a slug, it could exceed max len
if (input.Length > TITLE_MAXLEN)
{
input = input.Substring(0, TITLE_MAXLEN);
}

// remove/replace odd char, lower case etc
var slug = Util.FormatSlug(input);

// slug from title could be empty, e.g. the title is in Chinese
// then we generate a random string of 6 chars
if (string.IsNullOrEmpty(slug))
{
slug = Util.RandomString(8);
}
// make slug
var slug = Util.Slugify(input, randomCharCountOnEmpty: 8);

// make sure slug is unique
int i = 2;
if (createOrUpdate == ECreateOrUpdate.Create) // create
{
while (await _postRepo.GetAsync(slug, createdOn.Year, createdOn.Month, createdOn.Day) != null)
{
slug = $"{slug}-{i}";
i++;
slug = Util.UniquefySlug(slug, ref i);
}
}
else // update
{
var p = await _postRepo.GetAsync(slug, createdOn.Year, createdOn.Month, createdOn.Day);
while (p != null && p.Id != blogPostId)
{
slug = $"{slug}-{i}";
i++;
slug = Util.UniquefySlug(slug, ref i);
p = await _postRepo.GetAsync(slug, createdOn.Year, createdOn.Month, createdOn.Day);
}
}
@@ -149,7 +149,7 @@ public async Task<Category> CreateAsync(string title, string description = null)
var category = new Category
{
Title = title,
Slug = BlogUtil.FormatTaxonomySlug(title, SLUG_MAXLEN, allCats.Select(c => c.Slug)),
Slug = BlogUtil.SlugifyTaxonomy(title, SLUG_MAXLEN, allCats.Select(c => c.Slug)),
Description = Util.CleanHtml(description),
Count = 0,
};
@@ -192,7 +192,7 @@ public async Task<Category> UpdateAsync(Category category)
// prep slug, description and count
var entity = await _catRepo.GetAsync(category.Id);
entity.Title = category.Title; // assign new title
entity.Slug = BlogUtil.FormatTaxonomySlug(category.Title, SLUG_MAXLEN, allCats.Select(c => c.Slug)); // slug is based on title
entity.Slug = BlogUtil.SlugifyTaxonomy(category.Title, SLUG_MAXLEN, allCats.Select(c => c.Slug)); // slug is based on title
entity.Description = Util.CleanHtml(category.Description);
entity.Count = category.Count;

@@ -320,7 +320,7 @@ private async Task DeleteImageFileAsync(Media media, EImageSize size)
}

// slug file name
var slug = Util.FormatSlug(fileNameWithoutExt);
var slug = Util.Slugify(fileNameWithoutExt);
if (slug.IsNullOrEmpty()) // slug may end up empty
{
slug = Util.RandomString(6);
@@ -345,15 +345,15 @@ private async Task DeleteImageFileAsync(Media media, EImageSize size)
/// <returns></returns>
private async Task<string> GetUniqueFileNameAsync(string fileNameSlugged, DateTimeOffset uploadedOn)
{
int i = 1;
int i = 2;
while (await _mediaSvc.ExistsAsync(m => m.AppType == EAppType.Blog &&
m.UploadedOn.Year == uploadedOn.Year &&
m.UploadedOn.Month == uploadedOn.Month &&
m.FileName.Equals(fileNameSlugged)))
{
var lookUp = ".";
var replace = $"-{i}.";
if (i > 1)
if (i > 2)
{
int j = i - 1;
lookUp = $"-{j}.";
@@ -231,7 +231,7 @@ private async Task<string> FormatPageSlugAsync(string input, int? parentId)
if (input.Length > TITLE_MAXLEN)
input = input.Substring(0, TITLE_MAXLEN);

var slug = Util.FormatSlug(input); // remove/replace odd char, lower case etc
var slug = Util.Slugify(input); // remove/replace odd char, lower case etc

// slug from title could be empty, e.g. the title is in Chinese
// then we generate a random string of 6 chars
@@ -152,7 +152,7 @@ public async Task<Tag> CreateAsync(Tag tag)
}

// prep slug, description and count
tag.Slug = BlogUtil.FormatTaxonomySlug(tag.Title, SLUG_MAXLEN, allTags.Select(c => c.Slug)); // slug is based on title
tag.Slug = BlogUtil.SlugifyTaxonomy(tag.Title, SLUG_MAXLEN, allTags.Select(c => c.Slug)); // slug is based on title
tag.Description = Util.CleanHtml(tag.Description);
tag.Count = tag.Count;

@@ -194,7 +194,7 @@ public async Task<Tag> UpdateAsync(Tag tag)
// prep slug, description and count
var entity = await _tagRepo.GetAsync(tag.Id);
entity.Title = tag.Title; // assign new title
entity.Slug = BlogUtil.FormatTaxonomySlug(tag.Title, SLUG_MAXLEN, allTags.Select(c => c.Slug)); // slug is based on title
entity.Slug = BlogUtil.SlugifyTaxonomy(tag.Title, SLUG_MAXLEN, allTags.Select(c => c.Slug)); // slug is based on title
entity.Description = Util.CleanHtml(tag.Description);
entity.Count = tag.Count;

@@ -1,6 +1,7 @@
using HtmlAgilityPack;
using Humanizer;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
@@ -10,18 +11,28 @@

namespace Fan.Helpers
{
/// <summary>
/// Utility helpers.
/// </summary>
public static class Util
{
/// <summary>
/// Produces optional, URL-friendly version of a title, "like-this-one".
/// hand-tuned for speed, reflects performance refactoring contributed by John Gietzen (user otac0n)
/// Returns the slug of a given string.
/// </summary>
/// <param name="title"></param>
/// <param name="randomCharCountOnEmpty">
/// The result slug could be empty if given title is non-english such as chinese, turn this
/// to false will return a random string of the specified number of chars instead of empty string.
/// </param>
/// <remarks>
/// Produces optional, URL-friendly version of a title, "like-this-one",
/// hand-tuned for speed, reflects performance refactoring contributed by John Gietzen (user otac0n)
/// http://stackoverflow.com/questions/25259/how-does-stackoverflow-generate-its-seo-friendly-urls
/// </remarks>
public static string FormatSlug(string title)
public static string Slugify(string title, int randomCharCountOnEmpty = 0)
{
if (title == null) return "";
if (title == null)
return randomCharCountOnEmpty <= 0 ? "" : Util.RandomString(randomCharCountOnEmpty);

const int maxlen = 80;
int len = title.Length;
@@ -57,10 +68,63 @@ public static string FormatSlug(string title)
if (i == maxlen) break;
}

if (prevdash)
return sb.ToString().Substring(0, sb.Length - 1);
var slug = prevdash ? sb.ToString().Substring(0, sb.Length - 1) : sb.ToString();
if (slug == string.Empty && randomCharCountOnEmpty > 0) slug = Util.RandomString(randomCharCountOnEmpty);

return slug;
}

/// <summary>
/// Returns a new slug by appending a counter to the given slug. This method is called
/// when caller already determined the slug is a duplicate.
/// </summary>
/// <param name="slug">The current slug that runs into conflict.</param>
/// <param name="i">The counter on what slug should be appended with.</param>
/// <returns></returns>
public static string UniquefySlug(string slug, IEnumerable<string> existingSlugs)
{
if (slug.IsNullOrEmpty() || existingSlugs.IsNullOrEmpty()) return slug;

int i = 2;
while (existingSlugs.Contains(slug))
{
var lookup = $"-{i}";
if (slug.EndsWith(lookup))
{
var idx = slug.LastIndexOf(lookup);
slug = slug.Remove(idx, lookup.Length).Insert(idx, $"-{++i}");
}
else
{
slug = $"{slug}-{i}";
}
}

return slug;
}

/// <summary>
/// Returns a new slug by appending a counter to the given slug.
/// </summary>
/// <param name="slug"></param>
/// <param name="i"></param>
/// <returns></returns>
public static string UniquefySlug(string slug, ref int i)
{
if (slug.IsNullOrEmpty()) return slug;

var lookup = $"-{i}";
if (slug.EndsWith(lookup))
{
var idx = slug.LastIndexOf(lookup);
slug = slug.Remove(idx, lookup.Length).Insert(idx, $"-{++i}");
}
else
return sb.ToString();
{
slug = $"{slug}-{i}";
}

return slug;
}

/// <summary>
@@ -181,7 +245,7 @@ public static string GetExcerpt(string body, int wordsLimit)
if (body.IsNullOrEmpty()) return "";

// html entities https://stackoverflow.com/a/10971380/32240
body = WebUtility.HtmlDecode(body);
body = WebUtility.HtmlDecode(body);

return body.Truncate(wordsLimit, Truncator.FixedNumberOfWords);
}
@@ -57,7 +57,7 @@ public async void Author_can_upload_images_from_Composer_or_MediaGallery()
Assert.Equal(contentType, media.ContentType);

// with a unique name
Assert.Equal("fanray-logo-1.png", media.FileName);
Assert.Equal("fanray-logo-2.png", media.FileName);

// 0 resized, only orig was saved
Assert.Equal(0, media.ResizeCount);
@@ -239,7 +239,7 @@ public async void Author_can_upload_images_from_OLW()
var uploadedOn = DateTimeOffset.UtcNow;
var year = uploadedOn.Year.ToString();
var month = uploadedOn.Month.ToString("d2");
var expectedUrl = $"{STORAGE_ENDPOINT}/media/blog/{year}/{month}/fanray-logo-1.png";
var expectedUrl = $"{STORAGE_ENDPOINT}/media/blog/{year}/{month}/fanray-logo-2.png";
Assert.Equal(expectedUrl, mediaInfo.Url);

// and storage provider is called only once since it's a tiny image

0 comments on commit e9cda39

Please sign in to comment.
You can’t perform that action at this time.