diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index da73e5e3..992201f9 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,114 +1,114 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/web/MZikmund.Web.Core/MZikmund.Web.Core.csproj b/src/web/MZikmund.Web.Core/MZikmund.Web.Core.csproj index a3cc6035..633cf5db 100644 --- a/src/web/MZikmund.Web.Core/MZikmund.Web.Core.csproj +++ b/src/web/MZikmund.Web.Core/MZikmund.Web.Core.csproj @@ -13,6 +13,7 @@ + diff --git a/src/web/MZikmund.Web.Core/Syndication/FeedEntry.cs b/src/web/MZikmund.Web.Core/Syndication/FeedEntry.cs index 0fe76a48..e5c4ffed 100644 --- a/src/web/MZikmund.Web.Core/Syndication/FeedEntry.cs +++ b/src/web/MZikmund.Web.Core/Syndication/FeedEntry.cs @@ -4,7 +4,9 @@ public record FeedEntry { public string Id { get; set; } = ""; - public DateTimeOffset PubDate { get; set; } + public DateTimeOffset PublishedDate { get; set; } + + public DateTimeOffset UpdatedDate { get; set; } public string Title { get; set; } = ""; diff --git a/src/web/MZikmund.Web.Core/Syndication/FeedGenerator.cs b/src/web/MZikmund.Web.Core/Syndication/FeedGenerator.cs index 762f7b80..52f2494a 100644 --- a/src/web/MZikmund.Web.Core/Syndication/FeedGenerator.cs +++ b/src/web/MZikmund.Web.Core/Syndication/FeedGenerator.cs @@ -1,11 +1,15 @@ // Based on https://github.com/EdiWang/Moonglade/blob/cf5571b0db09c7722b310ca9922cdcd542e93a51/src/Moonglade.Syndication/FeedGenerator.cs -using Edi.SyndicationFeed.ReaderWriter; using Edi.SyndicationFeed.ReaderWriter.Atom; -using Edi.SyndicationFeed.ReaderWriter.Rss; using Microsoft.AspNetCore.Http; +using System.ServiceModel.Syndication; +using System; using System.Text; using System.Xml; +using EwSyndicationItem = Edi.SyndicationFeed.ReaderWriter.SyndicationItem; +using EwSyndicationLink = Edi.SyndicationFeed.ReaderWriter.SyndicationLink; +using EwSyndicationCategory = Edi.SyndicationFeed.ReaderWriter.SyndicationCategory; +using EwSyndicationPerson = Edi.SyndicationFeed.ReaderWriter.SyndicationPerson; namespace MZikmund.Web.Core.Syndication; @@ -18,7 +22,7 @@ public FeedGenerator(IHttpContextAccessor httpContextAccessor) HostUrl = baseUrl!; HeadTitle = "Martin Zikmund"; HeadDescription = "Open-source enthusiast and Microsoft MVP. Passionate speaker, avid climber, and Lego aficionado."; - Copyright = "©2023 Martin Zikmund"; + Copyright = $"©{DateTimeOffset.UtcNow.Year} Martin Zikmund"; Generator = "MZikmund"; GeneratorVersion = "0.1"; TrackBackUrl = baseUrl!; @@ -42,28 +46,29 @@ public FeedGenerator(IHttpContextAccessor httpContextAccessor) public string Language { get; set; } - public async Task GetRssAsync(IEnumerable feedEntries) + public async Task GetRssAsync(IEnumerable feedEntries, string id) { + var feed = new SyndicationFeed(HeadTitle, HeadDescription, new Uri("https://mzikmund.dev/rss"), id, DateTimeOffset.UtcNow); + feed.Copyright = new TextSyndicationContent(Copyright); + + var items = GetItemCollection(feedEntries); + feed.Items = items; // TODO: Generate additional properties (image, etc.) - var feed = GetItemCollection(feedEntries); var sw = new StringWriterWithEncoding(Encoding.UTF8); - await using (var xmlWriter = XmlWriter.Create(sw, new() { Async = true })) - { - var writer = new RssFeedWriter(xmlWriter); - await writer.WriteTitle(HeadTitle); - await writer.WriteDescription(HeadDescription); - await writer.Write(new SyndicationLink(new(TrackBackUrl))); - await writer.WritePubDate(DateTimeOffset.UtcNow); - await writer.WriteCopyright(Copyright); - await writer.WriteGenerator(Generator); - await writer.WriteLanguage(new(Language)); + var settings = new XmlWriterSettings + { + Encoding = Encoding.UTF8, + NewLineHandling = NewLineHandling.Entitize, + NewLineOnAttributes = true, + Indent = true + }; - foreach (var item in feed) - { - await writer.Write(item); - } + await using (var xmlWriter = XmlWriter.Create(sw, new() { Async = true })) + { + var rssFormatter = new Rss20FeedFormatter(feed, false); + rssFormatter.WriteTo(xmlWriter); await xmlWriter.FlushAsync(); xmlWriter.Close(); @@ -75,7 +80,7 @@ public async Task GetRssAsync(IEnumerable feedEntries) public async Task GetAtomAsync(IEnumerable feedEntries) { - var feed = GetItemCollection(feedEntries); + var feed = GetEwItemCollection(feedEntries); var sw = new StringWriterWithEncoding(Encoding.UTF8); await using (var xmlWriter = XmlWriter.Create(sw, new() { Async = true })) @@ -101,29 +106,29 @@ public async Task GetAtomAsync(IEnumerable feedEntries) return xml; } - private static List GetItemCollection(IEnumerable itemCollection) + private static List GetEwItemCollection(IEnumerable itemCollection) { - var synItemCollection = new List(); + var synItemCollection = new List(); if (null == itemCollection) return synItemCollection; foreach (var item in itemCollection) { // create rss item - var sItem = new SyndicationItem + var sItem = new EwSyndicationItem { Id = item.Id, Title = item.Title, Description = item.Description, - LastUpdated = item.PubDate.ToUniversalTime(), - Published = item.PubDate.ToUniversalTime() + LastUpdated = item.UpdatedDate.ToUniversalTime(), + Published = item.PublishedDate.ToUniversalTime() }; - sItem.AddLink(new SyndicationLink(new(item.Link))); + sItem.AddLink(new EwSyndicationLink(new(item.Link))); // add author if (!string.IsNullOrWhiteSpace(item.Author) && !string.IsNullOrWhiteSpace(item.AuthorEmail)) { - sItem.AddContributor(new SyndicationPerson(item.Author, item.AuthorEmail)); + sItem.AddContributor(new EwSyndicationPerson(item.Author, item.AuthorEmail)); } // add categories @@ -131,13 +136,41 @@ private static List GetItemCollection(IEnumerable it { foreach (var itemCategory in item.Categories) { - sItem.AddCategory(new SyndicationCategory(itemCategory)); + sItem.AddCategory(new EwSyndicationCategory(itemCategory)); } } synItemCollection.Add(sItem); } return synItemCollection; } + + private List GetItemCollection(IEnumerable itemCollection) + { + var syndicationItems = new List(); + if (null == itemCollection) + { + return syndicationItems; + } + + foreach (var item in itemCollection) + { + // create rss item + var syndicationItem = new SyndicationItem(item.Title, item.Description, new Uri(item.Link), item.Id, item.UpdatedDate); + syndicationItem.Authors.Add(new SyndicationPerson(item.AuthorEmail, item.Author, HostUrl)); + + // add categories + if (item.Categories is { Length: > 0 }) + { + foreach (var itemCategory in item.Categories) + { + syndicationItem.Categories.Add(new SyndicationCategory(itemCategory)); + } + } + + syndicationItems.Add(syndicationItem); + } + return syndicationItems; + } } public class StringWriterWithEncoding : StringWriter diff --git a/src/web/MZikmund.Web.Core/Syndication/GetRssHandler.cs b/src/web/MZikmund.Web.Core/Syndication/GetRssHandler.cs index 1cd221e6..7386a64a 100644 --- a/src/web/MZikmund.Web.Core/Syndication/GetRssHandler.cs +++ b/src/web/MZikmund.Web.Core/Syndication/GetRssHandler.cs @@ -19,12 +19,12 @@ public GetRssHandler( public async Task Handle(GetRssQuery request, CancellationToken ct) { - var data = await _syndicationDataSource.GetFeedDataAsync(request.CategoryName); + var data = await _syndicationDataSource.GetFeedDataAsync(request.CategoryName, request.TagName); if (data is null) { return null; } - return await _feedGenerator.GetRssAsync(data); + return await _feedGenerator.GetRssAsync(data, request.CategoryName ?? "Default"); } } diff --git a/src/web/MZikmund.Web.Core/Syndication/GetRssQuery.cs b/src/web/MZikmund.Web.Core/Syndication/GetRssQuery.cs index 9f30a645..9ec89500 100644 --- a/src/web/MZikmund.Web.Core/Syndication/GetRssQuery.cs +++ b/src/web/MZikmund.Web.Core/Syndication/GetRssQuery.cs @@ -2,4 +2,4 @@ namespace MZikmund.Web.Core.Syndication; -public record GetRssQuery(string? CategoryName) : IRequest; +public record GetRssQuery(string? CategoryName, string? TagName) : IRequest; diff --git a/src/web/MZikmund.Web.Core/Syndication/IFeedGenerator.cs b/src/web/MZikmund.Web.Core/Syndication/IFeedGenerator.cs index 74851e4f..41a1ba49 100644 --- a/src/web/MZikmund.Web.Core/Syndication/IFeedGenerator.cs +++ b/src/web/MZikmund.Web.Core/Syndication/IFeedGenerator.cs @@ -2,7 +2,7 @@ public interface IFeedGenerator { - Task GetRssAsync(IEnumerable feedEntries); + Task GetRssAsync(IEnumerable feedEntries, string id); Task GetAtomAsync(IEnumerable feedEntries); } diff --git a/src/web/MZikmund.Web.Core/Syndication/ISyndicationDataSource.cs b/src/web/MZikmund.Web.Core/Syndication/ISyndicationDataSource.cs index 1fac9558..c5f28fb2 100644 --- a/src/web/MZikmund.Web.Core/Syndication/ISyndicationDataSource.cs +++ b/src/web/MZikmund.Web.Core/Syndication/ISyndicationDataSource.cs @@ -4,5 +4,5 @@ namespace MZikmund.Web.Core.Syndication; public interface ISyndicationDataSource { - Task?> GetFeedDataAsync(string? categoryRouteName = null); + Task?> GetFeedDataAsync(string? categoryRouteName = null, string? tagRouteName = null); } diff --git a/src/web/MZikmund.Web.Core/Syndication/SyndicationDataSource.cs b/src/web/MZikmund.Web.Core/Syndication/SyndicationDataSource.cs index 528603e2..723810fe 100644 --- a/src/web/MZikmund.Web.Core/Syndication/SyndicationDataSource.cs +++ b/src/web/MZikmund.Web.Core/Syndication/SyndicationDataSource.cs @@ -1,8 +1,6 @@ // Based on https://github.com/EdiWang/Moonglade/blob/cf5571b0db09c7722b310ca9922cdcd542e93a51/src/Moonglade.Syndication/SyndicationDataSource.cs using Microsoft.AspNetCore.Http; - -using Microsoft.Extensions.Configuration; using MZikmund.Web.Configuration; using MZikmund.Web.Core.Services; using MZikmund.Web.Data.Entities; @@ -15,6 +13,7 @@ public class SyndicationDataSource : ISyndicationDataSource { private readonly string _baseUrl; private readonly IRepository _categoriesRepository; + private readonly IRepository _tagsRepository; private readonly IRepository _postsRepository; private readonly IPostContentProcessor _postContentProcessor; private readonly ISiteConfiguration _siteConfiguration; @@ -22,11 +21,13 @@ public class SyndicationDataSource : ISyndicationDataSource public SyndicationDataSource( IHttpContextAccessor httpContextAccessor, IRepository categoriesRepository, + IRepository tagsRepository, IRepository postsRepository, IPostContentProcessor postContentProcessor, ISiteConfiguration siteConfiguration) { _categoriesRepository = categoriesRepository; + _tagsRepository = tagsRepository; _postsRepository = postsRepository; _postContentProcessor = postContentProcessor; _siteConfiguration = siteConfiguration; @@ -34,18 +35,28 @@ public SyndicationDataSource( _baseUrl = $"{acc.HttpContext.Request.Scheme}://{acc.HttpContext.Request.Host}"; } - public async Task?> GetFeedDataAsync(string? categoryRouteName = null) + public async Task?> GetFeedDataAsync(string? categoryRouteName = null, string? tagRouteName = null) { IReadOnlyList itemCollection; if (!string.IsNullOrWhiteSpace(categoryRouteName)) { - var cat = await _categoriesRepository.GetAsync(c => c.RouteName == categoryRouteName); - if (cat is null) + var category = await _categoriesRepository.GetAsync(c => c.RouteName == categoryRouteName); + if (category is null) + { + return null; + } + + itemCollection = await GetFeedEntriesAsync(category.Id); + } + else if (!string.IsNullOrWhiteSpace(tagRouteName)) + { + var tag = await _tagsRepository.GetAsync(c => c.RouteName == tagRouteName); + if (tag is null) { return null; } - itemCollection = await GetFeedEntriesAsync(cat.Id); + itemCollection = await GetFeedEntriesAsync(null, tag.Id); } else { @@ -55,15 +66,16 @@ public SyndicationDataSource( return itemCollection; } - private async Task> GetFeedEntriesAsync(Guid? categoryId = null) + private async Task> GetFeedEntriesAsync(Guid? categoryId = null, Guid? tagId = null) { - var specification = new ListPostsSpecification(1, 15, categoryId, null); + var specification = new ListPostsSpecification(1, 15, categoryId, tagId); var posts = await _postsRepository.SelectAsync(specification, post => new FeedEntry { Id = post.Id.ToString(), Title = post.Title, - PubDate = post.PublishedDate ?? DateTimeOffset.UtcNow, - Description = post.Content, // TODO: Do we want full content here? + PublishedDate = post.PublishedDate ?? DateTimeOffset.UtcNow, + UpdatedDate = post.LastModifiedDate ?? DateTimeOffset.UtcNow, + Description = post.Content, Link = $"{_baseUrl}/blog/{post.RouteName}", Author = $"{_siteConfiguration.Author.FirstName} {_siteConfiguration.Author.LastName}", AuthorEmail = _siteConfiguration.Author.Email, diff --git a/src/web/MZikmund.Web/Controllers/SyndicationController.cs b/src/web/MZikmund.Web/Controllers/SyndicationController.cs index 7d7f5229..de43dec3 100644 --- a/src/web/MZikmund.Web/Controllers/SyndicationController.cs +++ b/src/web/MZikmund.Web/Controllers/SyndicationController.cs @@ -30,7 +30,7 @@ public async Task Opml() ContentInfo = categoryMap, BlogUrl = $"{rootUrl}/blog", RssUrl = $"{rootUrl}/rss", - RssCategoryTemplate = $"{rootUrl}/rss/[catTitle]", + RssCategoryTemplate = $"{rootUrl}/rss/category/[catTitle]", BlogCategoryTemplate = $"{rootUrl}/blog/categories/[catTitle]" }; @@ -38,10 +38,21 @@ public async Task Opml() return Content(xml, "text/xml"); } - [HttpGet("rss/{categoryName?}")] - public async Task Rss([MaxLength(64)] string? categoryName = null) + [HttpGet("rss")] + public async Task Rss() => await GenerateRssResponseAsync(new GetRssQuery(null, null)); + + [HttpGet("rss/category/{categoryName}")] + public async Task RssByCategory([MaxLength(64)] string categoryName) => + await GenerateRssResponseAsync(new GetRssQuery(categoryName, null)); + + [HttpGet("rss/tag/{tagName}")] + public async Task RssByTag([MaxLength(64)] string tagName) => + await GenerateRssResponseAsync(new GetRssQuery(null, tagName)); + + [HttpGet("atom/{categoryName?}")] + public async Task Atom([MaxLength(64)] string? categoryName = null) { - var xml = await _mediator.Send(new GetRssQuery(categoryName)); + var xml = await _mediator.Send(new GetAtomQuery(categoryName)); if (string.IsNullOrWhiteSpace(xml)) { return NotFound(); @@ -50,15 +61,14 @@ public async Task Rss([MaxLength(64)] string? categoryName = null return Content(xml, "text/xml"); } - [HttpGet("atom/{categoryName?}")] - public async Task Atom([MaxLength(64)] string? categoryName = null) + private async Task GenerateRssResponseAsync(GetRssQuery query) { - var xml = await _mediator.Send(new GetAtomQuery(categoryName)); + var xml = await _mediator.Send(query); if (string.IsNullOrWhiteSpace(xml)) { return NotFound(); } - return Content(xml, "text/xml"); + return Content(xml, "application/rss+xml; charset=utf-8"); } }