diff --git a/src/NoteBookmark.Api.Tests/Domain/FeedItemStateTests.cs b/src/NoteBookmark.Api.Tests/Domain/FeedItemStateTests.cs new file mode 100644 index 0000000..7e5fff2 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Domain/FeedItemStateTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using NoteBookmark.Domain; +using Xunit; + +namespace NoteBookmark.Api.Tests.Domain; + +public class FeedItemStateTests +{ + [Theory] + [InlineData("https://Example.COM/post/", "https://example.com/post")] + [InlineData(" https://example.com/post#comments ", "https://example.com/post")] + [InlineData("https://example.com/", "https://example.com/")] + [InlineData("https://example.com/post?id=1", "https://example.com/post?id=1")] + public void NormalizeUrl_RemovesCosmeticDifferences(string input, string expected) + { + FeedItemState.NormalizeUrl(input).Should().Be(expected); + } + + [Fact] + public void NormalizeUrl_KeepsPathCase() + { + FeedItemState.NormalizeUrl("https://example.com/My-Post").Should().Be("https://example.com/My-Post"); + } + + [Fact] + public void KeyForUrl_IsSameForEquivalentUrls() + { + FeedItemState.KeyForUrl("https://EXAMPLE.com/a/#top") + .Should().Be(FeedItemState.KeyForUrl("https://example.com/a")); + } + + [Fact] + public void KeyForUrl_DiffersForDifferentUrls() + { + FeedItemState.KeyForUrl("https://example.com/a") + .Should().NotBe(FeedItemState.KeyForUrl("https://example.com/b")); + } + + [Fact] + public void KeyForUrl_IsValidTableKey() + { + var key = FeedItemState.KeyForUrl("https://example.com/path?q=1#x\\y"); + + key.Should().MatchRegex("^[0-9a-f]{64}$"); + } + + [Theory] + [InlineData("Added", true)] + [InlineData("Skipped", true)] + [InlineData("added", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsValidState_AcceptsOnlyAddedAndSkipped(string? state, bool expected) + { + FeedItemState.IsValidState(state).Should().Be(expected); + } + + [Fact] + public void NewFeedAndGroup_GetPartitionAndGeneratedId() + { + var group = new FeedGroup { Name = "Dev" }; + var feed = new Feed { FeedUrl = "https://example.com/rss", GroupId = group.RowKey }; + + group.PartitionKey.Should().Be(FeedGroup.Partition); + feed.PartitionKey.Should().Be(Feed.Partition); + Guid.TryParse(group.RowKey, out _).Should().BeTrue(); + Guid.TryParse(feed.RowKey, out _).Should().BeTrue(); + group.RowKey.Should().NotBe(feed.RowKey); + } +} diff --git a/src/NoteBookmark.Api.Tests/Endpoints/FeedEndpointsTests.cs b/src/NoteBookmark.Api.Tests/Endpoints/FeedEndpointsTests.cs new file mode 100644 index 0000000..05fd618 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Endpoints/FeedEndpointsTests.cs @@ -0,0 +1,322 @@ +using Azure.Data.Tables; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NoteBookmark.Api.Tests.Fixtures; +using NoteBookmark.Domain; +using System.Net; +using System.Net.Http.Json; +using Xunit; + +namespace NoteBookmark.Api.Tests.Endpoints; + +public class FeedEndpointsTests : IClassFixture +{ + private readonly NoteBookmarkApiTestFactory _factory; + private readonly HttpClient _client; + + public FeedEndpointsTests(NoteBookmarkApiTestFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + private static string Unique(string prefix) => $"{prefix}-{Guid.NewGuid():N}"; + + private async Task CreateGroup(string? name = null) + { + var response = await _client.PostAsJsonAsync("/api/feeds/groups", new FeedGroup { Name = name ?? Unique("group") }); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } + + private async Task CreateFeed(string groupId, string? url = null) + { + var response = await _client.PostAsJsonAsync("/api/feeds/", new Feed + { + Title = "Test feed", + FeedUrl = url ?? $"https://example.com/{Guid.NewGuid():N}/rss", + GroupId = groupId + }); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } + + // ----- Groups ----- + + [Fact] + public async Task SaveGroup_ThenGetGroups_ReturnsIt() + { + var group = await CreateGroup(); + + var groups = await _client.GetFromJsonAsync>("/api/feeds/groups"); + + groups.Should().ContainSingle(g => g.RowKey == group.RowKey).Which.Name.Should().Be(group.Name); + } + + [Fact] + public async Task SaveGroup_TrimsName() + { + var name = Unique("trim"); + + var group = await CreateGroup($" {name} "); + + group.Name.Should().Be(name); + } + + [Fact] + public async Task SaveGroup_WithExistingId_RenamesGroup() + { + var group = await CreateGroup(); + group.Name = Unique("renamed"); + + var response = await _client.PostAsJsonAsync("/api/feeds/groups", group); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var groups = await _client.GetFromJsonAsync>("/api/feeds/groups"); + groups!.Where(g => g.RowKey == group.RowKey).Should().ContainSingle().Which.Name.Should().Be(group.Name); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task SaveGroup_WithoutName_ReturnsBadRequest(string name) + { + var response = await _client.PostAsJsonAsync("/api/feeds/groups", new FeedGroup { Name = name }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task SaveGroup_WithDuplicateName_ReturnsConflict() + { + var group = await CreateGroup(); + + var response = await _client.PostAsJsonAsync("/api/feeds/groups", new FeedGroup { Name = group.Name.ToUpperInvariant() }); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task DeleteGroup_WhenEmpty_RemovesIt() + { + var group = await CreateGroup(); + + var response = await _client.DeleteAsync($"/api/feeds/groups/{group.RowKey}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var groups = await _client.GetFromJsonAsync>("/api/feeds/groups"); + groups.Should().NotContain(g => g.RowKey == group.RowKey); + } + + [Fact] + public async Task DeleteGroup_WithFeeds_ReturnsConflict() + { + var group = await CreateGroup(); + await CreateFeed(group.RowKey); + + var response = await _client.DeleteAsync($"/api/feeds/groups/{group.RowKey}"); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task DeleteGroup_WhenMissing_ReturnsNotFound() + { + var response = await _client.DeleteAsync($"/api/feeds/groups/{Guid.NewGuid()}"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ----- Feeds ----- + + [Fact] + public async Task SaveFeed_ThenGetFeeds_ReturnsIt() + { + var group = await CreateGroup(); + var feed = await CreateFeed(group.RowKey); + + var feeds = await _client.GetFromJsonAsync>("/api/feeds/"); + + var saved = feeds.Should().ContainSingle(f => f.RowKey == feed.RowKey).Which; + saved.FeedUrl.Should().Be(feed.FeedUrl); + saved.GroupId.Should().Be(group.RowKey); + } + + [Fact] + public async Task SaveFeed_WithoutTitle_UsesFeedUrl() + { + var group = await CreateGroup(); + var url = $"https://example.com/{Guid.NewGuid():N}/atom"; + + var response = await _client.PostAsJsonAsync("/api/feeds/", new Feed { FeedUrl = url, GroupId = group.RowKey }); + + var feed = await response.Content.ReadFromJsonAsync(); + feed!.Title.Should().Be(url); + } + + [Fact] + public async Task SaveFeed_WithExistingId_MovesFeedToAnotherGroup() + { + var first = await CreateGroup(); + var second = await CreateGroup(); + var feed = await CreateFeed(first.RowKey); + feed.GroupId = second.RowKey; + + var response = await _client.PostAsJsonAsync("/api/feeds/", feed); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var feeds = await _client.GetFromJsonAsync>("/api/feeds/"); + feeds!.Where(f => f.RowKey == feed.RowKey).Should().ContainSingle().Which.GroupId.Should().Be(second.RowKey); + } + + [Theory] + [InlineData("")] + [InlineData("not a url")] + [InlineData("ftp://example.com/rss")] + public async Task SaveFeed_WithInvalidUrl_ReturnsBadRequest(string url) + { + var group = await CreateGroup(); + + var response = await _client.PostAsJsonAsync("/api/feeds/", new Feed { FeedUrl = url, GroupId = group.RowKey }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task SaveFeed_WithUnknownGroup_ReturnsBadRequest() + { + var response = await _client.PostAsJsonAsync("/api/feeds/", new Feed + { + FeedUrl = "https://example.com/rss", + GroupId = Guid.NewGuid().ToString() + }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task SaveFeed_WithDuplicateUrl_ReturnsConflict() + { + var group = await CreateGroup(); + var feed = await CreateFeed(group.RowKey); + + var response = await _client.PostAsJsonAsync("/api/feeds/", new Feed + { + // Same feed, spelled with an uppercase host and a trailing slash. + FeedUrl = feed.FeedUrl.Replace("https://example.com", "https://EXAMPLE.com") + "/", + GroupId = group.RowKey + }); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task DeleteFeed_RemovesIt_ThenReturnsNotFound() + { + var group = await CreateGroup(); + var feed = await CreateFeed(group.RowKey); + + var first = await _client.DeleteAsync($"/api/feeds/{feed.RowKey}"); + var second = await _client.DeleteAsync($"/api/feeds/{feed.RowKey}"); + + first.StatusCode.Should().Be(HttpStatusCode.OK); + second.StatusCode.Should().Be(HttpStatusCode.NotFound); + var feeds = await _client.GetFromJsonAsync>("/api/feeds/"); + feeds.Should().NotContain(f => f.RowKey == feed.RowKey); + } + + // ----- Item state ----- + + [Fact] + public async Task SaveItemState_ThenGetStates_ReturnsItKeyedByUrl() + { + var url = $"https://example.com/post/{Guid.NewGuid():N}"; + + var response = await _client.PostAsJsonAsync("/api/feeds/items/state", + new FeedItemState { Url = url, State = FeedItemState.Added, FeedId = "feed-1" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var states = await _client.GetFromJsonAsync>("/api/feeds/items/states"); + var saved = states.Should().ContainSingle(s => s.Url == url).Which; + saved.State.Should().Be(FeedItemState.Added); + saved.RowKey.Should().Be(FeedItemState.KeyForUrl(url)); + } + + [Fact] + public async Task SaveItemState_Twice_KeepsOneRecordWithLatestState() + { + var url = $"https://example.com/post/{Guid.NewGuid():N}"; + + await _client.PostAsJsonAsync("/api/feeds/items/state", new FeedItemState { Url = url, State = FeedItemState.Skipped }); + await _client.PostAsJsonAsync("/api/feeds/items/state", new FeedItemState { Url = url + "#again", State = FeedItemState.Added }); + + var states = await _client.GetFromJsonAsync>("/api/feeds/items/states"); + states!.Where(s => s.RowKey == FeedItemState.KeyForUrl(url)).Should().ContainSingle() + .Which.State.Should().Be(FeedItemState.Added); + } + + [Fact] + public async Task SaveItemStates_MarksManyItems() + { + // More than one table transaction (100 max) worth of items. + var urls = Enumerable.Range(0, 120).Select(i => $"https://example.com/bulk/{Guid.NewGuid():N}/{i}").ToList(); + var states = urls.Select(u => new FeedItemState { Url = u, State = FeedItemState.Skipped }).ToList(); + + var response = await _client.PostAsJsonAsync("/api/feeds/items/states", states); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var saved = await _client.GetFromJsonAsync>("/api/feeds/items/states"); + saved!.Select(s => s.Url).Should().Contain(urls); + } + + [Theory] + [InlineData("https://example.com/post", "Read")] + [InlineData("https://example.com/post", "")] + [InlineData("not a url", "Added")] + public async Task SaveItemState_WithInvalidInput_ReturnsBadRequest(string url, string state) + { + var response = await _client.PostAsJsonAsync("/api/feeds/items/state", new FeedItemState { Url = url, State = state }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task SaveItemStates_WithEmptyList_ReturnsBadRequest() + { + var response = await _client.PostAsJsonAsync("/api/feeds/items/states", new List()); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetItemStates_PrunesOldSkippedButKeepsOldAddedAndRecentSkipped() + { + var table = _factory.Services.GetRequiredService().GetTableClient("FeedItemStates"); + await table.CreateIfNotExistsAsync(); + FeedItemState Make(string state, int daysAgo) + { + var url = $"https://example.com/prune/{Guid.NewGuid():N}"; + return new FeedItemState + { + Url = url, + RowKey = FeedItemState.KeyForUrl(url), + State = state, + DateModified = DateTime.UtcNow.AddDays(-daysAgo) + }; + } + var oldSkipped = Make(FeedItemState.Skipped, 61); + var recentSkipped = Make(FeedItemState.Skipped, 59); + var oldAdded = Make(FeedItemState.Added, 120); + foreach (var s in new[] { oldSkipped, recentSkipped, oldAdded }) + { + await table.AddEntityAsync(s); + } + + var states = await _client.GetFromJsonAsync>("/api/feeds/items/states"); + + states.Should().NotContain(s => s.RowKey == oldSkipped.RowKey); + states.Should().Contain(s => s.RowKey == recentSkipped.RowKey); + states.Should().Contain(s => s.RowKey == oldAdded.RowKey); + (await table.GetEntityIfExistsAsync(FeedItemState.Partition, oldSkipped.RowKey)).HasValue.Should().BeFalse(); + } +} diff --git a/src/NoteBookmark.Api.Tests/Endpoints/FeedImportEndpointTests.cs b/src/NoteBookmark.Api.Tests/Endpoints/FeedImportEndpointTests.cs new file mode 100644 index 0000000..cdb193a --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Endpoints/FeedImportEndpointTests.cs @@ -0,0 +1,106 @@ +using FluentAssertions; +using NoteBookmark.Api.Tests.Feeds; +using NoteBookmark.Api.Tests.Fixtures; +using NoteBookmark.Domain; +using System.Net; +using System.Net.Http.Json; +using Xunit; + +namespace NoteBookmark.Api.Tests.Endpoints; + +public class FeedImportEndpointTests : IClassFixture +{ + private readonly FeedsApiTestFactory _factory; + private readonly HttpClient _client; + + public FeedImportEndpointTests(FeedsApiTestFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + [Fact] + public async Task ImportOpml_CreatesGroupsAndFeeds_ThenSkipsThemOnSecondImport() + { + // Unique folder names and URLs so the test doesn't depend on other tests' data. + var tag = Guid.NewGuid().ToString("N"); + var opml = OpmlParserTests.FeedlyExport + .Replace("\"Dev\"", $"\"Dev {tag}\"") + .Replace("\"Cloud\"", $"\"Cloud {tag}\"") + .Replace("example.com", $"{tag}.example.com"); + + var first = await _client.PostAsJsonAsync("/api/feeds/import-opml", new OpmlImportRequest { Opml = opml }); + var second = await _client.PostAsJsonAsync("/api/feeds/import-opml", new OpmlImportRequest { Opml = opml }); + + first.StatusCode.Should().Be(HttpStatusCode.OK); + var firstResult = await first.Content.ReadFromJsonAsync(); + firstResult!.FeedsCreated.Should().Be(4); + firstResult.FeedsSkipped.Should().Be(0); + firstResult.GroupsCreated.Should().BeInRange(2, 3); // "Imported" may already exist from another test + + var secondResult = await second.Content.ReadFromJsonAsync(); + secondResult!.FeedsCreated.Should().Be(0); + secondResult.FeedsSkipped.Should().Be(4); + secondResult.GroupsCreated.Should().Be(0); + + var groups = await _client.GetFromJsonAsync>("/api/feeds/groups"); + var feeds = await _client.GetFromJsonAsync>("/api/feeds/"); + var dev = groups!.Single(g => g.Name == $"Dev {tag}"); + feeds!.Where(f => f.GroupId == dev.RowKey).Select(f => f.Title).Should().BeEquivalentTo("Blog A", "Blog B"); + } + + [Fact] + public async Task ImportOpml_ReusesExistingGroupByName() + { + var name = $"Existing {Guid.NewGuid():N}"; + var created = await (await _client.PostAsJsonAsync("/api/feeds/groups", new FeedGroup { Name = name })) + .Content.ReadFromJsonAsync(); + var opml = $""""""; + + var result = await (await _client.PostAsJsonAsync("/api/feeds/import-opml", new OpmlImportRequest { Opml = opml })) + .Content.ReadFromJsonAsync(); + + result!.GroupsCreated.Should().Be(0); + var feeds = await _client.GetFromJsonAsync>("/api/feeds/"); + feeds!.Should().Contain(f => f.GroupId == created!.RowKey && f.Title == "X"); + } + + [Fact] + public async Task ImportOpml_InvalidFile_ReturnsBadRequest() + { + var response = await _client.PostAsJsonAsync("/api/feeds/import-opml", new OpmlImportRequest { Opml = "" }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Discover_SiteUrl_ReturnsFeed() + { + var host = $"https://{Guid.NewGuid():N}.example.com"; + _factory.Feeds.Respond($"{host}/", $"""""", "text/html"); + _factory.Feeds.Respond($"{host}/atom", FeedSamples.Atom()); + + var result = await _client.GetFromJsonAsync($"/api/feeds/discover?url={Uri.EscapeDataString(host + "/")}"); + + result!.FeedUrl.Should().Be($"{host}/atom"); + result.Title.Should().Be("Sample Atom"); + } + + [Fact] + public async Task Discover_NoFeed_ReturnsNotFound() + { + var response = await _client.GetAsync($"/api/feeds/discover?url={Uri.EscapeDataString("https://nothing.example.com/")}"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Theory] + [InlineData("")] + [InlineData("?url=ftp%3A%2F%2Fx")] + public async Task Discover_InvalidUrl_ReturnsBadRequest(string query) + { + var response = await _client.GetAsync("/api/feeds/discover" + query); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} diff --git a/src/NoteBookmark.Api.Tests/Endpoints/FeedItemsEndpointTests.cs b/src/NoteBookmark.Api.Tests/Endpoints/FeedItemsEndpointTests.cs new file mode 100644 index 0000000..1933c80 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Endpoints/FeedItemsEndpointTests.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using NoteBookmark.Api.Tests.Feeds; +using NoteBookmark.Api.Tests.Fixtures; +using NoteBookmark.Domain; +using System.Net; +using System.Net.Http.Json; +using Xunit; + +namespace NoteBookmark.Api.Tests.Endpoints; + +public class FeedItemsEndpointTests : IClassFixture +{ + private readonly FeedsApiTestFactory _factory; + private readonly HttpClient _client; + + public FeedItemsEndpointTests(FeedsApiTestFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + private async Task CreateGroup() + { + var response = await _client.PostAsJsonAsync("/api/feeds/groups", new FeedGroup { Name = $"g-{Guid.NewGuid():N}" }); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } + + private async Task CreateFeed(FeedGroup group, string feedUrl) + { + var response = await _client.PostAsJsonAsync("/api/feeds/", new Feed { Title = "Feed " + feedUrl, FeedUrl = feedUrl, GroupId = group.RowKey }); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } + + private Task GetItems(string query) => + _client.GetFromJsonAsync("/api/feeds/items" + query); + + private static string UniqueHost() => $"https://{Guid.NewGuid():N}.example.com"; + + [Fact] + public async Task GetItems_ForGroup_ReturnsItemsNewestFirst_WithCounts() + { + var group = await CreateGroup(); + var host = UniqueHost(); + var now = DateTimeOffset.UtcNow; + _factory.Feeds.Respond($"{host}/a.xml", FeedSamples.Rss(($"{host}/old", "Old", now.AddDays(-3)), ($"{host}/new", "New", now.AddHours(-1)))); + _factory.Feeds.Respond($"{host}/b.xml", FeedSamples.Atom(($"{host}/mid", "Mid", now.AddDays(-1)))); + var feedA = await CreateFeed(group, $"{host}/a.xml"); + var feedB = await CreateFeed(group, $"{host}/b.xml"); + + var response = await GetItems($"?groupId={group.RowKey}"); + + response!.Items.Select(i => i.Title).Should().Equal("New", "Mid", "Old"); + response.Feeds.Should().HaveCount(2); + response.Feeds.Single(f => f.FeedId == feedA.RowKey).PendingCount.Should().Be(2); + response.Feeds.Single(f => f.FeedId == feedB.RowKey).PendingCount.Should().Be(1); + response.Feeds.Should().OnlyContain(f => f.Error == null && f.GroupId == group.RowKey); + } + + [Fact] + public async Task GetItems_HidesOldItems_AddedOrSkippedItems_AndExistingPosts() + { + var group = await CreateGroup(); + var host = UniqueHost(); + var now = DateTimeOffset.UtcNow; + _factory.Feeds.Respond($"{host}/rss", FeedSamples.Rss( + ($"{host}/keep", "Keep", now), + ($"{host}/too-old", "Too old", now.AddDays(-31)), + ($"{host}/skipped", "Skipped", now), + ($"{host}/added", "Added", now), + ($"{host}/already-post", "Already a post", now))); + var feed = await CreateFeed(group, $"{host}/rss"); + + await _client.PostAsJsonAsync("/api/feeds/items/states", new[] + { + new FeedItemState { Url = $"{host}/skipped", State = FeedItemState.Skipped }, + new FeedItemState { Url = $"{host}/added/", State = FeedItemState.Added } // trailing slash still matches + }); + var post = new Post { PartitionKey = DateTime.UtcNow.ToString("yyyy-MM"), RowKey = Guid.NewGuid().ToString(), Url = $"{host}/already-post", Title = "x" }; + (await _client.PostAsJsonAsync("/api/posts/", post)).EnsureSuccessStatusCode(); + + var response = await GetItems($"?feedId={feed.RowKey}"); + + response!.Items.Select(i => i.Title).Should().Equal("Keep"); + response.Feeds.Single().PendingCount.Should().Be(1); + } + + [Fact] + public async Task GetItems_FailingFeed_ReportsError_WhileOthersLoad() + { + var group = await CreateGroup(); + var host = UniqueHost(); + _factory.Feeds.Respond($"{host}/ok", FeedSamples.Rss(($"{host}/1", "Fine", DateTimeOffset.UtcNow))); + var ok = await CreateFeed(group, $"{host}/ok"); + var broken = await CreateFeed(group, $"{host}/broken"); + + var response = await GetItems($"?groupId={group.RowKey}"); + + response!.Items.Should().ContainSingle().Which.Title.Should().Be("Fine"); + response.Feeds.Single(f => f.FeedId == ok.RowKey).Error.Should().BeNull(); + response.Feeds.Single(f => f.FeedId == broken.RowKey).Error.Should().Contain("404"); + } + + [Fact] + public async Task GetItems_SameArticleInTwoFeeds_IsListedOnce() + { + var group = await CreateGroup(); + var host = UniqueHost(); + _factory.Feeds.Respond($"{host}/one", FeedSamples.Rss(($"{host}/shared", "Shared", DateTimeOffset.UtcNow))); + _factory.Feeds.Respond($"{host}/two", FeedSamples.Atom(($"{host}/shared", "Shared", DateTimeOffset.UtcNow))); + await CreateFeed(group, $"{host}/one"); + await CreateFeed(group, $"{host}/two"); + + var response = await GetItems($"?groupId={group.RowKey}"); + + response!.Items.Should().ContainSingle(); + } + + [Fact] + public async Task GetItems_Refresh_BypassesCache() + { + var group = await CreateGroup(); + var host = UniqueHost(); + var url = $"{host}/rss"; + _factory.Feeds.Respond(url, FeedSamples.Rss(($"{host}/1", "First", DateTimeOffset.UtcNow))); + var feed = await CreateFeed(group, url); + await GetItems($"?feedId={feed.RowKey}"); + _factory.Feeds.Respond(url, FeedSamples.Rss(($"{host}/1", "First", DateTimeOffset.UtcNow), ($"{host}/2", "Second", DateTimeOffset.UtcNow))); + + var cached = await GetItems($"?feedId={feed.RowKey}"); + var refreshed = await GetItems($"?feedId={feed.RowKey}&refresh=true"); + + cached!.Items.Should().HaveCount(1); + refreshed!.Items.Should().HaveCount(2); + } + + [Fact] + public async Task GetItems_UnknownGroup_ReturnsEmpty() + { + var response = await _client.GetAsync($"/api/feeds/items?groupId={Guid.NewGuid()}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadFromJsonAsync(); + body!.Items.Should().BeEmpty(); + body.Feeds.Should().BeEmpty(); + } +} diff --git a/src/NoteBookmark.Api.Tests/Feeds/FeedParserTests.cs b/src/NoteBookmark.Api.Tests/Feeds/FeedParserTests.cs new file mode 100644 index 0000000..30c4070 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Feeds/FeedParserTests.cs @@ -0,0 +1,198 @@ +using FluentAssertions; +using NoteBookmark.Api; +using Xunit; + +namespace NoteBookmark.Api.Tests.Feeds; + +public class FeedParserTests +{ + private static readonly Uri FeedUri = new("https://blog.example.com/feed.xml"); + + [Fact] + public void Parse_Rss2_ReadsChannelAndItems() + { + var date = DateTimeOffset.UtcNow.AddDays(-1); + var xml = FeedSamples.Rss(("https://blog.example.com/a", "First post", date)); + + var feed = FeedParser.Parse(xml, FeedUri); + + feed.Title.Should().Be("Sample RSS"); + feed.SiteUrl.Should().Be("https://rss.example.com/"); + var item = feed.Items.Should().ContainSingle().Which; + item.Url.Should().Be("https://blog.example.com/a"); + item.Title.Should().Be("First post"); + item.Excerpt.Should().Be("Summary of First post"); + item.Published.Should().BeCloseTo(date, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Parse_Atom_ReadsAlternateLinkAndPublishedDate() + { + var date = DateTimeOffset.UtcNow.AddHours(-3); + var xml = FeedSamples.Atom(("https://atom.example.com/post-1", "Atom entry", date)); + + var feed = FeedParser.Parse(xml, FeedUri); + + feed.Title.Should().Be("Sample Atom"); + feed.SiteUrl.Should().Be("https://atom.example.com/"); + var item = feed.Items.Should().ContainSingle().Which; + item.Url.Should().Be("https://atom.example.com/post-1"); + item.Published.Should().BeCloseTo(date, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Parse_Rss1Rdf_ReadsItemsBesideChannel() + { + const string xml = """ + + + RDF feedhttps://rdf.example.com/ + RDF itemhttps://rdf.example.com/12026-10-30T10:00:00ZAda + + """; + + var feed = FeedParser.Parse(xml, FeedUri); + + feed.Title.Should().Be("RDF feed"); + var item = feed.Items.Should().ContainSingle().Which; + item.Url.Should().Be("https://rdf.example.com/1"); + item.Author.Should().Be("Ada"); + item.Published.Should().Be(new DateTimeOffset(2026, 10, 30, 10, 0, 0, TimeSpan.Zero)); + } + + [Fact] + public void Parse_PrefersContentEncodedForBody_AndDescriptionForExcerpt() + { + const string xml = """ + + T + + Posthttps://blog.example.com/p + Short <b>teaser</b> + Full article

]]>
+
+
+
+ """; + + var item = FeedParser.Parse(xml, FeedUri).Items.Single(); + + item.Excerpt.Should().Be("Short teaser"); + item.Body.Should().Be("

Full article

"); + } + + [Fact] + public void Parse_ResolvesRelativeLinks_AgainstSiteUrl() + { + const string xml = """ + Thttps://site.example.com/blog/ + Relativeposts/1 + + """; + + var item = FeedParser.Parse(xml, FeedUri).Items.Single(); + + item.Url.Should().Be("https://site.example.com/blog/posts/1"); + } + + [Fact] + public void Parse_UsesGuidPermalink_WhenLinkIsMissing() + { + const string xml = """ + T + Guid onlyhttps://blog.example.com/guid-post + Not a permalinktag:1234 + + """; + + var items = FeedParser.Parse(xml, FeedUri).Items; + + items.Should().ContainSingle().Which.Url.Should().Be("https://blog.example.com/guid-post"); + } + + [Fact] + public void Parse_ReadsLeadImage_FromMediaThenEnclosureThenBody() + { + const string xml = """ + T + Mediahttps://b.example.com/1 + Enclosurehttps://b.example.com/2 + Bodyhttps://b.example.com/3<img src="/b.gif"> + Nonehttps://b.example.com/4 + + """; + + var images = FeedParser.Parse(xml, FeedUri).Items.Select(i => i.LeadImageUrl).ToList(); + + images.Should().Equal("https://img.example.com/m.jpg", "https://img.example.com/e.png", "https://b.example.com/b.gif", null); + } + + [Fact] + public void Parse_DecodesHtmlInTitles() + { + const string xml = """ + Tom &amp; Jerry + <em>Rock</em> &amp; rollhttps://b.example.com/1 + + """; + + var feed = FeedParser.Parse(xml, FeedUri); + + feed.Title.Should().Be("Tom & Jerry"); + feed.Items.Single().Title.Should().Be("Rock & roll"); + } + + [Fact] + public void Parse_TruncatesLongExcerpts() + { + var longText = string.Join(" ", Enumerable.Repeat("word", 100)); + var xml = $"""TLhttps://b.example.com/1{longText}"""; + + var excerpt = FeedParser.Parse(xml, FeedUri).Items.Single().Excerpt; + + excerpt.Length.Should().BeLessThanOrEqualTo(201); + excerpt.Should().EndWith("…"); + } + + [Fact] + public void Parse_IgnoresDoctype() + { + const string xml = """ + + ]> + Doctypexhttps://b.example.com/1 + """; + + var feed = FeedParser.Parse(xml, FeedUri); + + feed.Title.Should().Be("Doctype"); + } + + [Theory] + [InlineData("Not a feed")] + [InlineData("not xml at all")] + public void Parse_NonFeed_ThrowsFormatException(string content) + { + var act = () => FeedParser.Parse(content, FeedUri); + + act.Should().Throw(); + } + + [Theory] + [InlineData("Wed, 29 Oct 2026 14:30:00 GMT", "2026-10-29T14:30:00Z")] + [InlineData("Wed, 29 Oct 2026 14:30:00 +0200", "2026-10-29T12:30:00Z")] + [InlineData("Wed, 29 Oct 2026 09:30:00 EST", "2026-10-29T14:30:00Z")] + [InlineData("Mon, 29 Oct 2026 14:30:00 GMT", "2026-10-29T14:30:00Z")] // wrong day name + [InlineData("2026-10-29T14:30:00Z", "2026-10-29T14:30:00Z")] + [InlineData("2026-10-29T16:30:00+02:00", "2026-10-29T14:30:00Z")] + public void ParseDate_HandlesCommonFeedFormats(string input, string expectedUtc) + { + FeedParser.ParseDate(input).Should().Be(DateTimeOffset.Parse(expectedUtc)); + } + + [Fact] + public void ParseDate_ReturnsNull_ForGarbage() + { + FeedParser.ParseDate("sometime last week").Should().BeNull(); + } +} diff --git a/src/NoteBookmark.Api.Tests/Feeds/FeedReaderTests.cs b/src/NoteBookmark.Api.Tests/Feeds/FeedReaderTests.cs new file mode 100644 index 0000000..be88103 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Feeds/FeedReaderTests.cs @@ -0,0 +1,163 @@ +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using NoteBookmark.Api; +using NoteBookmark.Api.Tests.Fixtures; +using NoteBookmark.Domain; +using Xunit; + +namespace NoteBookmark.Api.Tests.Feeds; + +public class FeedReaderTests +{ + private readonly StubFeedHandler _handler = new(); + private readonly FeedReader _reader; + + public FeedReaderTests() + { + _reader = new FeedReader(new HttpClient(_handler), new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); + } + + private static Feed MakeFeed(string url) => new() { Title = "My feed", FeedUrl = url, GroupId = "group-1" }; + + [Fact] + public async Task FetchAsync_ReturnsItemsStampedWithFeed() + { + const string url = "https://a.example.com/rss"; + _handler.Respond(url, FeedSamples.Rss(("https://a.example.com/1", "One", DateTimeOffset.UtcNow))); + var feed = MakeFeed(url); + + var result = await _reader.FetchAsync(feed, refresh: false); + + result.Error.Should().BeNull(); + var item = result.Items.Should().ContainSingle().Which; + item.FeedId.Should().Be(feed.RowKey); + item.FeedTitle.Should().Be("My feed"); + item.GroupId.Should().Be("group-1"); + } + + [Fact] + public async Task FetchAsync_UsesCache_UntilRefreshIsRequested() + { + const string url = "https://b.example.com/rss"; + _handler.Respond(url, FeedSamples.Rss(("https://b.example.com/1", "One", DateTimeOffset.UtcNow))); + var feed = MakeFeed(url); + + await _reader.FetchAsync(feed, refresh: false); + await _reader.FetchAsync(feed, refresh: false); + _handler.Hits(url).Should().Be(1); + + await _reader.FetchAsync(feed, refresh: true); + _handler.Hits(url).Should().Be(2); + } + + [Fact] + public async Task FetchAsync_CachedItems_PickUpFeedMovesBetweenGroups() + { + const string url = "https://c.example.com/rss"; + _handler.Respond(url, FeedSamples.Rss(("https://c.example.com/1", "One", DateTimeOffset.UtcNow))); + var feed = MakeFeed(url); + await _reader.FetchAsync(feed, refresh: false); + + feed.GroupId = "group-2"; + var result = await _reader.FetchAsync(feed, refresh: false); + + result.Items.Single().GroupId.Should().Be("group-2"); + } + + [Fact] + public async Task FetchAsync_HttpError_ReturnsErrorInsteadOfThrowing() + { + var result = await _reader.FetchAsync(MakeFeed("https://missing.example.com/rss"), refresh: false); + + result.Items.Should().BeEmpty(); + result.Error.Should().Contain("404"); + } + + [Fact] + public async Task FetchAsync_NotAFeed_ReturnsError() + { + const string url = "https://html.example.com/"; + _handler.Respond(url, "hello", "text/html"); + + var result = await _reader.FetchAsync(MakeFeed(url), refresh: false); + + result.Error.Should().Be("Not a valid RSS or Atom feed."); + } + + [Fact] + public async Task FetchAsync_SlowFeed_TimesOut() + { + const string url = "https://slow.example.com/rss"; + _handler.Hanging[url] = true; + _reader.Timeout = TimeSpan.FromMilliseconds(200); + + var result = await _reader.FetchAsync(MakeFeed(url), refresh: false); + + result.Error.Should().StartWith("Timed out"); + } + + [Fact] + public async Task FetchAsync_FailedFeed_IsNotCached() + { + const string url = "https://flaky.example.com/rss"; + await _reader.FetchAsync(MakeFeed(url), refresh: false); + _handler.Respond(url, FeedSamples.Rss(("https://flaky.example.com/1", "Back", DateTimeOffset.UtcNow))); + + var result = await _reader.FetchAsync(MakeFeed(url), refresh: false); + + result.Error.Should().BeNull(); + result.Items.Should().ContainSingle(); + } + + [Fact] + public async Task DiscoverAsync_FeedUrl_ReturnsItWithTitle() + { + const string url = "https://d.example.com/feed.xml"; + _handler.Respond(url, FeedSamples.Atom()); + + var result = await _reader.DiscoverAsync(url); + + result.Should().NotBeNull(); + result!.FeedUrl.Should().Be(url); + result.Title.Should().Be("Sample Atom"); + result.SiteUrl.Should().Be("https://atom.example.com/"); + } + + [Fact] + public async Task DiscoverAsync_SiteUrl_FollowsAlternateLink() + { + const string site = "https://e.example.com/"; + _handler.Respond(site, """ + E site + + + + """, "text/html"); + _handler.Respond("https://e.example.com/rss.xml", FeedSamples.Rss()); + + var result = await _reader.DiscoverAsync(site); + + result.Should().NotBeNull(); + result!.FeedUrl.Should().Be("https://e.example.com/rss.xml"); + result.Title.Should().Be("Sample RSS"); + } + + [Fact] + public async Task DiscoverAsync_PageWithoutFeed_ReturnsNull() + { + const string site = "https://f.example.com/"; + _handler.Respond(site, "No feed", "text/html"); + + (await _reader.DiscoverAsync(site)).Should().BeNull(); + } + + [Theory] + [InlineData("not a url")] + [InlineData("ftp://g.example.com/")] + [InlineData("https://unreachable.example.com/")] + public async Task DiscoverAsync_BadOrUnreachableUrl_ReturnsNull(string url) + { + (await _reader.DiscoverAsync(url)).Should().BeNull(); + } +} diff --git a/src/NoteBookmark.Api.Tests/Feeds/FeedSamples.cs b/src/NoteBookmark.Api.Tests/Feeds/FeedSamples.cs new file mode 100644 index 0000000..02043fe --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Feeds/FeedSamples.cs @@ -0,0 +1,44 @@ +namespace NoteBookmark.Api.Tests.Feeds; + +/// Small feed documents used across feed tests. Dates are relative to now so age filters stay valid. +public static class FeedSamples +{ + public static string Rfc822(DateTimeOffset date) => date.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", System.Globalization.CultureInfo.InvariantCulture); + + public static string Iso(DateTimeOffset date) => date.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); + + public static string Rss(params (string Url, string Title, DateTimeOffset Date)[] items) => $""" + + + + Sample RSS + https://rss.example.com/ + {string.Concat(items.Select(i => $""" + + {i.Title} + {i.Url} + {Rfc822(i.Date)} + Summary of {i.Title} + + """))} + + + """; + + public static string Atom(params (string Url, string Title, DateTimeOffset Date)[] entries) => $""" + + + Sample Atom + + + {string.Concat(entries.Select(e => $""" + + {e.Title} + + {Iso(e.Date)} + Summary of {e.Title} + + """))} + + """; +} diff --git a/src/NoteBookmark.Api.Tests/Feeds/HtmlSanitizerTests.cs b/src/NoteBookmark.Api.Tests/Feeds/HtmlSanitizerTests.cs new file mode 100644 index 0000000..e88b706 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Feeds/HtmlSanitizerTests.cs @@ -0,0 +1,101 @@ +using FluentAssertions; +using NoteBookmark.Api; +using Xunit; + +namespace NoteBookmark.Api.Tests.Feeds; + +public class HtmlSanitizerTests +{ + private static readonly Uri Base = new("https://blog.example.com/posts/1"); + + [Fact] + public void Sanitize_RemovesScriptsStylesAndFrames_WithTheirContent() + { + var html = "

Hi

"; + + HtmlSanitizer.Sanitize(html, Base).Should().Be("

Hi

"); + } + + [Fact] + public void Sanitize_RemovesEventHandlersAndStyleAttributes() + { + var html = "

Text

"; + + HtmlSanitizer.Sanitize(html, Base).Should().Be("

Text

"); + } + + [Theory] + [InlineData("x", "x")] + [InlineData("", "")] + public void Sanitize_DropsNonHttpUrls(string html, string expected) + { + HtmlSanitizer.Sanitize(html, Base).Should().Be(expected); + } + + [Fact] + public void Sanitize_ObfuscatedJavascriptUrl_EndsUpAsHarmlessHttpUrl() + { + var result = HtmlSanitizer.Sanitize("x", Base); + + result.Should().NotContainEquivalentOf("href=\"javascript"); + result.Should().Contain("href=\"https://blog.example.com/"); + } + + [Fact] + public void Sanitize_ResolvesRelativeUrls_AndOpensLinksInNewTab() + { + var html = "About\"A\""; + + var result = HtmlSanitizer.Sanitize(html, Base); + + result.Should().Contain("href=\"https://blog.example.com/about\""); + result.Should().Contain("target=\"_blank\""); + result.Should().Contain("rel=\"noopener noreferrer\""); + result.Should().Contain("src=\"https://blog.example.com/posts/img/a.png\""); + result.Should().Contain("alt=\"A\""); + } + + [Fact] + public void Sanitize_UnwrapsUnknownTags_KeepingTheirText() + { + var html = "
Kept bold
"; + + HtmlSanitizer.Sanitize(html, Base).Should().Be("Kept bold"); + } + + [Fact] + public void Sanitize_RemovesComments() + { + HtmlSanitizer.Sanitize("

a

", Base).Should().Be("

a

"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Sanitize_Empty_ReturnsEmpty(string? html) + { + HtmlSanitizer.Sanitize(html, Base).Should().BeEmpty(); + } + + [Fact] + public void ToPlainText_SeparatesBlocks_DecodesEntities_AndCollapsesWhitespace() + { + var html = "

First para

Second & last

\n\n"; + + HtmlSanitizer.ToPlainText(html).Should().Be("First para Second & last"); + } + + [Fact] + public void ToPlainText_KeepsInlineWordsTogether() + { + HtmlSanitizer.ToPlainText("Hello world").Should().Be("Hello world"); + } + + [Fact] + public void FirstImage_ReturnsFirstSrc() + { + HtmlSanitizer.FirstImage("

x

").Should().Be("https://a/1.png"); + HtmlSanitizer.FirstImage("

none

").Should().BeNull(); + } +} diff --git a/src/NoteBookmark.Api.Tests/Feeds/OpmlParserTests.cs b/src/NoteBookmark.Api.Tests/Feeds/OpmlParserTests.cs new file mode 100644 index 0000000..df5fe63 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Feeds/OpmlParserTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; +using NoteBookmark.Api; +using Xunit; + +namespace NoteBookmark.Api.Tests.Feeds; + +public class OpmlParserTests +{ + public const string FeedlyExport = """ + + + Frank subscriptions in feedly Cloud + + + + + + + + + + + + + + + """; + + [Fact] + public void Parse_FeedlyExport_MapsFoldersToGroups() + { + var feeds = OpmlParser.Parse(FeedlyExport); + + feeds.Should().BeEquivalentTo(new[] + { + new OpmlParser.OpmlFeed("Dev", "Blog A", "https://a.example.com/feed", "https://a.example.com/"), + new OpmlParser.OpmlFeed("Dev", "Blog B", "https://b.example.com/rss", null), + new OpmlParser.OpmlFeed("Cloud", "Blog C", "https://c.example.com/atom.xml", null), + new OpmlParser.OpmlFeed(OpmlParser.DefaultGroupName, "Loose feed", "https://loose.example.com/rss", null), + }, o => o.WithStrictOrdering()); + } + + [Theory] + [InlineData("")] + [InlineData("not xml")] + [InlineData("")] + public void Parse_NonOpml_ThrowsFormatException(string content) + { + var act = () => OpmlParser.Parse(content); + + act.Should().Throw(); + } + + [Fact] + public void Parse_EmptyBody_ReturnsNoFeeds() + { + OpmlParser.Parse("").Should().BeEmpty(); + } +} diff --git a/src/NoteBookmark.Api.Tests/Fixtures/FeedsApiTestFactory.cs b/src/NoteBookmark.Api.Tests/Fixtures/FeedsApiTestFactory.cs new file mode 100644 index 0000000..2c06986 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Fixtures/FeedsApiTestFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace NoteBookmark.Api.Tests.Fixtures; + +/// API factory whose feed reader downloads from instead of the network. +public class FeedsApiTestFactory : NoteBookmarkApiTestFactory +{ + public StubFeedHandler Feeds { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => + { + services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => Feeds); + }); + } +} diff --git a/src/NoteBookmark.Api.Tests/Fixtures/StubFeedHandler.cs b/src/NoteBookmark.Api.Tests/Fixtures/StubFeedHandler.cs new file mode 100644 index 0000000..e0e8c71 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Fixtures/StubFeedHandler.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text; + +namespace NoteBookmark.Api.Tests.Fixtures; + +/// Serves canned responses by URL so feed tests never touch the network. +public class StubFeedHandler : HttpMessageHandler +{ + private readonly ConcurrentDictionary> _responses = new(); + private readonly ConcurrentDictionary _hits = new(); + + /// URLs that never answer (to exercise the timeout). + public ConcurrentDictionary Hanging { get; } = new(); + + public void Respond(string url, string content, string mediaType = "application/rss+xml", HttpStatusCode status = HttpStatusCode.OK) => + _responses[url] = () => new HttpResponseMessage(status) { Content = new StringContent(content, Encoding.UTF8, mediaType) }; + + public int Hits(string url) => _hits.TryGetValue(url, out var count) ? count : 0; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.AbsoluteUri; + _hits.AddOrUpdate(url, 1, (_, c) => c + 1); + if (Hanging.ContainsKey(url)) + { + await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken); + } + return _responses.TryGetValue(url, out var factory) + ? factory() + : new HttpResponseMessage(HttpStatusCode.NotFound) { ReasonPhrase = "Not Found" }; + } +} diff --git a/src/NoteBookmark.Api/DataStorageService.cs b/src/NoteBookmark.Api/DataStorageService.cs index 778dbce..01d787b 100644 --- a/src/NoteBookmark.Api/DataStorageService.cs +++ b/src/NoteBookmark.Api/DataStorageService.cs @@ -363,4 +363,143 @@ public async Task SaveReadingNotesMarkdown(string markdown, string numbe return string.Empty; } + // ----- Feeds (RSS reader) ----- + + private async Task GetTableAsync(string name) + { + var table = tblClient.GetTableClient(name); + await table.CreateIfNotExistsAsync(); + return table; + } + + private Task GetFeedGroupTable() => GetTableAsync("FeedGroups"); + + private Task GetFeedTable() => GetTableAsync("Feeds"); + + private Task GetFeedItemStateTable() => GetTableAsync("FeedItemStates"); + + private static async Task> QueryAllAsync(TableClient table, string partitionKey) where T : class, ITableEntity + { + var results = new List(); + await foreach (var entity in table.QueryAsync(e => e.PartitionKey == partitionKey)) + { + results.Add(entity); + } + return results; + } + + private static async Task GetEntityAsync(TableClient table, string partitionKey, string rowKey) where T : class, ITableEntity + { + var result = await table.GetEntityIfExistsAsync(partitionKey, rowKey); + return result.HasValue ? result.Value : null; + } + + public async Task> GetPostUrls() + { + var table = GetPostTable(); + var urls = new List(); + await foreach (var post in table.QueryAsync(select: new[] { "url", "Url" })) + { + if (!string.IsNullOrWhiteSpace(post.Url)) + { + urls.Add(post.Url); + } + } + return urls; + } + + public async Task> GetFeedGroups() + { + var groups = await QueryAllAsync(await GetFeedGroupTable(), FeedGroup.Partition); + return groups.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + + public async Task GetFeedGroup(string id) => + await GetEntityAsync(await GetFeedGroupTable(), FeedGroup.Partition, id); + + public async Task SaveFeedGroup(FeedGroup group) + { + group.PartitionKey = FeedGroup.Partition; + group.DateModified = DateTime.UtcNow; + var table = await GetFeedGroupTable(); + await table.UpsertEntityAsync(group, TableUpdateMode.Replace); + } + + public async Task DeleteFeedGroup(string id) + { + var table = await GetFeedGroupTable(); + var response = await table.DeleteEntityAsync(FeedGroup.Partition, id); + return response.Status != 404; + } + + public async Task> GetFeeds() + { + var feeds = await QueryAllAsync(await GetFeedTable(), Feed.Partition); + return feeds.OrderBy(f => f.Title, StringComparer.OrdinalIgnoreCase).ToList(); + } + + public async Task GetFeed(string id) => + await GetEntityAsync(await GetFeedTable(), Feed.Partition, id); + + public async Task SaveFeed(Feed feed) + { + feed.PartitionKey = Feed.Partition; + feed.DateModified = DateTime.UtcNow; + var table = await GetFeedTable(); + await table.UpsertEntityAsync(feed, TableUpdateMode.Replace); + } + + public async Task DeleteFeed(string id) + { + var table = await GetFeedTable(); + var response = await table.DeleteEntityAsync(Feed.Partition, id); + return response.Status != 404; + } + + public async Task> GetFeedItemStates() => + await QueryAllAsync(await GetFeedItemStateTable(), FeedItemState.Partition); + + public async Task SaveFeedItemStates(IEnumerable states) + { + var table = await GetFeedItemStateTable(); + // One state per URL; the last one wins when a URL is sent twice. + var unique = states + .Select(s => + { + s.PartitionKey = FeedItemState.Partition; + s.RowKey = FeedItemState.KeyForUrl(s.Url); + s.DateModified = DateTime.UtcNow; + return s; + }) + .GroupBy(s => s.RowKey) + .Select(g => g.Last()) + .ToList(); + + // Table transactions accept at most 100 entities from the same partition. + foreach (var chunk in unique.Chunk(100)) + { + var actions = chunk.Select(s => new TableTransactionAction(TableTransactionActionType.UpsertReplace, s)); + await table.SubmitTransactionAsync(actions); + } + } + + public async Task PruneSkippedFeedItemStates(DateTime olderThan) + { + var table = await GetFeedItemStateTable(); + var cutoff = olderThan.ToUniversalTime(); + var stale = new List(); + await foreach (var state in table.QueryAsync(s => + s.PartitionKey == FeedItemState.Partition && s.State == FeedItemState.Skipped && s.DateModified < cutoff)) + { + stale.Add(state); + } + + foreach (var chunk in stale.Chunk(100)) + { + var actions = chunk.Select(s => new TableTransactionAction(TableTransactionActionType.Delete, s, ETag.All)); + await table.SubmitTransactionAsync(actions); + } + + return stale.Count; + } } diff --git a/src/NoteBookmark.Api/FeedEndpoints.cs b/src/NoteBookmark.Api/FeedEndpoints.cs new file mode 100644 index 0000000..8c45539 --- /dev/null +++ b/src/NoteBookmark.Api/FeedEndpoints.cs @@ -0,0 +1,294 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using NoteBookmark.Domain; + +namespace NoteBookmark.Api; + +/// Endpoints for the Feeds tab: feed groups, feeds and per-item Added/Skipped state. +public static class FeedEndpoints +{ + /// Skipped item records older than this are pruned. + public static readonly TimeSpan SkippedRetention = TimeSpan.FromDays(60); + + /// Feed items older than this are not shown. + public static readonly TimeSpan ItemMaxAge = TimeSpan.FromDays(30); + + /// How many feeds are downloaded at the same time. + private const int MaxParallelFetches = 8; + + public static void MapFeedEndpoints(this IEndpointRouteBuilder app) + { + var endpoints = app.MapGroup("api/feeds") + .WithOpenApi(); + + endpoints.MapGet("/groups", GetGroups) + .WithDescription("List all feed groups"); + endpoints.MapPost("/groups", SaveGroup) + .WithDescription("Create or rename a feed group"); + endpoints.MapDelete("/groups/{id}", DeleteGroup) + .WithDescription("Delete an empty feed group"); + + endpoints.MapGet("/", GetFeeds) + .WithDescription("List all feeds"); + endpoints.MapPost("/", SaveFeed) + .WithDescription("Create or update a feed (including moving it to another group)"); + endpoints.MapDelete("/{id}", DeleteFeed) + .WithDescription("Delete a feed"); + + endpoints.MapGet("/discover", Discover) + .WithDescription("Find the RSS/Atom feed for a feed URL or a site URL"); + endpoints.MapPost("/import-opml", ImportOpml) + .WithDescription("Import an OPML subscription list (e.g. a Feedly export); folders become groups"); + + endpoints.MapGet("/items", GetItems) + .WithDescription("Pending items (newest first) for all feeds, one group (groupId) or one feed (feedId); refresh=true bypasses the 15 minute cache"); + endpoints.MapGet("/items/states", GetItemStates) + .WithDescription("List Added/Skipped feed item states; prunes Skipped records older than 60 days"); + endpoints.MapPost("/items/state", SaveItemState) + .WithDescription("Mark one feed item as Added or Skipped"); + endpoints.MapPost("/items/states", SaveItemStates) + .WithDescription("Mark many feed items as Added or Skipped (e.g. Mark all as skipped)"); + } + + // ----- Groups ----- + + static async Task>> GetGroups(IDataStorageService storage) => + TypedResults.Ok(await storage.GetFeedGroups()); + + static async Task, BadRequest, Conflict>> SaveGroup(FeedGroup group, IDataStorageService storage) + { + group.Name = group.Name?.Trim() ?? string.Empty; + if (group.Name.Length == 0) + { + return TypedResults.BadRequest("Group name is required."); + } + if (string.IsNullOrWhiteSpace(group.RowKey)) + { + group.RowKey = Guid.NewGuid().ToString(); + } + + var groups = await storage.GetFeedGroups(); + if (groups.Any(g => g.RowKey != group.RowKey && string.Equals(g.Name, group.Name, StringComparison.OrdinalIgnoreCase))) + { + return TypedResults.Conflict($"A group named '{group.Name}' already exists."); + } + + await storage.SaveFeedGroup(group); + return TypedResults.Ok(group); + } + + static async Task>> DeleteGroup(string id, IDataStorageService storage) + { + if (await storage.GetFeedGroup(id) is null) + { + return TypedResults.NotFound(); + } + + var feeds = await storage.GetFeeds(); + var count = feeds.Count(f => f.GroupId == id); + if (count > 0) + { + return TypedResults.Conflict($"The group still contains {count} feed(s). Move or delete them first."); + } + + await storage.DeleteFeedGroup(id); + return TypedResults.Ok(); + } + + // ----- Feeds ----- + + static async Task>> GetFeeds(IDataStorageService storage) => + TypedResults.Ok(await storage.GetFeeds()); + + static async Task, BadRequest, Conflict>> SaveFeed(Feed feed, IDataStorageService storage) + { + feed.FeedUrl = feed.FeedUrl?.Trim() ?? string.Empty; + if (!IsHttpUrl(feed.FeedUrl)) + { + return TypedResults.BadRequest("A valid http(s) feed URL is required."); + } + if (!string.IsNullOrWhiteSpace(feed.SiteUrl) && !IsHttpUrl(feed.SiteUrl.Trim())) + { + return TypedResults.BadRequest("The site URL must be a valid http(s) URL."); + } + if (string.IsNullOrWhiteSpace(feed.GroupId) || await storage.GetFeedGroup(feed.GroupId) is null) + { + return TypedResults.BadRequest("The feed must belong to an existing group."); + } + if (string.IsNullOrWhiteSpace(feed.RowKey)) + { + feed.RowKey = Guid.NewGuid().ToString(); + } + feed.SiteUrl = string.IsNullOrWhiteSpace(feed.SiteUrl) ? null : feed.SiteUrl.Trim(); + feed.Title = string.IsNullOrWhiteSpace(feed.Title) ? feed.FeedUrl : feed.Title.Trim(); + + var feeds = await storage.GetFeeds(); + var normalized = FeedItemState.NormalizeUrl(feed.FeedUrl); + if (feeds.Any(f => f.RowKey != feed.RowKey && FeedItemState.NormalizeUrl(f.FeedUrl) == normalized)) + { + return TypedResults.Conflict("This feed is already in your list."); + } + + await storage.SaveFeed(feed); + return TypedResults.Ok(feed); + } + + static async Task> DeleteFeed(string id, IDataStorageService storage) => + await storage.DeleteFeed(id) ? TypedResults.Ok() : TypedResults.NotFound(); + + // ----- Discovery and import ----- + + static async Task, BadRequest, NotFound>> Discover( + string? url, IFeedReader reader, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(url) || !IsHttpUrl(url.Trim())) + { + return TypedResults.BadRequest("A valid http(s) URL is required."); + } + + var result = await reader.DiscoverAsync(url.Trim(), cancellationToken); + return result is null + ? TypedResults.NotFound("No RSS or Atom feed was found at this address.") + : TypedResults.Ok(result); + } + + static async Task, BadRequest>> ImportOpml(OpmlImportRequest request, IDataStorageService storage) + { + List entries; + try + { + entries = OpmlParser.Parse(request?.Opml ?? string.Empty); + } + catch (FormatException ex) + { + return TypedResults.BadRequest(ex.Message); + } + + var result = new OpmlImportResult(); + var groups = (await storage.GetFeedGroups()) + .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var knownFeeds = new HashSet((await storage.GetFeeds()).Select(f => FeedItemState.NormalizeUrl(f.FeedUrl))); + + foreach (var entry in entries) + { + if (!knownFeeds.Add(FeedItemState.NormalizeUrl(entry.FeedUrl))) + { + result.FeedsSkipped++; + continue; + } + + if (!groups.TryGetValue(entry.GroupName, out var group)) + { + group = new FeedGroup { Name = entry.GroupName }; + await storage.SaveFeedGroup(group); + groups[group.Name] = group; + result.GroupsCreated++; + } + + await storage.SaveFeed(new Feed + { + Title = entry.Title, + FeedUrl = entry.FeedUrl, + SiteUrl = entry.SiteUrl, + GroupId = group.RowKey + }); + result.FeedsCreated++; + } + + return TypedResults.Ok(result); + } + + // ----- Items ----- + + static async Task> GetItems(IDataStorageService storage, IFeedReader reader, + string? groupId, string? feedId, bool? refresh, CancellationToken cancellationToken) + { + var feeds = (await storage.GetFeeds()) + .Where(f => string.IsNullOrEmpty(groupId) || f.GroupId == groupId) + .Where(f => string.IsNullOrEmpty(feedId) || f.RowKey == feedId) + .ToList(); + + // Start the lookups for hidden URLs while the feeds download. + var statesTask = storage.GetFeedItemStates(); + var postUrlsTask = storage.GetPostUrls(); + + using var throttle = new SemaphoreSlim(MaxParallelFetches); + var fetches = feeds.Select(async feed => + { + await throttle.WaitAsync(cancellationToken); + try + { + return (Feed: feed, Result: await reader.FetchAsync(feed, refresh == true, cancellationToken)); + } + finally + { + throttle.Release(); + } + }).ToList(); + var results = await Task.WhenAll(fetches); + + var hidden = new HashSet((await statesTask).Select(s => s.RowKey)); + hidden.UnionWith((await postUrlsTask).Select(FeedItemState.KeyForUrl)); + var cutoff = DateTimeOffset.UtcNow - ItemMaxAge; + + var response = new FeedItemsResponse(); + foreach (var (feed, result) in results) + { + var pending = result.Items + .Where(i => i.Published is null || i.Published >= cutoff) + .Where(i => hidden.Add(FeedItemState.KeyForUrl(i.Url))) // also drops the same article listed by two feeds + .ToList(); + response.Items.AddRange(pending); + response.Feeds.Add(new FeedStatus + { + FeedId = feed.RowKey, + Title = feed.Title, + GroupId = feed.GroupId, + PendingCount = pending.Count, + Error = result.Error + }); + } + + response.Items = response.Items + .OrderByDescending(i => i.Published ?? DateTimeOffset.MinValue) + .ToList(); + return TypedResults.Ok(response); + } + + // ----- Item state ----- + + static async Task>> GetItemStates(IDataStorageService storage) + { + await storage.PruneSkippedFeedItemStates(DateTime.UtcNow - SkippedRetention); + return TypedResults.Ok(await storage.GetFeedItemStates()); + } + + static async Task>> SaveItemState(FeedItemState state, IDataStorageService storage) => + await SaveItemStates([state], storage); + + static async Task>> SaveItemStates(List states, IDataStorageService storage) + { + if (states is null || states.Count == 0) + { + return TypedResults.BadRequest("At least one item state is required."); + } + foreach (var state in states) + { + state.Url = state.Url?.Trim() ?? string.Empty; + if (!IsHttpUrl(state.Url)) + { + return TypedResults.BadRequest($"Invalid item URL: '{state.Url}'."); + } + if (!FeedItemState.IsValidState(state.State)) + { + return TypedResults.BadRequest($"State must be '{FeedItemState.Added}' or '{FeedItemState.Skipped}'."); + } + } + + await storage.SaveFeedItemStates(states); + return TypedResults.Ok(); + } + + private static bool IsHttpUrl(string value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); +} diff --git a/src/NoteBookmark.Api/FeedParser.cs b/src/NoteBookmark.Api/FeedParser.cs new file mode 100644 index 0000000..d7c3724 --- /dev/null +++ b/src/NoteBookmark.Api/FeedParser.cs @@ -0,0 +1,247 @@ +using System.Globalization; +using System.Net; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; +using HtmlAgilityPack; +using NoteBookmark.Domain; + +namespace NoteBookmark.Api; + +/// Parses RSS 2.0, RSS 1.0 (RDF) and Atom documents into s. +public static partial class FeedParser +{ + private static readonly XNamespace AtomNs = "http://www.w3.org/2005/Atom"; + private static readonly XNamespace MediaNs = "http://search.yahoo.com/mrss/"; + private const int ExcerptLength = 200; + + public class ParsedFeed + { + public string Title { get; set; } = string.Empty; + public string? SiteUrl { get; set; } + public List Items { get; set; } = []; + } + + /// Loads XML safely (no DTD processing, no external entities). + public static XDocument? TryLoad(string content) + { + try + { + var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null }; + using var reader = XmlReader.Create(new StringReader(content.TrimStart('', ' ', '\t', '\r', '\n')), settings); + return XDocument.Load(reader); + } + catch (XmlException) + { + return null; + } + } + + public static bool IsFeed(XDocument? doc) => + doc?.Root is { } root && root.Name.LocalName is "rss" or "feed" or "RDF"; + + /// Parses a feed document. Throws when it isn't RSS or Atom. + public static ParsedFeed Parse(string content, Uri feedUri) + { + var doc = TryLoad(content); + if (!IsFeed(doc)) + { + throw new FormatException("Not a valid RSS or Atom feed."); + } + + var root = doc!.Root!; + return root.Name.LocalName == "feed" ? ParseAtom(root, feedUri) : ParseRss(root, feedUri); + } + + private static ParsedFeed ParseRss(XElement root, Uri feedUri) + { + // RSS 2.0 nests items in ; RSS 1.0 (RDF) puts them next to it. + var channel = Child(root, "channel") ?? root; + var siteUrl = Resolve(Text(Child(channel, "link")), feedUri); + var baseUri = siteUrl is null ? feedUri : new Uri(siteUrl); + var result = new ParsedFeed + { + Title = CleanTitle(Text(Child(channel, "title"))), + SiteUrl = siteUrl + }; + + var items = channel.Elements().Concat(root.Elements()).Where(e => e.Name.LocalName == "item").Distinct(); + foreach (var item in items) + { + var guid = Child(item, "guid"); + var link = Text(Child(item, "link")); + if (string.IsNullOrWhiteSpace(link) && guid is not null && (string?)guid.Attribute("isPermaLink") != "false") + { + link = Text(guid); + } + var url = Resolve(link, baseUri); + if (url is null) + { + continue; + } + + var summaryHtml = Text(Child(item, "description")); + var bodyHtml = Text(item.Elements().FirstOrDefault(e => e.Name.LocalName == "encoded")) ?? summaryHtml; + var image = MediaImage(item) + ?? item.Elements().Where(e => e.Name.LocalName == "enclosure" && ((string?)e.Attribute("type") ?? "").StartsWith("image/")) + .Select(e => (string?)e.Attribute("url")).FirstOrDefault(); + + result.Items.Add(BuildItem( + url, + Text(Child(item, "title")), + ParseDate(Text(Child(item, "pubDate")) ?? Text(Child(item, "date"))), + Text(Child(item, "creator")) ?? Text(Child(item, "author")), + summaryHtml, + bodyHtml, + image, + new Uri(url))); + } + + return result; + } + + private static ParsedFeed ParseAtom(XElement root, Uri feedUri) + { + var siteUrl = Resolve(AtomLink(root), feedUri); + var result = new ParsedFeed + { + Title = CleanTitle(Text(root.Element(AtomNs + "title"))), + SiteUrl = siteUrl + }; + var baseUri = siteUrl is null ? feedUri : new Uri(siteUrl); + + foreach (var entry in root.Elements(AtomNs + "entry")) + { + var url = Resolve(AtomLink(entry), baseUri); + if (url is null) + { + continue; + } + + var summaryHtml = Text(entry.Element(AtomNs + "summary")); + var bodyHtml = Text(entry.Element(AtomNs + "content")) ?? summaryHtml; + + result.Items.Add(BuildItem( + url, + Text(entry.Element(AtomNs + "title")), + ParseDate(Text(entry.Element(AtomNs + "published")) ?? Text(entry.Element(AtomNs + "updated"))), + Text(entry.Element(AtomNs + "author")?.Element(AtomNs + "name")), + summaryHtml, + bodyHtml, + MediaImage(entry), + new Uri(url))); + } + + return result; + } + + private static FeedItem BuildItem(string url, string? title, DateTimeOffset? published, string? author, + string? summaryHtml, string? bodyHtml, string? image, Uri baseUri) + { + var body = HtmlSanitizer.Sanitize(bodyHtml, baseUri); + var excerptSource = string.IsNullOrWhiteSpace(summaryHtml) ? bodyHtml : summaryHtml; + var imageUrl = Resolve(image, baseUri) ?? HtmlSanitizer.FirstImage(body); + var cleanTitle = CleanTitle(title); + + return new FeedItem + { + Url = url, + Title = cleanTitle.Length > 0 ? cleanTitle : url, + Published = published, + Author = string.IsNullOrWhiteSpace(author) ? null : author.Trim(), + Excerpt = Truncate(HtmlSanitizer.ToPlainText(excerptSource), ExcerptLength), + LeadImageUrl = imageUrl, + Body = body + }; + } + + private static XElement? Child(XElement parent, string localName) => + parent.Elements().FirstOrDefault(e => e.Name.LocalName == localName); + + private static string? Text(XElement? element) + { + if (element is null) + { + return null; + } + // Atom "xhtml" content is inline markup rather than escaped text. + if ((string?)element.Attribute("type") == "xhtml") + { + return string.Concat(element.Nodes().Select(n => n.ToString())); + } + var value = element.Value.Trim(); + return value.Length == 0 ? null : value; + } + + private static string? AtomLink(XElement element) + { + var links = element.Elements(AtomNs + "link").ToList(); + var link = links.FirstOrDefault(l => (string?)l.Attribute("rel") is null or "alternate") ?? links.FirstOrDefault(); + return (string?)link?.Attribute("href"); + } + + private static string? MediaImage(XElement item) + { + var media = item.Descendants().FirstOrDefault(e => + e.Name.Namespace == MediaNs && e.Name.LocalName is "content" or "thumbnail" + && (e.Name.LocalName == "thumbnail" || ((string?)e.Attribute("medium") ?? "image") == "image" + || ((string?)e.Attribute("type") ?? "").StartsWith("image/"))); + return (string?)media?.Attribute("url"); + } + + /// Resolves a possibly relative URL; returns null unless the result is http(s). + public static string? Resolve(string? url, Uri baseUri) + { + if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(baseUri, url.Trim(), out var uri)) + { + return null; + } + return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps ? uri.AbsoluteUri : null; + } + + private static string CleanTitle(string? title) => + string.IsNullOrWhiteSpace(title) ? string.Empty : HtmlSanitizer.ToPlainText(title); + + private static string Truncate(string text, int max) => + text.Length <= max ? text : text[..max].TrimEnd() + "…"; + + /// Parses RFC 822 (RSS) and ISO 8601 (Atom, dc:date) dates, including named US time zones. + public static DateTimeOffset? ParseDate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var text = value.Trim(); + foreach (var (name, offset) in TimeZoneNames) + { + if (text.EndsWith(" " + name, StringComparison.OrdinalIgnoreCase)) + { + text = text[..^name.Length] + offset; + break; + } + } + + if (DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + { + return date.ToUniversalTime(); + } + + // Drop a leading day name ("Tue, ") that disagrees with the date, a common feed bug. + var withoutDay = DayPrefix().Replace(text, string.Empty); + return DateTimeOffset.TryParse(withoutDay, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out date) + ? date.ToUniversalTime() + : null; + } + + private static readonly (string Name, string Offset)[] TimeZoneNames = + [ + ("UT", "+0000"), ("GMT", "+0000"), ("Z", "+0000"), + ("EST", "-0500"), ("EDT", "-0400"), ("CST", "-0600"), ("CDT", "-0500"), + ("MST", "-0700"), ("MDT", "-0600"), ("PST", "-0800"), ("PDT", "-0700") + ]; + + [GeneratedRegex(@"^[A-Za-z]{3},\s*")] + private static partial Regex DayPrefix(); +} diff --git a/src/NoteBookmark.Api/FeedReader.cs b/src/NoteBookmark.Api/FeedReader.cs new file mode 100644 index 0000000..acfb2cf --- /dev/null +++ b/src/NoteBookmark.Api/FeedReader.cs @@ -0,0 +1,158 @@ +using System.Net; +using HtmlAgilityPack; +using Microsoft.Extensions.Caching.Memory; +using NoteBookmark.Domain; + +namespace NoteBookmark.Api; + +public record FeedFetchResult(List Items, string? Error); + +public interface IFeedReader +{ + /// Items of one feed, from a ~15 minute cache unless is set. Never throws for feed errors. + Task FetchAsync(Feed feed, bool refresh, CancellationToken cancellationToken = default); + + /// Finds the feed for a feed URL or a site URL (via <link rel="alternate">). Null when there is none. + Task DiscoverAsync(string url, CancellationToken cancellationToken = default); +} + +/// Downloads and parses feeds on demand. There is no background polling. +public class FeedReader(HttpClient httpClient, IMemoryCache cache, ILogger logger) : IFeedReader +{ + public static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); + public static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10); + private const long MaxDocumentBytes = 10 * 1024 * 1024; + + /// Per-download timeout; settable so tests don't wait 10 seconds. + public TimeSpan Timeout { get; set; } = FetchTimeout; + + public async Task FetchAsync(Feed feed, bool refresh, CancellationToken cancellationToken = default) + { + var cacheKey = "feed:" + FeedItemState.NormalizeUrl(feed.FeedUrl); + if (!refresh && cache.TryGetValue(cacheKey, out List? cached) && cached is not null) + { + return new FeedFetchResult(Stamp(cached, feed), null); + } + + try + { + var content = await DownloadAsync(feed.FeedUrl, cancellationToken); + var parsed = FeedParser.Parse(content, new Uri(feed.FeedUrl)); + cache.Set(cacheKey, parsed.Items, CacheDuration); + return new FeedFetchResult(Stamp(parsed.Items, feed), null); + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + var message = Describe(ex); + logger.LogWarning("Feed {FeedUrl} failed: {Error}", feed.FeedUrl, message); + return new FeedFetchResult([], message); + } + } + + public async Task DiscoverAsync(string url, CancellationToken cancellationToken = default) + { + if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return null; + } + + string content; + try + { + content = await DownloadAsync(uri.AbsoluteUri, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + logger.LogInformation("Feed discovery for {Url} failed: {Error}", url, Describe(ex)); + return null; + } + + // The URL is already a feed. + if (FeedParser.IsFeed(FeedParser.TryLoad(content))) + { + var parsed = FeedParser.Parse(content, uri); + return new FeedDiscoveryResult + { + FeedUrl = uri.AbsoluteUri, + Title = parsed.Title.Length > 0 ? parsed.Title : uri.Host, + SiteUrl = parsed.SiteUrl + }; + } + + // An HTML page: look for . + var html = new HtmlDocument(); + html.LoadHtml(content); + var feedUrl = html.DocumentNode.SelectNodes("//link[@href]")? + .Where(l => l.GetAttributeValue("rel", "").Split(' ').Contains("alternate", StringComparer.OrdinalIgnoreCase)) + .Where(l => l.GetAttributeValue("type", "").Trim().ToLowerInvariant() is "application/rss+xml" or "application/atom+xml" or "application/rdf+xml") + .Select(l => FeedParser.Resolve(WebUtility.HtmlDecode(l.GetAttributeValue("href", "")), uri)) + .FirstOrDefault(u => u is not null); + if (feedUrl is null) + { + return null; + } + + var pageTitle = HtmlSanitizer.ToPlainText(html.DocumentNode.SelectSingleNode("//title")?.InnerHtml); + try + { + var feedContent = await DownloadAsync(feedUrl, cancellationToken); + var parsed = FeedParser.Parse(feedContent, new Uri(feedUrl)); + return new FeedDiscoveryResult + { + FeedUrl = feedUrl, + Title = parsed.Title.Length > 0 ? parsed.Title : (pageTitle.Length > 0 ? pageTitle : uri.Host), + SiteUrl = parsed.SiteUrl ?? uri.AbsoluteUri + }; + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + logger.LogInformation("Discovered feed {FeedUrl} could not be read: {Error}", feedUrl, Describe(ex)); + return null; + } + } + + private async Task DownloadAsync(string url, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(Timeout); + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("Accept", + "application/rss+xml, application/atom+xml, application/rdf+xml, application/xml;q=0.9, text/xml;q=0.9, text/html;q=0.8, */*;q=0.5"); + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd(), null, response.StatusCode); + } + if (response.Content.Headers.ContentLength > MaxDocumentBytes) + { + throw new HttpRequestException("The feed is larger than 10 MB."); + } + + return await response.Content.ReadAsStringAsync(timeout.Token); + } + + private string Describe(Exception ex) => ex switch + { + OperationCanceledException => $"Timed out after {Timeout.TotalSeconds:0.#} seconds.", + FormatException => "Not a valid RSS or Atom feed.", + HttpRequestException http => http.Message, + _ => ex.Message + }; + + /// Copies cached items and attaches the feed's current title and group (a feed can move between groups). + private static List Stamp(List items, Feed feed) => + items.Select(i => new FeedItem + { + Url = i.Url, + Title = i.Title, + Published = i.Published, + Author = i.Author, + Excerpt = i.Excerpt, + LeadImageUrl = i.LeadImageUrl, + Body = i.Body, + FeedId = feed.RowKey, + FeedTitle = feed.Title, + GroupId = feed.GroupId + }).ToList(); +} diff --git a/src/NoteBookmark.Api/HtmlSanitizer.cs b/src/NoteBookmark.Api/HtmlSanitizer.cs new file mode 100644 index 0000000..28db858 --- /dev/null +++ b/src/NoteBookmark.Api/HtmlSanitizer.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Text.RegularExpressions; +using HtmlAgilityPack; + +namespace NoteBookmark.Api; + +/// +/// Allow-list HTML sanitizer for feed content shown in the preview dialog. Keeps text formatting, +/// links and images; removes scripts, styles, frames, forms, event handlers and non-http(s) URLs. +/// +public static partial class HtmlSanitizer +{ + private static readonly HashSet AllowedTags = new(StringComparer.OrdinalIgnoreCase) + { + "p", "br", "hr", "a", "b", "strong", "i", "em", "u", "s", "small", "sub", "sup", "mark", + "ul", "ol", "li", "dl", "dt", "dd", "blockquote", "q", "cite", "pre", "code", "kbd", + "h1", "h2", "h3", "h4", "h5", "h6", "img", "figure", "figcaption", "picture", + "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "span", "div", "abbr", "time" + }; + + /// Removed together with everything inside them. + private static readonly HashSet DroppedTags = new(StringComparer.OrdinalIgnoreCase) + { + "script", "style", "iframe", "frame", "frameset", "object", "embed", "applet", "form", "input", + "button", "select", "textarea", "link", "meta", "base", "noscript", "template", "svg", "math", "video", "audio", "source" + }; + + private static readonly Dictionary AllowedAttributes = new(StringComparer.OrdinalIgnoreCase) + { + ["a"] = ["href", "title"], + ["img"] = ["src", "alt", "title", "width", "height"], + ["abbr"] = ["title"], + ["time"] = ["datetime"], + ["td"] = ["colspan", "rowspan"], + ["th"] = ["colspan", "rowspan"], + }; + + public static string Sanitize(string? html, Uri baseUri) + { + if (string.IsNullOrWhiteSpace(html)) + { + return string.Empty; + } + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + CleanChildren(doc.DocumentNode, baseUri); + return doc.DocumentNode.InnerHtml.Trim(); + } + + private static void CleanChildren(HtmlNode parent, Uri baseUri) + { + foreach (var node in parent.ChildNodes.ToList()) + { + switch (node.NodeType) + { + case HtmlNodeType.Comment: + node.Remove(); + break; + case HtmlNodeType.Element when DroppedTags.Contains(node.Name): + node.Remove(); + break; + case HtmlNodeType.Element when !AllowedTags.Contains(node.Name): + // Unknown wrapper (e.g.
,
, ): keep its content. + CleanChildren(node, baseUri); + foreach (var child in node.ChildNodes.ToList()) + { + parent.InsertBefore(child, node); + } + node.Remove(); + break; + case HtmlNodeType.Element: + CleanAttributes(node, baseUri); + if (node.Name.Equals("img", StringComparison.OrdinalIgnoreCase) && node.GetAttributeValue("src", null) is null) + { + node.Remove(); + break; + } + CleanChildren(node, baseUri); + break; + } + } + } + + private static void CleanAttributes(HtmlNode node, Uri baseUri) + { + var allowed = AllowedAttributes.TryGetValue(node.Name, out var names) ? names : []; + foreach (var attribute in node.Attributes.ToList()) + { + if (!allowed.Contains(attribute.Name, StringComparer.OrdinalIgnoreCase)) + { + attribute.Remove(); + continue; + } + + if (attribute.Name is "href" or "src") + { + var url = FeedParser.Resolve(WebUtility.HtmlDecode(attribute.Value), baseUri); + if (url is null) + { + attribute.Remove(); + } + else + { + attribute.Value = url; + } + } + } + + if (node.Name.Equals("a", StringComparison.OrdinalIgnoreCase) && node.Attributes.Contains("href")) + { + node.SetAttributeValue("target", "_blank"); + node.SetAttributeValue("rel", "noopener noreferrer"); + } + } + + /// The src of the first image in already-sanitized HTML. + public static string? FirstImage(string sanitizedHtml) + { + if (string.IsNullOrEmpty(sanitizedHtml)) + { + return null; + } + var doc = new HtmlDocument(); + doc.LoadHtml(sanitizedHtml); + return doc.DocumentNode.SelectSingleNode("//img[@src]")?.GetAttributeValue("src", null); + } + + /// Text content of an HTML fragment with entities decoded and whitespace collapsed. + public static string ToPlainText(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return string.Empty; + } + var doc = new HtmlDocument(); + // Block boundaries become spaces so "

a

b

" reads "a b", not "ab". + doc.LoadHtml(BlockBoundary().Replace(html, " $0")); + foreach (var node in doc.DocumentNode.Descendants().Where(n => DroppedTags.Contains(n.Name)).ToList()) + { + node.Remove(); + } + var text = WebUtility.HtmlDecode(doc.DocumentNode.InnerText); + return Whitespace().Replace(text, " ").Trim(); + } + + [GeneratedRegex(@"<(br|/?p|/?div|/?li|/?h[1-6]|/?tr|/?td|/?th|/?blockquote)\b[^>]*>", RegexOptions.IgnoreCase)] + private static partial Regex BlockBoundary(); + + [GeneratedRegex(@"\s+")] + private static partial Regex Whitespace(); +} diff --git a/src/NoteBookmark.Api/IDataStorageService.cs b/src/NoteBookmark.Api/IDataStorageService.cs index 72b08ab..8a1d9a6 100644 --- a/src/NoteBookmark.Api/IDataStorageService.cs +++ b/src/NoteBookmark.Api/IDataStorageService.cs @@ -38,4 +38,29 @@ public interface IDataStorageService public bool DeletePost(string rowKey); public Task SaveReadingNotesMarkdown(string markdown, string number); + + /// URLs of all saved Posts, used to hide feed items that are already Posts. + public Task> GetPostUrls(); + + public Task> GetFeedGroups(); + + public Task GetFeedGroup(string id); + + public Task SaveFeedGroup(FeedGroup group); + + public Task DeleteFeedGroup(string id); + + public Task> GetFeeds(); + + public Task GetFeed(string id); + + public Task SaveFeed(Feed feed); + + public Task DeleteFeed(string id); + + public Task> GetFeedItemStates(); + + public Task SaveFeedItemStates(IEnumerable states); + + public Task PruneSkippedFeedItemStates(DateTime olderThan); } diff --git a/src/NoteBookmark.Api/OpmlParser.cs b/src/NoteBookmark.Api/OpmlParser.cs new file mode 100644 index 0000000..a289c26 --- /dev/null +++ b/src/NoteBookmark.Api/OpmlParser.cs @@ -0,0 +1,75 @@ +using System.Xml.Linq; + +namespace NoteBookmark.Api; + +/// Reads OPML subscription lists such as a Feedly export. +public static class OpmlParser +{ + /// Group used for feeds that sit at the top level of the OPML, outside any folder. + public const string DefaultGroupName = "Imported"; + + public record OpmlFeed(string GroupName, string Title, string FeedUrl, string? SiteUrl); + + /// + /// Top-level folders become groups; feeds in nested folders go to their top-level folder's group, + /// since a feed belongs to exactly one group. Throws for non-OPML input. + /// + public static List Parse(string content) + { + var doc = FeedParser.TryLoad(content); + var body = doc?.Root is { } root && root.Name.LocalName.Equals("opml", StringComparison.OrdinalIgnoreCase) + ? root.Elements().FirstOrDefault(e => e.Name.LocalName == "body") + : null; + if (body is null) + { + throw new FormatException("Not a valid OPML file."); + } + + var feeds = new List(); + foreach (var outline in Outlines(body)) + { + if (FeedUrl(outline) is not null) + { + Add(feeds, outline, DefaultGroupName); + continue; + } + + var groupName = Label(outline); + if (string.IsNullOrEmpty(groupName)) + { + groupName = DefaultGroupName; + } + foreach (var nested in outline.Descendants().Where(e => e.Name.LocalName == "outline")) + { + Add(feeds, nested, groupName); + } + } + return feeds; + } + + private static IEnumerable Outlines(XElement parent) => parent.Elements().Where(e => e.Name.LocalName == "outline"); + + private static void Add(List feeds, XElement outline, string groupName) + { + var url = FeedUrl(outline); + if (url is null) + { + return; + } + var siteUrl = (string?)outline.Attribute("htmlUrl"); + var title = Label(outline); + feeds.Add(new OpmlFeed(groupName, string.IsNullOrEmpty(title) ? url : title, url, + Uri.TryCreate(siteUrl, UriKind.Absolute, out var site) && site.Scheme.StartsWith("http") ? site.AbsoluteUri : null)); + } + + private static string? FeedUrl(XElement outline) + { + var value = ((string?)outline.Attribute("xmlUrl"))?.Trim(); + return Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) + ? uri.AbsoluteUri + : null; + } + + private static string Label(XElement outline) => + (((string?)outline.Attribute("title")) ?? ((string?)outline.Attribute("text")) ?? string.Empty).Trim(); +} diff --git a/src/NoteBookmark.Api/Program.cs b/src/NoteBookmark.Api/Program.cs index 55fd0f6..c816d98 100644 --- a/src/NoteBookmark.Api/Program.cs +++ b/src/NoteBookmark.Api/Program.cs @@ -23,6 +23,13 @@ // Register AI settings provider builder.Services.AddScoped(); +// Feeds tab: on-demand RSS/Atom fetching with an in-memory cache +builder.Services.AddMemoryCache(); +builder.Services.AddHttpClient(client => +{ + client.DefaultRequestHeaders.UserAgent.ParseAdd("NoteBookmark/1.0 (+https://github.com/fboucher/NoteBookmark)"); +}); + var app = builder.Build(); app.MapDefaultEndpoints(); @@ -40,6 +47,7 @@ app.MapNoteEndpoints(); app.MapSummaryEndpoints(); app.MapSettingEndpoints(); +app.MapFeedEndpoints(); app.Run(); diff --git a/src/NoteBookmark.BlazorApp.Tests/Helpers/FeedItemDialogTestWrapper.razor b/src/NoteBookmark.BlazorApp.Tests/Helpers/FeedItemDialogTestWrapper.razor new file mode 100644 index 0000000..a450092 --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Helpers/FeedItemDialogTestWrapper.razor @@ -0,0 +1,15 @@ +@using NoteBookmark.SharedUI.Components.Shared +@using NoteBookmark.Domain +@using Microsoft.FluentUI.AspNetCore.Components + + + + + +@code { + [Parameter] + public FeedItem Content { get; set; } = default!; + + [Parameter] + public EventCallback OnClose { get; set; } +} diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/FeedClientTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/FeedClientTests.cs new file mode 100644 index 0000000..603993c --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/FeedClientTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +public class FeedClientTests +{ + private sealed class RecordingHandler(HttpStatusCode status, string body) : HttpMessageHandler + { + public List<(HttpMethod Method, string Url, string? Body)> Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add((request.Method, request.RequestUri!.PathAndQuery, + request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken))); + return new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + } + } + + private static (FeedClient Client, RecordingHandler Handler) Create(HttpStatusCode status = HttpStatusCode.OK, string body = "{}") + { + var handler = new RecordingHandler(status, body); + return (new FeedClient(new HttpClient(handler) { BaseAddress = new Uri("http://localhost/") }), handler); + } + + [Fact] + public async Task GetItems_BuildsQueryFromArguments() + { + var (client, handler) = Create(body: """{"items":[],"feeds":[]}"""); + + await client.GetItems(); + await client.GetItems(groupId: "g 1", refresh: true); + await client.GetItems(feedId: "f1"); + + handler.Requests.Select(r => r.Url).Should().Equal( + "/api/feeds/items", + "/api/feeds/items?groupId=g%201&refresh=true", + "/api/feeds/items?feedId=f1"); + } + + [Fact] + public async Task SaveGroup_Conflict_ReturnsApiMessage() + { + var (client, _) = Create(HttpStatusCode.Conflict, JsonSerializer.Serialize("A group named 'Dev' already exists.")); + + var result = await client.SaveGroup(new FeedGroup { Name = "Dev" }); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("A group named 'Dev' already exists."); + } + + [Fact] + public async Task DeleteFeed_NotFoundWithoutBody_ReturnsGenericMessage() + { + var (client, _) = Create(HttpStatusCode.NotFound, ""); + + var result = await client.DeleteFeed("x"); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Not found."); + } + + [Fact] + public async Task SetItemStates_PostsOneStatePerItem() + { + var (client, handler) = Create(); + var items = new[] + { + new FeedItem { Url = "https://a.example.com/1", FeedId = "f1" }, + new FeedItem { Url = "https://a.example.com/2", FeedId = "f1" } + }; + + var ok = await client.SetItemStates(items, FeedItemState.Skipped); + + ok.Should().BeTrue(); + var request = handler.Requests.Single(); + request.Url.Should().Be("/api/feeds/items/states"); + var sent = JsonSerializer.Deserialize>(request.Body!, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + sent.Select(s => (s.Url, s.State, s.FeedId)).Should().Equal( + ("https://a.example.com/1", "Skipped", "f1"), + ("https://a.example.com/2", "Skipped", "f1")); + } + + [Fact] + public async Task SetItemStates_NoItems_SkipsRequest() + { + var (client, handler) = Create(); + + (await client.SetItemStates([], FeedItemState.Skipped)).Should().BeTrue(); + + handler.Requests.Should().BeEmpty(); + } +} diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/FeedItemDialogTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/FeedItemDialogTests.cs new file mode 100644 index 0000000..85f0d47 --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/FeedItemDialogTests.cs @@ -0,0 +1,144 @@ +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using NoteBookmark.BlazorApp.Tests.Helpers; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +public sealed class FeedItemDialogTests : BunitContext +{ + private readonly Mock _data = new(); + private readonly Mock _feeds = new(); + private readonly Mock _toasts = new(); + private readonly Mock _launcher = new(); + private readonly List _closed = []; + + private readonly FeedItem _item = new() + { + Url = "https://blog.example.com/great-post", + Title = "A great post", + FeedId = "f-1", + FeedTitle = "Some blog", + Author = "Ada", + Published = new DateTimeOffset(2026, 10, 30, 12, 0, 0, TimeSpan.Zero), + LeadImageUrl = "https://img.example.com/lead.jpg", + Body = "

Hello readers

" + }; + + public FeedItemDialogTests() + { + this.AddFluentUI(); + Services.AddSingleton(_data.Object); + Services.AddSingleton(_feeds.Object); + Services.AddSingleton(_toasts.Object); + Services.AddSingleton(_launcher.Object); + _feeds.Setup(f => f.SetItemStates(It.IsAny>(), It.IsAny())).ReturnsAsync(true); + } + + private IRenderedComponent RenderDialog(FeedItem? item = null) => + Render(p => p + .Add(x => x.Content, item ?? _item) + .Add(x => x.OnClose, (string? state) => _closed.Add(state))); + + private static Task Click(IRenderedComponent cut, string id) => + cut.InvokeAsync(() => cut.Find($"fluent-button#{id}").Click()); + + [Fact] + public void Renders_SourceDateAuthorImageAndBody() + { + var cut = RenderDialog(); + + cut.Find(".feed-preview-meta").TextContent.Should().Contain("Some blog").And.Contain("Ada").And.Contain("2026"); + cut.Find(".feed-preview-image").GetAttribute("src").Should().Be(_item.LeadImageUrl); + cut.Find(".feed-preview-body strong").TextContent.Should().Be("readers"); + } + + [Fact] + public void LeadImage_NotRepeated_WhenBodyAlreadyShowsIt() + { + var item = new FeedItem { Url = _item.Url, Title = "x", LeadImageUrl = "https://img.example.com/a.png", Body = "" }; + + var cut = RenderDialog(item); + + cut.FindAll(".feed-preview-image").Should().BeEmpty(); + } + + [Fact] + public void EmptyBody_ExplainsToOpenOriginal() + { + var cut = RenderDialog(new FeedItem { Url = _item.Url, Title = "x" }); + + cut.Find(".feed-preview-empty").TextContent.Should().Contain("Open original"); + } + + [Fact] + public async Task Add_SavesPostThroughExistingPath_MarksAdded_AndCloses() + { + _data.Setup(d => d.ExtractPostDetailsAndSave(_item.Url)) + .ReturnsAsync(new Post { PartitionKey = "p", RowKey = "r", Title = "A great post" }); + var cut = RenderDialog(); + + await Click(cut, "preview-add"); + + _data.Verify(d => d.ExtractPostDetailsAndSave(_item.Url), Times.Once); + _feeds.Verify(f => f.SetItemStates(It.Is>(i => i.Single().Url == _item.Url), FeedItemState.Added), Times.Once); + _closed.Should().Equal(FeedItemState.Added); + } + + [Fact] + public async Task Add_WhenSaveFails_StaysOpen() + { + _data.Setup(d => d.ExtractPostDetailsAndSave(_item.Url)).ReturnsAsync((Post?)null); + var cut = RenderDialog(); + + await Click(cut, "preview-add"); + + _feeds.Verify(f => f.SetItemStates(It.IsAny>(), It.IsAny()), Times.Never); + _closed.Should().BeEmpty(); + } + + [Fact] + public async Task Skip_MarksSkipped_AndCloses() + { + var cut = RenderDialog(); + + await Click(cut, "preview-skip"); + + _feeds.Verify(f => f.SetItemStates(It.Is>(i => i.Single().Url == _item.Url), FeedItemState.Skipped), Times.Once); + _data.Verify(d => d.ExtractPostDetailsAndSave(It.IsAny()), Times.Never); + _closed.Should().Equal(FeedItemState.Skipped); + } + + [Fact] + public async Task Skip_WhenApiFails_StaysOpen() + { + _feeds.Setup(f => f.SetItemStates(It.IsAny>(), FeedItemState.Skipped)).ReturnsAsync(false); + var cut = RenderDialog(); + + await Click(cut, "preview-skip"); + + _closed.Should().BeEmpty(); + } + + [Fact] + public async Task Close_ClosesWithoutChangingState() + { + var cut = RenderDialog(); + + await Click(cut, "preview-close"); + + _closed.Should().Equal((string?)null); + _feeds.Verify(f => f.SetItemStates(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task OpenOriginal_OpensRealUrlInNewTab_AndKeepsDialogOpen() + { + var cut = RenderDialog(); + + await Click(cut, "preview-open"); + + _launcher.Verify(l => l.OpenUrlAsync(_item.Url), Times.Once); + _closed.Should().BeEmpty(); + } +} diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/FeedsPageTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/FeedsPageTests.cs new file mode 100644 index 0000000..a76bcf6 --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/FeedsPageTests.cs @@ -0,0 +1,217 @@ +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using NoteBookmark.BlazorApp.Tests.Helpers; +using NoteBookmark.SharedUI.Components.Pages; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +public sealed class FeedsPageTests : BunitContext +{ + private readonly Mock _feeds = new(); + private readonly Mock _toasts = new(); + private static readonly DateTimeOffset Now = DateTimeOffset.UtcNow; + + private readonly List _groups = + [ + new() { RowKey = "g-dev", Name = "Dev" }, + new() { RowKey = "g-news", Name = "News" } + ]; + + private readonly List _feedList = + [ + new() { RowKey = "f-a", Title = "Blog A", FeedUrl = "https://a.example.com/rss", GroupId = "g-dev" }, + new() { RowKey = "f-b", Title = "Blog B", FeedUrl = "https://b.example.com/rss", GroupId = "g-dev" }, + new() { RowKey = "f-n", Title = "Newsy", FeedUrl = "https://n.example.com/rss", GroupId = "g-news" } + ]; + + private static FeedItem Item(string feedId, string groupId, string title, double hoursAgo) => new() + { + Url = $"https://example.com/{Guid.NewGuid():N}", + Title = title, + FeedId = feedId, + FeedTitle = feedId == "f-a" ? "Blog A" : feedId == "f-b" ? "Blog B" : "Newsy", + GroupId = groupId, + Published = Now.AddHours(-hoursAgo), + Excerpt = $"About {title}" + }; + + private FeedItemsResponse _response; + + public FeedsPageTests() + { + this.AddFluentUI(); + Services.AddSingleton(_feeds.Object); + Services.AddSingleton(_toasts.Object); + _response = new FeedItemsResponse + { + Items = + [ + Item("f-a", "g-dev", "Dev newest", 1), + Item("f-n", "g-news", "News item", 2), + Item("f-a", "g-dev", "Dev older", 30) + ], + Feeds = + [ + new() { FeedId = "f-a", GroupId = "g-dev", PendingCount = 2 }, + new() { FeedId = "f-b", GroupId = "g-dev", Error = "HTTP 404 Not Found" }, + new() { FeedId = "f-n", GroupId = "g-news", PendingCount = 1 } + ] + }; + _feeds.Setup(f => f.GetGroups()).ReturnsAsync(() => _groups); + _feeds.Setup(f => f.GetFeeds()).ReturnsAsync(() => _feedList); + _feeds.Setup(f => f.GetItems(null, null, false)).ReturnsAsync(() => _response); + } + + private static List Titles(IRenderedComponent cut) => + cut.FindAll(".feed-item-title").Select(e => e.TextContent).ToList(); + + [Fact] + public void ShowsAllItemsNewestFirst_WithCounts() + { + var cut = Render(); + + Titles(cut).Should().Equal("Dev newest", "News item", "Dev older"); + cut.Find("#nav-all .feeds-count").TextContent.Should().Be("3"); + cut.Find("[data-group-id='g-dev'] .feeds-count").TextContent.Should().Be("2"); + cut.Find("[data-group-id='g-news'] .feeds-count").TextContent.Should().Be("1"); + } + + [Fact] + public void ItemRow_ShowsFeedNameAgeAndExcerpt() + { + var cut = Render(); + + var first = cut.FindAll(".feed-item")[0]; + first.QuerySelector(".feed-item-meta")!.TextContent.Should().Be("Blog A · 1h"); + first.QuerySelector(".feed-item-excerpt")!.TextContent.Should().Be("About Dev newest"); + } + + [Fact] + public void SelectingGroup_FiltersItems() + { + var cut = Render(); + + cut.Find("[data-group-id='g-news']").Click(); + + Titles(cut).Should().Equal("News item"); + } + + [Fact] + public void ExpandingGroup_ListsFeeds_WithErrorTooltip() + { + var cut = Render(); + cut.FindAll(".feeds-nav-row.feed").Should().BeEmpty(); + cut.Find("[data-group-id='g-dev'] .feeds-warning").Should().NotBeNull(); // collapsed group shows a warning + + cut.Find("[data-group-id='g-dev'] .feeds-expand").Click(); + + cut.FindAll(".feeds-nav-row.feed").Should().HaveCount(2); + cut.Find("[data-feed-id='f-b'] .feeds-warning").GetAttribute("title").Should().Be("HTTP 404 Not Found"); + cut.FindAll("[data-feed-id='f-a'] .feeds-warning").Should().BeEmpty(); + } + + [Fact] + public void SelectingFeed_FiltersItems() + { + var cut = Render(); + cut.Find("[data-group-id='g-dev'] .feeds-expand").Click(); + + cut.Find("[data-feed-id='f-b']").Click(); + + cut.FindAll(".feed-item").Should().BeEmpty(); + cut.Markup.Should().Contain("All caught up"); + } + + [Fact] + public void MarkAllSkipped_IsDisabledForAll() + { + var cut = Render(); + + cut.FindComponents().Single(b => b.Instance.Id == "skip-all").Instance.Disabled.Should().BeTrue(); + } + + [Fact] + public async Task MarkAllSkipped_SkipsOnlyTheSelectedGroup() + { + _feeds.Setup(f => f.SetItemStates(It.IsAny>(), FeedItemState.Skipped)).ReturnsAsync(true); + var cut = Render(); + cut.Find("[data-group-id='g-dev']").Click(); + + await cut.InvokeAsync(() => cut.Find("fluent-button#skip-all").Click()); + + _feeds.Verify(f => f.SetItemStates( + It.Is>(items => items.Select(i => i.Title).OrderBy(t => t).SequenceEqual(new[] { "Dev newest", "Dev older" })), + FeedItemState.Skipped), Times.Once); + cut.FindAll(".feed-item").Should().BeEmpty(); + cut.Find("#nav-all .feeds-count").TextContent.Should().Be("1"); + } + + [Fact] + public async Task Refresh_ForSelectedGroup_BypassesCache_AndMergesItems() + { + _feeds.Setup(f => f.GetItems("g-news", null, true)).ReturnsAsync(new FeedItemsResponse + { + Items = [Item("f-n", "g-news", "Fresh news", 0.5), Item("f-n", "g-news", "News item", 2)], + Feeds = [new() { FeedId = "f-n", GroupId = "g-news", PendingCount = 2 }] + }); + var cut = Render(); + cut.Find("[data-group-id='g-news']").Click(); + + await cut.InvokeAsync(() => cut.Find("fluent-button#refresh-feeds").Click()); + + Titles(cut).Should().Equal("Fresh news", "News item"); + cut.Find("#nav-all .feeds-count").TextContent.Should().Be("4"); // Dev items kept + } + + [Fact] + public void WithoutFeeds_ShowsManageLink() + { + _feedList.Clear(); + + var cut = Render(); + + cut.Find("#no-feeds a").GetAttribute("href").Should().Be("feeds/manage"); + _feeds.Verify(f => f.GetItems(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("Skipped", true)] + [InlineData("Added", true)] + [InlineData(null, false)] + public async Task ClickingItem_OpensPreview_AndRemovesItemOnlyWhenAddedOrSkipped(string? state, bool removed) + { + var dialogs = new Mock(); + var reference = new Mock(); + reference.Setup(r => r.Result).ReturnsAsync(state is null ? DialogResult.Cancel() : DialogResult.Ok(state)); + dialogs.Setup(d => d.ShowDialogAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(reference.Object); + Services.AddSingleton(dialogs.Object); + var cut = Render(); + + await cut.InvokeAsync(() => cut.FindAll(".feed-item")[0].Click()); + + dialogs.Verify(d => d.ShowDialogAsync( + It.Is(o => ((FeedItem)o).Title == "Dev newest"), + It.Is(p => p.Title == "Dev newest")), Times.Once); + Titles(cut).Contains("Dev newest").Should().Be(!removed); + } + + [Theory] + [InlineData(0.2, "just now")] + [InlineData(45, "45m")] + [InlineData(5 * 60, "5h")] + [InlineData(3 * 24 * 60, "3d")] + public void Age_FormatsCompactly(double minutesAgo, string expected) + { + var now = DateTimeOffset.UtcNow; + + Feeds.Age(now.AddMinutes(-minutesAgo), now).Should().Be(expected); + } + + [Fact] + public void Age_Undated() + { + Feeds.Age(null).Should().Be("undated"); + } +} diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/ManageFeedsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/ManageFeedsTests.cs new file mode 100644 index 0000000..d5bd37d --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/ManageFeedsTests.cs @@ -0,0 +1,168 @@ +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using NoteBookmark.BlazorApp.Tests.Helpers; +using NoteBookmark.SharedUI.Components.Pages; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +public sealed class ManageFeedsTests : BunitContext +{ + private readonly Mock _feeds = new(); + private readonly Mock _toasts = new(); + private readonly FeedGroup _dev = new() { RowKey = "g-dev", Name = "Dev" }; + private readonly FeedGroup _news = new() { RowKey = "g-news", Name = "News" }; + private readonly Feed _blog = new() { RowKey = "f-1", Title = "Some blog", FeedUrl = "https://blog.example.com/rss", GroupId = "g-dev" }; + + public ManageFeedsTests() + { + this.AddFluentUI(); + Services.AddSingleton(_feeds.Object); + Services.AddSingleton(_toasts.Object); + _feeds.Setup(f => f.GetGroups()).ReturnsAsync(() => [_dev, _news]); + _feeds.Setup(f => f.GetFeeds()).ReturnsAsync(() => [_blog]); + } + + private static Task SetText(IRenderedComponent cut, string id, string value) => + cut.InvokeAsync(() => cut.FindComponents().Single(t => t.Instance.Id == id).Instance.ValueChanged.InvokeAsync(value)); + + private static Task ClickButton(IRenderedComponent cut, string selector) => + cut.InvokeAsync(() => cut.Find(selector).Click()); + + [Fact] + public void Renders_GroupsAndFeeds() + { + var cut = Render(); + + cut.FindAll("li.group-row").Should().HaveCount(2); + cut.Find("[data-feed-id='f-1']").TextContent.Should().Contain("Some blog").And.Contain("https://blog.example.com/rss"); + cut.Find("[data-group-id='g-dev'] .feed-count").TextContent.Should().Contain("1 feed"); + } + + [Fact] + public void WithoutGroups_ShowsHint() + { + _feeds.Setup(f => f.GetGroups()).ReturnsAsync([]); + _feeds.Setup(f => f.GetFeeds()).ReturnsAsync([]); + + var cut = Render(); + + cut.Find("#no-group-hint").TextContent.Should().Contain("Create a group"); + } + + [Fact] + public async Task AddGroup_SavesTrimmedName_AndListsIt() + { + _feeds.Setup(f => f.SaveGroup(It.IsAny())) + .ReturnsAsync((FeedGroup g) => new FeedResult(new FeedGroup { RowKey = "g-new", Name = g.Name }, null)); + var cut = Render(); + + await SetText(cut, "new-group-name", " Cloud "); + await ClickButton(cut, "fluent-button#add-group"); + + _feeds.Verify(f => f.SaveGroup(It.Is(g => g.Name == "Cloud")), Times.Once); + cut.FindAll("li.group-row").Should().HaveCount(3); + } + + [Fact] + public async Task AddFeed_DiscoversThenSavesInSelectedGroup() + { + _feeds.Setup(f => f.Discover("https://site.example.com")) + .ReturnsAsync(new FeedResult(new FeedDiscoveryResult { FeedUrl = "https://site.example.com/feed", Title = "Site", SiteUrl = "https://site.example.com/" }, null)); + _feeds.Setup(f => f.SaveFeed(It.IsAny())).ReturnsAsync((Feed f) => new FeedResult(f, null)); + var cut = Render(); + + await SetText(cut, "new-feed-url", "https://site.example.com"); + await ClickButton(cut, "fluent-button#add-feed"); + + _feeds.Verify(f => f.SaveFeed(It.Is(x => + x.FeedUrl == "https://site.example.com/feed" && x.Title == "Site" && x.GroupId == "g-dev")), Times.Once); + cut.FindAll(".feed-row").Should().HaveCount(2); + } + + [Fact] + public async Task AddFeed_WhenNothingDiscovered_DoesNotSave() + { + _feeds.Setup(f => f.Discover(It.IsAny())) + .ReturnsAsync(new FeedResult(null, "No RSS or Atom feed was found at this address.")); + var cut = Render(); + + await SetText(cut, "new-feed-url", "https://nofeed.example.com"); + await ClickButton(cut, "fluent-button#add-feed"); + + _feeds.Verify(f => f.SaveFeed(It.IsAny()), Times.Never); + cut.FindAll(".feed-row").Should().HaveCount(1); + } + + [Fact] + public async Task RenameGroup_SavesNewNameWithSameId() + { + _feeds.Setup(f => f.SaveGroup(It.IsAny())).ReturnsAsync((FeedGroup g) => new FeedResult(g, null)); + var cut = Render(); + + await cut.InvokeAsync(() => cut.FindComponents() + .Single(t => t.Instance.Value == "News").Instance.ValueChanged.InvokeAsync("World news")); + await ClickButton(cut, "[data-group-id='g-news'] fluent-button.rename-group"); + + _feeds.Verify(f => f.SaveGroup(It.Is(g => g.RowKey == "g-news" && g.Name == "World news")), Times.Once); + } + + [Fact] + public async Task DeleteGroup_RefusedByApi_KeepsGroup() + { + _feeds.Setup(f => f.DeleteGroup("g-dev")).ReturnsAsync(new FeedResult(false, "The group still contains 1 feed(s).")); + var cut = Render(); + + await ClickButton(cut, "[data-group-id='g-dev'] fluent-button.delete-group"); + + cut.FindAll("li.group-row").Should().HaveCount(2); + } + + [Fact] + public async Task DeleteGroup_Empty_RemovesIt() + { + _feeds.Setup(f => f.DeleteGroup("g-news")).ReturnsAsync(new FeedResult(true, null)); + var cut = Render(); + + await ClickButton(cut, "[data-group-id='g-news'] fluent-button.delete-group"); + + cut.FindAll("li.group-row").Should().ContainSingle(); + } + + [Fact] + public async Task MoveFeed_SavesFeedWithNewGroup() + { + _feeds.Setup(f => f.SaveFeed(It.IsAny())).ReturnsAsync((Feed f) => new FeedResult(f, null)); + var cut = Render(); + var select = cut.FindComponents>().Single(s => s.Instance.Id != "new-feed-group"); + + await cut.InvokeAsync(() => select.Instance.SelectedOptionChanged.InvokeAsync(_news)); + + _feeds.Verify(f => f.SaveFeed(It.Is(x => x.RowKey == "f-1" && x.GroupId == "g-news")), Times.Once); + } + + [Fact] + public async Task DeleteFeed_RemovesRow() + { + _feeds.Setup(f => f.DeleteFeed("f-1")).ReturnsAsync(new FeedResult(true, null)); + var cut = Render(); + + await ClickButton(cut, "[data-feed-id='f-1'] fluent-button.delete-feed"); + + cut.FindAll(".feed-row").Should().BeEmpty(); + } + + [Fact] + public async Task ImportOpml_SendsFileContent_AndReloads() + { + _feeds.Setup(f => f.ImportOpml(It.IsAny())) + .ReturnsAsync(new FeedResult(new OpmlImportResult { FeedsCreated = 2, GroupsCreated = 1 }, null)); + var cut = Render(); + + var input = cut.FindComponent(); + await cut.InvokeAsync(() => input.UploadFiles(InputFileContent.CreateFromText("", "feedly.opml"))); + + _feeds.Verify(f => f.ImportOpml(""), Times.Once); + _feeds.Verify(f => f.GetGroups(), Times.Exactly(2)); + } +} diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/NavMenuTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/NavMenuTests.cs index 84de226..cf6f12b 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/NavMenuTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/NavMenuTests.cs @@ -55,4 +55,12 @@ public void NavMenu_ContainsSearchLink() cut.Markup.Should().Contain("search"); } + + [Fact] + public void NavMenu_ContainsFeedsLink() + { + var cut = Render(); + + cut.Markup.Should().Contain("href=\"feeds\""); + } } diff --git a/src/NoteBookmark.BlazorApp/Components/Layout/NavMenu.razor b/src/NoteBookmark.BlazorApp/Components/Layout/NavMenu.razor index fa00510..2e6df46 100644 --- a/src/NoteBookmark.BlazorApp/Components/Layout/NavMenu.razor +++ b/src/NoteBookmark.BlazorApp/Components/Layout/NavMenu.razor @@ -9,6 +9,7 @@ Generate Summary Summaries Posts + Feeds Search diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index a1b3d69..29f7f46 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -18,6 +18,11 @@ client.BaseAddress = new Uri("https+http://api"); }); builder.Services.AddTransient(sp => sp.GetRequiredService()); +// Feeds tab (web app only in v1) +builder.Services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https+http://api"); + }); builder.Services.AddScoped(); builder.Services.AddHttpClient(nameof(NewsFeedService)); builder.Services.AddSingleton(sp => new NewsFeedService( diff --git a/src/NoteBookmark.Domain/Feed.cs b/src/NoteBookmark.Domain/Feed.cs new file mode 100644 index 0000000..450d128 --- /dev/null +++ b/src/NoteBookmark.Domain/Feed.cs @@ -0,0 +1,38 @@ +using System.Runtime.Serialization; +using Azure; +using Azure.Data.Tables; + +namespace NoteBookmark.Domain; + +/// An RSS or Atom feed the user follows. +public class Feed : ITableEntity +{ + public const string Partition = "feed"; + + [DataMember(Name = "title")] + public string Title { get; set; } = string.Empty; + + /// The RSS/Atom document URL. + [DataMember(Name = "feed_url")] + public string FeedUrl { get; set; } = string.Empty; + + /// The website the feed belongs to, when known. + [DataMember(Name = "site_url")] + public string? SiteUrl { get; set; } + + /// RowKey of the this feed belongs to. + [DataMember(Name = "group_id")] + public string GroupId { get; set; } = string.Empty; + + [DataMember(Name = "date_modified")] + public DateTime DateModified { get; set; } = DateTime.UtcNow; + + public string PartitionKey { get; set; } = Partition; + + /// The feed id. + public string RowKey { get; set; } = Guid.NewGuid().ToString(); + + public DateTimeOffset? Timestamp { get; set; } + + public ETag ETag { get; set; } +} diff --git a/src/NoteBookmark.Domain/FeedGroup.cs b/src/NoteBookmark.Domain/FeedGroup.cs new file mode 100644 index 0000000..9a73cf3 --- /dev/null +++ b/src/NoteBookmark.Domain/FeedGroup.cs @@ -0,0 +1,26 @@ +using System.Runtime.Serialization; +using Azure; +using Azure.Data.Tables; + +namespace NoteBookmark.Domain; + +/// A user-defined group of feeds (Feedly-style "collection"). Each feed belongs to exactly one group. +public class FeedGroup : ITableEntity +{ + public const string Partition = "feedgroup"; + + [DataMember(Name = "name")] + public string Name { get; set; } = string.Empty; + + [DataMember(Name = "date_modified")] + public DateTime DateModified { get; set; } = DateTime.UtcNow; + + public string PartitionKey { get; set; } = Partition; + + /// The group id. + public string RowKey { get; set; } = Guid.NewGuid().ToString(); + + public DateTimeOffset? Timestamp { get; set; } + + public ETag ETag { get; set; } +} diff --git a/src/NoteBookmark.Domain/FeedItem.cs b/src/NoteBookmark.Domain/FeedItem.cs new file mode 100644 index 0000000..e42e3c6 --- /dev/null +++ b/src/NoteBookmark.Domain/FeedItem.cs @@ -0,0 +1,79 @@ +namespace NoteBookmark.Domain; + +/// One article from a feed, as shown in the Feeds tab and its preview dialog. Not stored. +public class FeedItem +{ + public string Url { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string FeedId { get; set; } = string.Empty; + + public string FeedTitle { get; set; } = string.Empty; + + public string GroupId { get; set; } = string.Empty; + + public DateTimeOffset? Published { get; set; } + + public string? Author { get; set; } + + /// Plain-text one-liner for the list. + public string Excerpt { get; set; } = string.Empty; + + public string? LeadImageUrl { get; set; } + + /// Feed-provided HTML, sanitized by the API (no scripts, styles, frames or event handlers). + public string Body { get; set; } = string.Empty; +} + +/// Result of loading one feed: how many items are pending, or why it failed. +public class FeedStatus +{ + public string FeedId { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string GroupId { get; set; } = string.Empty; + + public int PendingCount { get; set; } + + /// Null when the feed loaded fine. + public string? Error { get; set; } +} + +/// Response of GET api/feeds/items. +public class FeedItemsResponse +{ + /// Pending items, newest first. + public List Items { get; set; } = []; + + /// One entry per requested feed, including failed ones. + public List Feeds { get; set; } = []; +} + +/// Response of GET api/feeds/discover. +public class FeedDiscoveryResult +{ + public string FeedUrl { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string? SiteUrl { get; set; } +} + +/// Body of POST api/feeds/import-opml. +public class OpmlImportRequest +{ + public string Opml { get; set; } = string.Empty; +} + +/// Response of POST api/feeds/import-opml. +public class OpmlImportResult +{ + public int GroupsCreated { get; set; } + + public int FeedsCreated { get; set; } + + /// Feeds already in the list (same URL), left untouched. + public int FeedsSkipped { get; set; } +} diff --git a/src/NoteBookmark.Domain/FeedItemState.cs b/src/NoteBookmark.Domain/FeedItemState.cs new file mode 100644 index 0000000..99934cb --- /dev/null +++ b/src/NoteBookmark.Domain/FeedItemState.cs @@ -0,0 +1,75 @@ +using System.Runtime.Serialization; +using System.Security.Cryptography; +using System.Text; +using Azure; +using Azure.Data.Tables; + +namespace NoteBookmark.Domain; + +/// +/// What the user did with a feed item. Items are tracked by URL; once Added or Skipped +/// they leave the Feeds list (inbox model). +/// +public class FeedItemState : ITableEntity +{ + public const string Partition = "feeditemstate"; + public const string Added = "Added"; + public const string Skipped = "Skipped"; + + [DataMember(Name = "url")] + public string Url { get; set; } = string.Empty; + + /// or . + [DataMember(Name = "state")] + public string State { get; set; } = string.Empty; + + [DataMember(Name = "feed_id")] + public string? FeedId { get; set; } + + [DataMember(Name = "date_modified")] + public DateTime DateModified { get; set; } = DateTime.UtcNow; + + public string PartitionKey { get; set; } = Partition; + + /// Hash of the normalized URL, see . + public string RowKey { get; set; } = string.Empty; + + public DateTimeOffset? Timestamp { get; set; } + + public ETag ETag { get; set; } + + public static bool IsValidState(string? state) => state is Added or Skipped; + + /// + /// Normalizes a URL so trivially different spellings of the same article match: + /// trims whitespace, drops the fragment, lowercases scheme and host, removes a trailing slash. + /// + public static string NormalizeUrl(string url) + { + var trimmed = url.Trim(); + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + return trimmed; + } + + var builder = new UriBuilder(uri) { Fragment = string.Empty }; + builder.Scheme = builder.Scheme.ToLowerInvariant(); + builder.Host = builder.Host.ToLowerInvariant(); + if (builder.Path.Length > 1 && builder.Path.EndsWith('/')) + { + builder.Path = builder.Path.TrimEnd('/'); + } + + return builder.Uri.GetComponents(UriComponents.AbsoluteUri & ~UriComponents.Fragment, UriFormat.UriEscaped); + } + + /// + /// Table Storage keys can't contain '/', '\', '#' or '?', so the RowKey is a SHA-256 hash + /// (lowercase hex) of the normalized URL. + /// + public static string KeyForUrl(string url) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url))); + return Convert.ToHexStringLower(bytes); + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Feeds.razor b/src/NoteBookmark.SharedUI/Components/Pages/Feeds.razor new file mode 100644 index 0000000..ab4f363 --- /dev/null +++ b/src/NoteBookmark.SharedUI/Components/Pages/Feeds.razor @@ -0,0 +1,290 @@ +@page "/feeds" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@inject IFeedService feedService +@inject IToastService toastService +@inject IDialogService dialogService + +Feeds + + +

Feeds

+ + Refresh + Mark all as skipped + Manage feeds +
+ +@if (loading) +{ + +} +else if (feeds.Count == 0) +{ +

You don't follow any feeds yet. Add a feed or import an OPML file.

+} +else +{ +
+ + +
+ @if (VisibleItems.Count == 0) + { +

+   All caught up. Nothing new here. +

+ } + @foreach (var item in VisibleItems) + { +
+
@item.Title
+
@item.FeedTitle · @Age(item.Published)
+ @if (!string.IsNullOrEmpty(item.Excerpt)) + { +
@item.Excerpt
+ } +
+ } +
+
+} + + + +@code { + /// What the right pane shows: everything, one group, or one feed. + public record Selection(string? GroupId, string? FeedId) + { + public static readonly Selection All = new(null, null); + public static Selection ForGroup(string groupId) => new(groupId, null); + public static Selection ForFeed(string groupId, string feedId) => new(groupId, feedId); + public bool IsAll => GroupId is null && FeedId is null; + } + + private bool loading = true; + private bool refreshing; + private List groups = []; + private List feeds = []; + private List items = []; + private Dictionary statuses = []; + private readonly HashSet expandedGroups = []; + private Selection selection = Selection.All; + + private List VisibleItems => items + .Where(i => selection.FeedId is null || i.FeedId == selection.FeedId) + .Where(i => selection.GroupId is null || i.GroupId == selection.GroupId) + .ToList(); + + protected override async Task OnInitializedAsync() + { + try + { + groups = await feedService.GetGroups(); + feeds = await feedService.GetFeeds(); + if (feeds.Count > 0) + { + Apply(await feedService.GetItems(), replaceAll: true); + } + } + catch (HttpRequestException ex) + { + toastService.ShowError($"Feeds could not be loaded: {ex.Message}"); + } + finally + { + loading = false; + } + } + + /// Merges a response: items and statuses of the returned feeds replace what we had for them. + private void Apply(FeedItemsResponse response, bool replaceAll) + { + var feedIds = response.Feeds.Select(f => f.FeedId).ToHashSet(); + if (replaceAll) + { + items = []; + statuses = []; + } + else + { + items.RemoveAll(i => feedIds.Contains(i.FeedId)); + } + + items.AddRange(response.Items); + items = items.OrderByDescending(i => i.Published ?? DateTimeOffset.MinValue).ToList(); + foreach (var status in response.Feeds) + { + statuses[status.FeedId] = status; + } + } + + private async Task Refresh() + { + refreshing = true; + try + { + var response = await feedService.GetItems(selection.FeedId is null ? selection.GroupId : null, selection.FeedId, refresh: true); + Apply(response, replaceAll: selection.IsAll); + var failed = response.Feeds.Count(f => f.Error is not null); + if (failed > 0) + { + toastService.ShowWarning($"{failed} feed(s) could not be loaded. Hover the warning icon for details."); + } + } + catch (HttpRequestException ex) + { + toastService.ShowError($"Refresh failed: {ex.Message}"); + } + finally + { + refreshing = false; + } + } + + private async Task MarkAllSkipped() + { + var toSkip = VisibleItems; + if (toSkip.Count == 0) + { + return; + } + + if (await feedService.SetItemStates(toSkip, FeedItemState.Skipped)) + { + var urls = toSkip.Select(i => i.Url).ToHashSet(); + items.RemoveAll(i => urls.Contains(i.Url)); + toastService.ShowSuccess($"Skipped {toSkip.Count} item(s)."); + } + else + { + toastService.ShowError("The items could not be marked as skipped. Please try again."); + } + } + + private void Select(Selection next) => selection = next; + + private void Toggle(string groupId) + { + if (!expandedGroups.Remove(groupId)) + { + expandedGroups.Add(groupId); + } + } + + private int CountFor(string? groupId, string? feedId) => + items.Count(i => (groupId is null || i.GroupId == groupId) && (feedId is null || i.FeedId == feedId)); + + private string? ErrorFor(string feedId) => statuses.TryGetValue(feedId, out var status) ? status.Error : null; + + /// Removes an item from the list after it was added or skipped. + private void RemoveItem(FeedItem item) => items.RemoveAll(i => i.Url == item.Url); + + private async Task OpenPreview(FeedItem item) + { + var dialog = await dialogService.ShowDialogAsync(item, new DialogParameters + { + Title = item.Title, + Width = "min(860px, 95vw)", + PreventScroll = true, + TrapFocus = true + }); + + var result = await dialog.Result; + if (!result.Cancelled && result.Data is string) + { + // Added or Skipped: the item leaves the list (inbox model). + RemoveItem(item); + StateHasChanged(); + } + } + + private async Task OnItemKey(KeyboardEventArgs e, FeedItem item) + { + if (e.Key is "Enter" or " ") + { + await OpenPreview(item); + } + } + + public static string Age(DateTimeOffset? published, DateTimeOffset? now = null) + { + if (published is null) + { + return "undated"; + } + var span = (now ?? DateTimeOffset.UtcNow) - published.Value; + return span.TotalMinutes < 1 ? "just now" + : span.TotalHours < 1 ? $"{(int)span.TotalMinutes}m" + : span.TotalDays < 1 ? $"{(int)span.TotalHours}h" + : $"{(int)span.TotalDays}d"; + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/ManageFeeds.razor b/src/NoteBookmark.SharedUI/Components/Pages/ManageFeeds.razor new file mode 100644 index 0000000..2607711 --- /dev/null +++ b/src/NoteBookmark.SharedUI/Components/Pages/ManageFeeds.razor @@ -0,0 +1,291 @@ +@page "/feeds/manage" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@inject IFeedService feedService +@inject IToastService toastService + +Manage feeds + + +

Manage feeds

+ + Back to feeds +
+ +@if (loading) +{ + +} +else +{ +
+

Add a feed

+ @if (groups.Count == 0) + { +

Create a group below first, or import an OPML file.

+ } + + + + Add feed + +
+ +
+

Groups

+ + + Add group + +
    + @foreach (var group in groups) + { +
  • + + + Rename + Delete + @FeedsIn(group.RowKey).Count() feed(s) + +
  • + } +
+
+ +
+

Feeds

+ @if (feeds.Count == 0) + { +

No feeds yet.

+ } + @foreach (var feed in feeds.OrderBy(f => GroupName(f.GroupId)).ThenBy(f => f.Title)) + { +
+
+ @feed.Title +
@feed.FeedUrl
+
+ + +
+ } +
+ +
+

Import OPML

+

Import a subscription list, for example a Feedly export. Folders become groups; feeds you already follow are skipped.

+ + @if (importing) + { + + } +
+} + + + +@code { + /// OPML files larger than this are rejected (Feedly exports are a few KB). + private const long MaxOpmlBytes = 5 * 1024 * 1024; + + private bool loading = true; + private bool addingFeed; + private bool importing; + private List groups = []; + private List feeds = []; + private Dictionary renames = []; + private string newFeedUrl = string.Empty; + private string newGroupName = string.Empty; + private FeedGroup? newFeedGroup; + + protected override async Task OnInitializedAsync() + { + await Reload(); + loading = false; + } + + private async Task Reload() + { + groups = await feedService.GetGroups(); + feeds = await feedService.GetFeeds(); + renames = groups.ToDictionary(g => g.RowKey, g => g.Name); + newFeedGroup = groups.FirstOrDefault(g => g.RowKey == newFeedGroup?.RowKey) ?? groups.FirstOrDefault(); + } + + private IEnumerable FeedsIn(string groupId) => feeds.Where(f => f.GroupId == groupId); + + private string GroupName(string groupId) => groups.FirstOrDefault(g => g.RowKey == groupId)?.Name ?? string.Empty; + + private async Task AddFeed() + { + if (string.IsNullOrWhiteSpace(newFeedUrl) || newFeedGroup is null) + { + return; + } + + addingFeed = true; + try + { + var discovered = await feedService.Discover(newFeedUrl.Trim()); + if (!discovered.Success || discovered.Value is null) + { + toastService.ShowError(discovered.Error ?? "No feed found at this address."); + return; + } + + var saved = await feedService.SaveFeed(new Feed + { + Title = discovered.Value.Title, + FeedUrl = discovered.Value.FeedUrl, + SiteUrl = discovered.Value.SiteUrl, + GroupId = newFeedGroup.RowKey + }); + if (!saved.Success || saved.Value is null) + { + toastService.ShowError(saved.Error ?? "The feed could not be saved."); + return; + } + + feeds.Add(saved.Value); + newFeedUrl = string.Empty; + toastService.ShowSuccess($"Added \"{saved.Value.Title}\" to {newFeedGroup.Name}."); + } + finally + { + addingFeed = false; + } + } + + private async Task AddGroup() + { + var result = await feedService.SaveGroup(new FeedGroup { Name = newGroupName.Trim() }); + if (!result.Success || result.Value is null) + { + toastService.ShowError(result.Error ?? "The group could not be created."); + return; + } + + groups.Add(result.Value); + groups = groups.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase).ToList(); + renames[result.Value.RowKey] = result.Value.Name; + newFeedGroup ??= result.Value; + newGroupName = string.Empty; + toastService.ShowSuccess($"Group \"{result.Value.Name}\" created."); + } + + private async Task RenameGroup(FeedGroup group) + { + var renamed = new FeedGroup { RowKey = group.RowKey, Name = renames[group.RowKey].Trim() }; + var result = await feedService.SaveGroup(renamed); + if (!result.Success) + { + toastService.ShowError(result.Error ?? "The group could not be renamed."); + renames[group.RowKey] = group.Name; + return; + } + + group.Name = renamed.Name; + toastService.ShowSuccess($"Group renamed to \"{group.Name}\"."); + } + + private async Task DeleteGroup(FeedGroup group) + { + var result = await feedService.DeleteGroup(group.RowKey); + if (!result.Success) + { + // The API refuses to delete a group that still has feeds. + toastService.ShowError(result.Error ?? "The group could not be deleted."); + return; + } + + groups.Remove(group); + renames.Remove(group.RowKey); + if (newFeedGroup?.RowKey == group.RowKey) + { + newFeedGroup = groups.FirstOrDefault(); + } + toastService.ShowSuccess($"Group \"{group.Name}\" deleted."); + } + + private async Task MoveFeed(Feed feed, FeedGroup? group) + { + if (group is null || group.RowKey == feed.GroupId) + { + return; + } + + var previous = feed.GroupId; + feed.GroupId = group.RowKey; + var result = await feedService.SaveFeed(feed); + if (!result.Success) + { + feed.GroupId = previous; + toastService.ShowError(result.Error ?? "The feed could not be moved."); + return; + } + toastService.ShowSuccess($"Moved \"{feed.Title}\" to {group.Name}."); + } + + private async Task DeleteFeed(Feed feed) + { + var result = await feedService.DeleteFeed(feed.RowKey); + if (!result.Success) + { + toastService.ShowError(result.Error ?? "The feed could not be deleted."); + return; + } + + feeds.Remove(feed); + toastService.ShowSuccess($"Removed \"{feed.Title}\"."); + } + + private async Task ImportOpml(InputFileChangeEventArgs e) + { + importing = true; + try + { + if (e.File.Size > MaxOpmlBytes) + { + toastService.ShowError("This file is too large to be an OPML subscription list."); + return; + } + + string content; + using (var reader = new StreamReader(e.File.OpenReadStream(MaxOpmlBytes))) + { + content = await reader.ReadToEndAsync(); + } + + var result = await feedService.ImportOpml(content); + if (!result.Success || result.Value is null) + { + toastService.ShowError(result.Error ?? "The file could not be imported."); + return; + } + + await Reload(); + var r = result.Value; + toastService.ShowSuccess($"Imported {r.FeedsCreated} feed(s) in {r.GroupsCreated} new group(s); {r.FeedsSkipped} already followed."); + } + finally + { + importing = false; + } + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Shared/FeedItemDialog.razor b/src/NoteBookmark.SharedUI/Components/Shared/FeedItemDialog.razor new file mode 100644 index 0000000..f610cb3 --- /dev/null +++ b/src/NoteBookmark.SharedUI/Components/Shared/FeedItemDialog.razor @@ -0,0 +1,131 @@ +@implements IDialogContentComponent +@inject IDataService dataService +@inject IFeedService feedService +@inject IToastService toastService +@inject IUrlLauncher urlLauncher + +
+
+ @Content.FeedTitle + @if (Content.Published is not null) + { + · @Content.Published.Value.ToLocalTime().ToString("MMMM d, yyyy") + } + @if (!string.IsNullOrEmpty(Content.Author)) + { + · @Content.Author + } +
+ + @if (ShowLeadImage) + { + + } + +
+ @if (string.IsNullOrWhiteSpace(Content.Body)) + { +

This feed doesn't include the article text. Use "Open original" to read it.

+ } + else + { + @* Body is sanitized by the API (allow-list of tags, http(s) URLs only, no scripts or handlers). *@ + @((MarkupString)Content.Body) + } +
+
+ + + Open original + + Skip + Close + Add + + + + +@code { + [Parameter] + public FeedItem Content { get; set; } = default!; + + [CascadingParameter] + public FluentDialog? Dialog { get; set; } + + /// Raised with , , or null for Close. + [Parameter] + public EventCallback OnClose { get; set; } + + private bool adding; + private bool skipping; + private bool busy => adding || skipping; + + /// Skip the lead image when the body already starts with it. + private bool ShowLeadImage => !string.IsNullOrEmpty(Content.LeadImageUrl) + && !Content.Body.Contains(Content.LeadImageUrl, StringComparison.Ordinal); + + private async Task Add() + { + adding = true; + try + { + // Same path as Search suggestions "+" and QuickAdd: saves an unread Post. + var post = await dataService.ExtractPostDetailsAndSave(Content.Url); + if (post is null) + { + toastService.ShowError("The article could not be added. Please try again."); + return; + } + + // The Post's URL already hides the item; the state also covers redirects to another URL. + await feedService.SetItemStates([Content], FeedItemState.Added); + toastService.ShowSuccess($"\"{post.Title ?? Content.Title}\" added to your posts."); + await Finish(FeedItemState.Added); + } + finally + { + adding = false; + } + } + + private async Task Skip() + { + skipping = true; + try + { + if (!await feedService.SetItemStates([Content], FeedItemState.Skipped)) + { + toastService.ShowError("The item could not be skipped. Please try again."); + return; + } + await Finish(FeedItemState.Skipped); + } + finally + { + skipping = false; + } + } + + private async Task Close() + { + await OnClose.InvokeAsync(null); + await (Dialog?.CancelAsync() ?? Task.CompletedTask); + } + + private Task OpenOriginal() => urlLauncher.OpenUrlAsync(Content.Url); + + private async Task Finish(string state) + { + await OnClose.InvokeAsync(state); + await (Dialog?.CloseAsync(state) ?? Task.CompletedTask); + } +} diff --git a/src/NoteBookmark.SharedUI/FeedClient.cs b/src/NoteBookmark.SharedUI/FeedClient.cs new file mode 100644 index 0000000..e9ccca5 --- /dev/null +++ b/src/NoteBookmark.SharedUI/FeedClient.cs @@ -0,0 +1,110 @@ +using System.Net.Http.Json; +using System.Text.Json; +using NoteBookmark.Domain; + +namespace NoteBookmark.SharedUI; + +public class FeedClient(HttpClient httpClient) : IFeedService +{ + public async Task> GetGroups() => + await httpClient.GetFromJsonAsync>("api/feeds/groups") ?? []; + + public Task> SaveGroup(FeedGroup group) => + Send(httpClient.PostAsJsonAsync("api/feeds/groups", group)); + + public Task> DeleteGroup(string groupId) => + SendNoContent(httpClient.DeleteAsync($"api/feeds/groups/{Uri.EscapeDataString(groupId)}")); + + public async Task> GetFeeds() => + await httpClient.GetFromJsonAsync>("api/feeds/") ?? []; + + public Task> SaveFeed(Feed feed) => + Send(httpClient.PostAsJsonAsync("api/feeds/", feed)); + + public Task> DeleteFeed(string feedId) => + SendNoContent(httpClient.DeleteAsync($"api/feeds/{Uri.EscapeDataString(feedId)}")); + + public Task> Discover(string url) => + Send(httpClient.GetAsync($"api/feeds/discover?url={Uri.EscapeDataString(url)}")); + + public Task> ImportOpml(string opml) => + Send(httpClient.PostAsJsonAsync("api/feeds/import-opml", new OpmlImportRequest { Opml = opml })); + + public async Task GetItems(string? groupId = null, string? feedId = null, bool refresh = false) + { + var query = new List(); + if (!string.IsNullOrEmpty(groupId)) query.Add("groupId=" + Uri.EscapeDataString(groupId)); + if (!string.IsNullOrEmpty(feedId)) query.Add("feedId=" + Uri.EscapeDataString(feedId)); + if (refresh) query.Add("refresh=true"); + var url = "api/feeds/items" + (query.Count > 0 ? "?" + string.Join("&", query) : string.Empty); + return await httpClient.GetFromJsonAsync(url) ?? new FeedItemsResponse(); + } + + public async Task SetItemStates(IEnumerable items, string state) + { + var states = items.Select(i => new FeedItemState { Url = i.Url, State = state, FeedId = i.FeedId }).ToList(); + if (states.Count == 0) + { + return true; + } + var response = await httpClient.PostAsJsonAsync("api/feeds/items/states", states); + return response.IsSuccessStatusCode; + } + + private static async Task> Send(Task request) + { + try + { + using var response = await request; + if (response.IsSuccessStatusCode) + { + return new FeedResult(await response.Content.ReadFromJsonAsync(), null); + } + return new FeedResult(default, await ReadError(response)); + } + catch (HttpRequestException ex) + { + return new FeedResult(default, ex.Message); + } + } + + private static async Task> SendNoContent(Task request) + { + try + { + using var response = await request; + return response.IsSuccessStatusCode + ? new FeedResult(true, null) + : new FeedResult(false, await ReadError(response)); + } + catch (HttpRequestException ex) + { + return new FeedResult(false, ex.Message); + } + } + + /// The API returns error messages as JSON strings; fall back to the status code. + private static async Task ReadError(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + if (!string.IsNullOrWhiteSpace(body)) + { + try + { + if (JsonSerializer.Deserialize(body) is { Length: > 0 } message) + { + return message; + } + } + catch (JsonException) + { + // Not a JSON string (e.g. a problem details object); use the status below. + } + } + return response.StatusCode switch + { + System.Net.HttpStatusCode.NotFound => "Not found.", + _ => $"The server returned {(int)response.StatusCode} {response.ReasonPhrase}." + }; + } +} diff --git a/src/NoteBookmark.SharedUI/IFeedService.cs b/src/NoteBookmark.SharedUI/IFeedService.cs new file mode 100644 index 0000000..183100e --- /dev/null +++ b/src/NoteBookmark.SharedUI/IFeedService.cs @@ -0,0 +1,29 @@ +using NoteBookmark.Domain; + +namespace NoteBookmark.SharedUI; + +/// Outcome of a feed API call: either a value or the API's error message. +public record FeedResult(T? Value, string? Error) +{ + public bool Success => Error is null; +} + +/// Client for the api/feeds endpoints used by the Feeds tab (web app only in v1). +public interface IFeedService +{ + Task> GetGroups(); + Task> SaveGroup(FeedGroup group); + Task> DeleteGroup(string groupId); + + Task> GetFeeds(); + Task> SaveFeed(Feed feed); + Task> DeleteFeed(string feedId); + + /// Finds the feed behind a feed URL or a site URL. + Task> Discover(string url); + Task> ImportOpml(string opml); + + /// Pending items; both ids null means all feeds. + Task GetItems(string? groupId = null, string? feedId = null, bool refresh = false); + Task SetItemStates(IEnumerable items, string state); +}