Voeg leesbare slug toe aan Offering naast het GUID-ID #10
@@ -24,10 +24,10 @@ public class OfferingsControllerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static OfferingDto PublicDto() => new(
|
private static OfferingDto PublicDto() => new(
|
||||||
Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false);
|
Guid.NewGuid().ToString(), "title", "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false);
|
||||||
|
|
||||||
private static OfferingAdminDto AdminDto(int displayOrder = 0) => new(
|
private static OfferingAdminDto AdminDto(int displayOrder = 0) => new(
|
||||||
Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder);
|
Guid.NewGuid().ToString(), "title", "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder);
|
||||||
|
|
||||||
private static CreateOfferingRequest CreateRequest() => new(
|
private static CreateOfferingRequest CreateRequest() => new(
|
||||||
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
||||||
|
|||||||
@@ -152,6 +152,37 @@ public class OfferingRepositoryTests : IDisposable
|
|||||||
result.Should().BeNull();
|
result.Should().BeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsBySlugAsync_ReturnsTrue_WhenSlugTaken()
|
||||||
|
{
|
||||||
|
await _sut.AddAsync(Offering("Website", displayOrder: 0));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.ExistsBySlugAsync("website");
|
||||||
|
|
||||||
|
result.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsBySlugAsync_ReturnsFalse_WhenSlugFree()
|
||||||
|
{
|
||||||
|
var result = await _sut.ExistsBySlugAsync("unused-slug");
|
||||||
|
|
||||||
|
result.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsBySlugAsync_ExcludesGivenId()
|
||||||
|
{
|
||||||
|
var offering = Offering("Website", displayOrder: 0);
|
||||||
|
await _sut.AddAsync(offering);
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.ExistsBySlugAsync("website", excludeId: offering.Id);
|
||||||
|
|
||||||
|
result.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Update_PersistsChanges()
|
public async Task Update_PersistsChanges()
|
||||||
{
|
{
|
||||||
@@ -170,6 +201,7 @@ public class OfferingRepositoryTests : IDisposable
|
|||||||
private static Offering Offering(string title, int displayOrder) => new()
|
private static Offering Offering(string title, int displayOrder) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
Slug = title.ToLowerInvariant(),
|
||||||
Title = title,
|
Title = title,
|
||||||
Description = "Description",
|
Description = "Description",
|
||||||
Price = "€ 100",
|
Price = "€ 100",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class OfferingsServiceTests
|
|||||||
private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new()
|
private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
Slug = "existing",
|
||||||
Title = "Existing",
|
Title = "Existing",
|
||||||
Description = "Description",
|
Description = "Description",
|
||||||
Price = "€ 100",
|
Price = "€ 100",
|
||||||
@@ -91,6 +92,57 @@ public class OfferingsServiceTests
|
|||||||
await _repo.DidNotReceive().BeginTransactionAsync();
|
await _repo.DidNotReceive().BeginTransactionAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Slug generation ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_GeneratesSlugFromTitle()
|
||||||
|
{
|
||||||
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
||||||
|
|
||||||
|
var dto = await CreateSut().CreateAsync(CreateRequest(), Guid.NewGuid());
|
||||||
|
|
||||||
|
dto.Slug.Should().Be("title");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_AppendsSuffix_WhenSlugAlreadyTaken()
|
||||||
|
{
|
||||||
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
||||||
|
_repo.ExistsBySlugAsync("title", null).Returns(true);
|
||||||
|
_repo.ExistsBySlugAsync("title-2", null).Returns(true);
|
||||||
|
_repo.ExistsBySlugAsync("title-3", null).Returns(false);
|
||||||
|
|
||||||
|
var dto = await CreateSut().CreateAsync(CreateRequest(), Guid.NewGuid());
|
||||||
|
|
||||||
|
dto.Slug.Should().Be("title-3");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RegeneratesSlug_WhenTitleChanges()
|
||||||
|
{
|
||||||
|
var target = ExistingOffering();
|
||||||
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
||||||
|
_repo.ExistsBySlugAsync("new-title", target.Id).Returns(false);
|
||||||
|
|
||||||
|
var request = new UpdateOfferingRequest("New Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
||||||
|
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
||||||
|
|
||||||
|
dto!.Slug.Should().Be("new-title");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_KeepsExistingSlug_WhenTitleUnchanged()
|
||||||
|
{
|
||||||
|
var target = ExistingOffering();
|
||||||
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
||||||
|
|
||||||
|
var request = new UpdateOfferingRequest(target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
||||||
|
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
||||||
|
|
||||||
|
dto!.Slug.Should().Be("existing");
|
||||||
|
await _repo.DidNotReceive().ExistsBySlugAsync(Arg.Any<string>(), Arg.Any<Guid?>());
|
||||||
|
}
|
||||||
|
|
||||||
// --- UpdateAsync ---
|
// --- UpdateAsync ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Tests.Services;
|
||||||
|
|
||||||
|
public class SlugifierTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Landingspagina", "landingspagina")]
|
||||||
|
[InlineData("Website", "website")]
|
||||||
|
[InlineData("Grote Website & Maatwerk", "grote-website-maatwerk")]
|
||||||
|
[InlineData(" Trimmed Title ", "trimmed-title")]
|
||||||
|
[InlineData("Café Ontwerp", "cafe-ontwerp")]
|
||||||
|
public void Slugify_ProducesReadableSlug(string title, string expected)
|
||||||
|
{
|
||||||
|
Slugifier.Slugify(title).Should().Be(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Slugify_FallsBackToPakket_WhenTitleHasNoAlphanumerics()
|
||||||
|
{
|
||||||
|
Slugifier.Slugify("---").Should().Be("pakket");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ namespace SlpModularCms.Modules.Offerings.Data.Entities;
|
|||||||
public class Offering
|
public class Offering
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public string Price { get; set; } = string.Empty;
|
public string Price { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class OfferingsDbContext(DbContextOptions<OfferingsDbContext> options) :
|
|||||||
{
|
{
|
||||||
entity.ToTable("OfferingsOfferings");
|
entity.ToTable("OfferingsOfferings");
|
||||||
entity.HasKey(e => e.Id);
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.Property(e => e.Slug).IsRequired().HasMaxLength(130);
|
||||||
entity.Property(e => e.Title).IsRequired().HasMaxLength(100);
|
entity.Property(e => e.Title).IsRequired().HasMaxLength(100);
|
||||||
entity.Property(e => e.Description).IsRequired().HasMaxLength(500);
|
entity.Property(e => e.Description).IsRequired().HasMaxLength(500);
|
||||||
entity.Property(e => e.Price).IsRequired().HasMaxLength(50);
|
entity.Property(e => e.Price).IsRequired().HasMaxLength(50);
|
||||||
|
|||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(OfferingsDbContext))]
|
||||||
|
[Migration("20260804113000_AddOfferingSlug")]
|
||||||
|
partial class AddOfferingSlug
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
modelBuilder.Entity("SlpModularCms.Modules.Offerings.Data.Entities.Offering", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.Property<string>("CtaLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("Featured")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Features")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<Guid>("LastModifiedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Price")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("PriceNote")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(130)
|
||||||
|
.HasColumnType("varchar(130)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("OfferingsOfferings", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOfferingSlug : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Slug",
|
||||||
|
table: "OfferingsOfferings",
|
||||||
|
type: "varchar(130)",
|
||||||
|
maxLength: 130,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
// Backfill existing rows with a title-derived slug so the NOT NULL column
|
||||||
|
// never holds an empty string for pre-existing data. New/edited rows get
|
||||||
|
// their slug from OfferingsService.GenerateUniqueSlugAsync instead, which
|
||||||
|
// also handles collisions this best-effort SQL backfill doesn't.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"UPDATE OfferingsOfferings " +
|
||||||
|
"SET Slug = LOWER(TRIM(BOTH '-' FROM REGEXP_REPLACE(TRIM(Title), '[^a-zA-Z0-9]+', '-'))) " +
|
||||||
|
"WHERE Slug = '';");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Slug",
|
||||||
|
table: "OfferingsOfferings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,11 @@ namespace SlpModularCms.Modules.Offerings.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("varchar(100)");
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(130)
|
||||||
|
.HasColumnType("varchar(130)");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
|
|||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
public record OfferingAdminDto(
|
public record OfferingAdminDto(
|
||||||
string Id,
|
string Id,
|
||||||
|
string Slug,
|
||||||
string Title,
|
string Title,
|
||||||
string Description,
|
string Description,
|
||||||
string Price,
|
string Price,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
|
|||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
public record OfferingDto(
|
public record OfferingDto(
|
||||||
string Id,
|
string Id,
|
||||||
|
string Slug,
|
||||||
string Title,
|
string Title,
|
||||||
string Description,
|
string Description,
|
||||||
string Price,
|
string Price,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public interface IOfferingRepository
|
|||||||
{
|
{
|
||||||
Task<IReadOnlyList<Offering>> GetAllAsync();
|
Task<IReadOnlyList<Offering>> GetAllAsync();
|
||||||
Task<Offering?> GetByIdAsync(Guid id);
|
Task<Offering?> GetByIdAsync(Guid id);
|
||||||
|
Task<bool> ExistsBySlugAsync(string slug, Guid? excludeId = null);
|
||||||
Task AddAsync(Offering offering);
|
Task AddAsync(Offering offering);
|
||||||
void Update(Offering offering);
|
void Update(Offering offering);
|
||||||
Task<int> GetMaxDisplayOrderAsync();
|
Task<int> GetMaxDisplayOrderAsync();
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public class OfferingRepository(OfferingsDbContext context) : IOfferingRepositor
|
|||||||
public async Task<Offering?> GetByIdAsync(Guid id)
|
public async Task<Offering?> GetByIdAsync(Guid id)
|
||||||
=> await context.Offerings.FirstOrDefaultAsync(o => o.Id == id);
|
=> await context.Offerings.FirstOrDefaultAsync(o => o.Id == id);
|
||||||
|
|
||||||
|
public async Task<bool> ExistsBySlugAsync(string slug, Guid? excludeId = null)
|
||||||
|
=> await context.Offerings.AnyAsync(o => o.Slug == slug && o.Id != (excludeId ?? Guid.Empty));
|
||||||
|
|
||||||
public async Task AddAsync(Offering offering)
|
public async Task AddAsync(Offering offering)
|
||||||
=> await context.Offerings.AddAsync(offering);
|
=> await context.Offerings.AddAsync(offering);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
|||||||
var offering = new Offering
|
var offering = new Offering
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
Slug = await GenerateUniqueSlugAsync(request.Title),
|
||||||
Title = request.Title,
|
Title = request.Title,
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
Price = request.Price,
|
Price = request.Price,
|
||||||
@@ -69,6 +70,9 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
|||||||
if (offering is null)
|
if (offering is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
if (!string.Equals(offering.Title, request.Title, StringComparison.Ordinal))
|
||||||
|
offering.Slug = await GenerateUniqueSlugAsync(request.Title, excludeId: offering.Id);
|
||||||
|
|
||||||
offering.Title = request.Title;
|
offering.Title = request.Title;
|
||||||
offering.Description = request.Description;
|
offering.Description = request.Description;
|
||||||
offering.Price = request.Price;
|
offering.Price = request.Price;
|
||||||
@@ -202,8 +206,24 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
|||||||
repository.Update(current);
|
repository.Update(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateUniqueSlugAsync(string title, Guid? excludeId = null)
|
||||||
|
{
|
||||||
|
var baseSlug = Slugifier.Slugify(title);
|
||||||
|
var slug = baseSlug;
|
||||||
|
var suffix = 2;
|
||||||
|
|
||||||
|
while (await repository.ExistsBySlugAsync(slug, excludeId))
|
||||||
|
{
|
||||||
|
slug = $"{baseSlug}-{suffix}";
|
||||||
|
suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
private static OfferingDto ToPublicDto(Offering o) => new(
|
private static OfferingDto ToPublicDto(Offering o) => new(
|
||||||
o.Id.ToString(),
|
o.Id.ToString(),
|
||||||
|
o.Slug,
|
||||||
o.Title,
|
o.Title,
|
||||||
o.Description,
|
o.Description,
|
||||||
o.Price,
|
o.Price,
|
||||||
@@ -215,6 +235,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
|||||||
|
|
||||||
private static OfferingAdminDto ToAdminDto(Offering o) => new(
|
private static OfferingAdminDto ToAdminDto(Offering o) => new(
|
||||||
o.Id.ToString(),
|
o.Id.ToString(),
|
||||||
|
o.Slug,
|
||||||
o.Title,
|
o.Title,
|
||||||
o.Description,
|
o.Description,
|
||||||
o.Price,
|
o.Price,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns a title into a URL/display-safe slug (lowercase, hyphen-separated, no diacritics).
|
||||||
|
/// Pure string transform — uniqueness is handled by the caller.
|
||||||
|
/// </summary>
|
||||||
|
public static class Slugifier
|
||||||
|
{
|
||||||
|
public static string Slugify(string value)
|
||||||
|
{
|
||||||
|
var normalized = value.Normalize(NormalizationForm.FormD);
|
||||||
|
var builder = new StringBuilder(normalized.Length);
|
||||||
|
var lastWasHyphen = false;
|
||||||
|
|
||||||
|
foreach (var c in normalized)
|
||||||
|
{
|
||||||
|
var category = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
|
||||||
|
if (category == System.Globalization.UnicodeCategory.NonSpacingMark)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (char.IsLetterOrDigit(c))
|
||||||
|
{
|
||||||
|
builder.Append(char.ToLowerInvariant(c));
|
||||||
|
lastWasHyphen = false;
|
||||||
|
}
|
||||||
|
else if (!lastWasHyphen && builder.Length > 0)
|
||||||
|
{
|
||||||
|
builder.Append('-');
|
||||||
|
lastWasHyphen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastWasHyphen)
|
||||||
|
builder.Length--;
|
||||||
|
|
||||||
|
return builder.Length > 0 ? builder.ToString() : "pakket";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user