diff --git a/.gitattributes b/.gitattributes index eb91f7f..acc44ab 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,10 +1,13 @@ -# Squad: union merge for append-only team state files -.ai-team/decisions.md merge=union -.ai-team/agents/*/history.md merge=union -.ai-team/log/** merge=union -.ai-team/orchestration-log/** merge=union -# Squad: union merge for append-only team state files -.squad/decisions.md merge=union -.squad/agents/*/history.md merge=union -.squad/log/** merge=union -.squad/orchestration-log/** merge=union +# Squad: union merge for append-only team state files +.ai-team/decisions.md merge=union +.ai-team/agents/*/history.md merge=union +.ai-team/log/** merge=union +.ai-team/orchestration-log/** merge=union +# Squad: union merge for append-only team state files +.squad/decisions.md merge=union +.squad/agents/*/history.md merge=union +.squad/log/** merge=union +.squad/orchestration-log/** merge=union +# Squad: union merge for append-only team state files +.squad/rai/audit-trail.md merge=union +.squad/fact-checker/audit-trail.md merge=union diff --git a/Directory.Build.props b/Directory.Build.props index acc3861..c4bff0d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,9 +1,9 @@ - - - 1.2.2 - net10.0 - enable - enable - true - - + + + 1.3.0 + net10.0 + enable + enable + true + + diff --git a/skills-lock.json b/skills-lock.json index 8447975..e2c8cb2 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,11 +1,11 @@ -{ - "version": 1, - "skills": { - "grill-with-docs": { - "source": "mattpocock/skills", - "sourceType": "github", - "skillPath": "skills/engineering/grill-with-docs/SKILL.md", - "computedHash": "1adf321072f53cce3dcaf5357d91b8230d4aa647bb8a51756745337a6ee567b8" - } - } -} +{ + "version": 1, + "skills": { + "grill-with-docs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/grill-with-docs/SKILL.md", + "computedHash": "9c460cbd94fd3c63cdef967dbdb6e66ca687103cdc380cd37834e4d10b738f78" + } + } +} diff --git a/src/NoteBookmark.Api/PostEndpoints.cs b/src/NoteBookmark.Api/PostEndpoints.cs index bade95f..87c1bb2 100644 --- a/src/NoteBookmark.Api/PostEndpoints.cs +++ b/src/NoteBookmark.Api/PostEndpoints.cs @@ -21,6 +21,8 @@ public static void MapPostEndpoints(this IEndpointRouteBuilder app) .WithDescription("Get all read posts"); endpoints.MapGet("/{id}", Get) .WithDescription("Get a post by id"); + endpoints.MapGet("/{postId}/html", GetPostHtml) + .WithDescription("Get cleaned HTML for a post"); endpoints.MapPost("/", SavePost) .WithDescription("Save or Create a post"); endpoints.MapPost("/extractPostDetails", ExtractPostDetails) @@ -55,6 +57,18 @@ static List GetReadPosts(TableServiceClient tblClient, BlobServiceClient return posts; } + static async Task>> GetPostHtml( + string postId, BlobServiceClient blobClient) + { + var containerClient = blobClient.GetBlobContainerClient("cleanedposts"); + var blob = containerClient.GetBlobClient($"{postId}.html"); + if (!await blob.ExistsAsync()) + return TypedResults.NotFound(new ErrorMessage("Post HTML not found")); + var download = await blob.DownloadContentAsync(); + var html = download.Value.Content.ToString(); + return TypedResults.Content(html, "text/html"); + } + static Results, NotFound> Get(string id, TableServiceClient tblClient, BlobServiceClient blobClient) { var dataStorageService = new DataStorageService(tblClient, blobClient); @@ -186,3 +200,4 @@ static Results, NotFound, BadRequest> PatchPost(string id, Post post, T } public record ExtractPostRequest(string url, string? tags = null, string? category = null); +public record ErrorMessage(string error); diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs new file mode 100644 index 0000000..26244cc --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs @@ -0,0 +1,129 @@ +using Bunit; +using Bunit.TestDoubles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Moq; +using NoteBookmark.BlazorApp.Tests.Helpers; +using NoteBookmark.Domain; +using NoteBookmark.SharedUI; +using NoteBookmark.SharedUI.Components.Pages; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +/// +/// Tests for the Posts page in NoteBookmark.SharedUI. +/// Covers the show/hide published date toggle, title filter, and read/unread switching. +/// +public sealed class PostsTests : BunitContext +{ + private readonly Mock _dataServiceMock; + + private static List SamplePosts() => + [ + new PostL { PartitionKey = "p", RowKey = "1", Title = "First Post", Url = "https://example.com/1", Date_published = "2025-01-15T00:00:00", is_read = false }, + new PostL { PartitionKey = "p", RowKey = "2", Title = "Second Post", Url = "https://example.com/2", Date_published = "2025-06-20T00:00:00", is_read = false }, + ]; + + public PostsTests() + { + this.AddFluentUI(); + this.AddAuthorization().SetAuthorized("testuser"); + + _dataServiceMock = new Mock(); + _dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync(SamplePosts()); + _dataServiceMock.Setup(s => s.GetReadPosts()).ReturnsAsync([]); + _dataServiceMock.Setup(s => s.SyncAsync()).Returns(Task.CompletedTask); + _dataServiceMock.SetupGet(s => s.IsOffline).Returns(false); + _dataServiceMock.SetupGet(s => s.CanSync).Returns(false); + + Services.AddSingleton(_dataServiceMock.Object); + Services.AddSingleton(new Mock().Object); + Services.AddSingleton(new Mock().Object); + Services.AddSingleton(new NoteBookmark.BlazorApp.AlwaysAvailableHtmlCache()); + } + + [Fact] + public void Posts_RendersWithoutThrowing() + { + var cut = Render(); + + cut.Markup.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Posts_RendersPostTitles() + { + var cut = Render(); + + cut.Markup.Should().Contain("First Post"); + cut.Markup.Should().Contain("Second Post"); + } + + [Fact] + public void Posts_PublishedDateColumn_HiddenByDefault() + { + var cut = Render(); + + // The Published column header text should not appear as a grid header; + // "Show Published Date" (the checkbox label) still contains "Published" as a substring, + // so we look for the exact header cell pattern instead. + cut.Markup.Should().NotMatchRegex(@"col-title-text[^>]*>Published<"); + } + + [Fact] + public void Posts_PublishedDateColumn_VisibleAfterToggle() + { + var cut = Render(); + + // Find and click the "Show Published Date" checkbox + var checkbox = cut.Find("fluent-checkbox"); + checkbox.Click(); + + cut.Markup.Should().Contain("Published"); + } + + [Fact] + public void Posts_ShowPublishedDateCheckbox_IsRendered() + { + var cut = Render(); + + cut.Markup.Should().Contain("Show Published Date"); + } + + [Fact] + public void Posts_TitleFilter_RendersFilterButton() + { + var cut = Render(); + + // The Title column renders a filter button; the options panel (with the search input) + // only opens after the button is clicked, so we verify the button is present. + cut.Markup.Should().Contain("Filter this column"); + } + + [Fact] + public void Posts_LoadsUnreadPostsByDefault() + { + Render(); + + _dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce); + } + + [Fact] + public void Posts_RendersAddButton() + { + var cut = Render(); + + // The URL input and add button are present + cut.Markup.Should().Contain("Enter URL"); + } + + [Fact] + public void Posts_RendersEmptyState_WhenNoPostsReturned() + { + _dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync([]); + + var cut = Render(); + + cut.Markup.Should().Contain("Nothing to see here"); + } +} diff --git a/src/NoteBookmark.BlazorApp/AlwaysAvailableHtmlCache.cs b/src/NoteBookmark.BlazorApp/AlwaysAvailableHtmlCache.cs new file mode 100644 index 0000000..2bed86b --- /dev/null +++ b/src/NoteBookmark.BlazorApp/AlwaysAvailableHtmlCache.cs @@ -0,0 +1,8 @@ +using NoteBookmark.SharedUI; + +namespace NoteBookmark.BlazorApp; + +public class AlwaysAvailableHtmlCache : ILocalHtmlCache +{ + public bool IsHtmlCached(string postId) => true; +} diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index 2843f04..774d266 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -18,6 +18,7 @@ client.BaseAddress = new Uri("https+http://api"); }); builder.Services.AddTransient(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); // Register server-side AI settings provider (direct database access, unmasked) builder.Services.AddScoped(); diff --git a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj index 47e015f..d0eb9dc 100644 --- a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj +++ b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj @@ -34,6 +34,7 @@ + diff --git a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs index 04108eb..b4eb5e6 100644 --- a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs +++ b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs @@ -16,6 +16,7 @@ public class SyncServiceTests private readonly Mock _apiClientMock; private readonly Mock _localDataServiceMock; private readonly Mock> _loggerMock; + private readonly Mock _localHtmlStorageServiceMock; private readonly SyncService _sut; public SyncServiceTests() @@ -23,11 +24,14 @@ public SyncServiceTests() _apiClientMock = new Mock(); _localDataServiceMock = new Mock(); _loggerMock = new Mock>(); + _localHtmlStorageServiceMock = new Mock(); _apiClientMock.Setup(c => c.GetNote(It.IsAny())).ReturnsAsync((Note?)null); + _apiClientMock.Setup(c => c.GetPostHtmlAsync(It.IsAny())).ReturnsAsync((string?)null); _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List()); + _localHtmlStorageServiceMock.Setup(s => s.GetCachedPostIds()).Returns(new List()); - _sut = new SyncService(_apiClientMock.Object, _localDataServiceMock.Object, _loggerMock.Object); + _sut = new SyncService(_apiClientMock.Object, _localDataServiceMock.Object, _loggerMock.Object, _localHtmlStorageServiceMock.Object); SyncService.ClearInMemoryPreferences(); } diff --git a/src/NoteBookmark.MauiApp/Data/ILocalHtmlStorageService.cs b/src/NoteBookmark.MauiApp/Data/ILocalHtmlStorageService.cs new file mode 100644 index 0000000..184d1d9 --- /dev/null +++ b/src/NoteBookmark.MauiApp/Data/ILocalHtmlStorageService.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace NoteBookmark.MauiApp.Data; + +public interface ILocalHtmlStorageService +{ + Task SavePostHtmlAsync(string postId, string html); + Task GetPostHtmlAsync(string postId); + bool IsPostHtmlCached(string postId); + void RemovePostHtml(string postId); + IEnumerable GetCachedPostIds(); +} diff --git a/src/NoteBookmark.MauiApp/Data/ISyncApiClient.cs b/src/NoteBookmark.MauiApp/Data/ISyncApiClient.cs index 42f9ad2..c71807a 100644 --- a/src/NoteBookmark.MauiApp/Data/ISyncApiClient.cs +++ b/src/NoteBookmark.MauiApp/Data/ISyncApiClient.cs @@ -17,4 +17,5 @@ public interface ISyncApiClient Task UpdateNote(Note note); Task CreateNote(Note note); Task DeleteNote(string rowKey); + Task GetPostHtmlAsync(string postId); } diff --git a/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs b/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs new file mode 100644 index 0000000..30e7d53 --- /dev/null +++ b/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using NoteBookmark.SharedUI; + +namespace NoteBookmark.MauiApp.Data; + +public class LocalHtmlStorageService(string baseDirectory) : ILocalHtmlStorageService, ILocalHtmlCache +{ + private string FilePath(string postId) => Path.Combine(baseDirectory, $"{postId}.html"); + + public async Task SavePostHtmlAsync(string postId, string html) + => await File.WriteAllTextAsync(FilePath(postId), html); + + public async Task GetPostHtmlAsync(string postId) + { + var path = FilePath(postId); + if (!File.Exists(path)) return null; + return await File.ReadAllTextAsync(path); + } + + public bool IsPostHtmlCached(string postId) => File.Exists(FilePath(postId)); + + public bool IsHtmlCached(string postId) => IsPostHtmlCached(postId); + + public void RemovePostHtml(string postId) + { + var path = FilePath(postId); + if (File.Exists(path)) File.Delete(path); + } + + public IEnumerable GetCachedPostIds() + { + if (!Directory.Exists(baseDirectory)) return Enumerable.Empty(); + return Directory.GetFiles(baseDirectory, "*.html") + .Select(Path.GetFileNameWithoutExtension) + .Where(id => id != null) + .Cast(); + } +} diff --git a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs index 66e6845..115efe8 100644 --- a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs @@ -8,7 +8,7 @@ namespace NoteBookmark.MauiApp.Data; -public class OfflineDataService(PostNoteClient apiClient, ILocalDataService localDataService, IConnectivity connectivity, ISyncService syncService) : IDataService +public class OfflineDataService(PostNoteClient apiClient, ILocalDataService localDataService, IConnectivity connectivity, ISyncService syncService, ILocalHtmlStorageService localHtmlStorageService) : IDataService { private bool IsOnline => connectivity.NetworkAccess == NetworkAccess.Internet; @@ -275,6 +275,9 @@ public async Task DeletePost(string id) public Task SaveReadingNotesMarkdown(string markdown, string number) => apiClient.SaveReadingNotesMarkdown(markdown, number); + public Task GetPostHtmlAsync(string postId) + => localHtmlStorageService.GetPostHtmlAsync(postId); + public Task SyncAsync() => syncService.SyncAsync(); public bool IsOffline => connectivity.NetworkAccess != NetworkAccess.Internet; public bool CanSync => true; diff --git a/src/NoteBookmark.MauiApp/Data/SyncApiClient.cs b/src/NoteBookmark.MauiApp/Data/SyncApiClient.cs index 9c3097a..61a76ef 100644 --- a/src/NoteBookmark.MauiApp/Data/SyncApiClient.cs +++ b/src/NoteBookmark.MauiApp/Data/SyncApiClient.cs @@ -22,4 +22,5 @@ public async Task CreateNote(Note note) return true; } public Task DeleteNote(string rowKey) => client.DeleteNote(rowKey); + public Task GetPostHtmlAsync(string postId) => client.GetPostHtmlAsync(postId); } diff --git a/src/NoteBookmark.MauiApp/Data/SyncService.cs b/src/NoteBookmark.MauiApp/Data/SyncService.cs index 638d12a..7f5821c 100644 --- a/src/NoteBookmark.MauiApp/Data/SyncService.cs +++ b/src/NoteBookmark.MauiApp/Data/SyncService.cs @@ -22,7 +22,8 @@ public interface ISyncService public class SyncService( ISyncApiClient apiClient, ILocalDataService localDataService, - ILogger logger) : ISyncService + ILogger logger, + ILocalHtmlStorageService localHtmlStorageService) : ISyncService { private const string LastSyncTimestampKey = "LastSyncTimestamp"; private bool _isSyncing; @@ -46,6 +47,7 @@ public async Task SyncAsync() await PushAsync(lastSync); await PullAsync(lastSync); + await SyncHtmlAsync(); await SetPreferenceAsync(LastSyncTimestampKey, DateTime.UtcNow.ToString("O")); } @@ -227,6 +229,42 @@ private async Task PullAsync(DateTime? lastSync) } } + private async Task SyncHtmlAsync() + { + var posts = await localDataService.GetPostsAsync(); + var postMap = posts.ToDictionary(p => p.Id ?? p.RowKey); + var cachedIds = localHtmlStorageService.GetCachedPostIds().ToHashSet(); + + // Prune cached HTML for posts that are read or no longer exist + foreach (var cachedId in cachedIds) + { + if (!postMap.TryGetValue(cachedId, out var post) || post.is_read == true) + { + localHtmlStorageService.RemovePostHtml(cachedId); + } + } + + // Download HTML for unread posts not yet cached + foreach (var post in posts.Where(p => p.is_read != true)) + { + var id = post.Id ?? post.RowKey; + if (localHtmlStorageService.IsPostHtmlCached(id)) continue; + + try + { + var html = await apiClient.GetPostHtmlAsync(id); + if (html != null) + { + await localHtmlStorageService.SavePostHtmlAsync(id, html); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to download HTML for post {PostId}", id); + } + } + } + #if NOT_MAUI private static readonly System.Collections.Concurrent.ConcurrentDictionary _inMemoryPreferences = new(); diff --git a/src/NoteBookmark.MauiApp/MauiProgram.cs b/src/NoteBookmark.MauiApp/MauiProgram.cs index 8a6bbb9..486f68c 100644 --- a/src/NoteBookmark.MauiApp/MauiProgram.cs +++ b/src/NoteBookmark.MauiApp/MauiProgram.cs @@ -34,6 +34,12 @@ public static MauiHostingApp CreateMauiApp() // Data Layer builder.Services.AddSingleton(); + builder.Services.AddSingleton( + sp => new NoteBookmark.MauiApp.Data.LocalHtmlStorageService(FileSystem.AppDataDirectory)); + builder.Services.AddSingleton( + sp => sp.GetRequiredService()); + builder.Services.AddSingleton( + sp => sp.GetRequiredService()); builder.Services.AddTransient(); builder.Services.AddHttpClient(client => diff --git a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj index 61dc569..2243907 100644 --- a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj +++ b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj @@ -44,9 +44,9 @@ c5m.notebookmark.mauiapp - 1.2.2 - 2 - 1.2.2 + 1.3.0 + 3 + 1.3.0 None diff --git a/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor new file mode 100644 index 0000000..9c086e5 --- /dev/null +++ b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor @@ -0,0 +1,71 @@ +@page "/postreader/{PostId}" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@using NoteBookmark.Domain +@using Microsoft.FluentUI.AspNetCore.Components +@inject IDataService DataService +@inject NavigationManager Navigation + +@(post?.Title ?? "Reading...") + + + + Back + + + @if (isLoading) + { +

Loading...

+ } + else + { + @if (post != null) + { +

@post.Title

+ + @if (!string.IsNullOrEmpty(post.Author)) + { + @post.Author + } + @if (!string.IsNullOrEmpty(post.Date_published) && post.Date_published.Length >= 10) + { + ยท @post.Date_published[..10] + } + + } + + @if (htmlContent != null) + { +
+ @((MarkupString)htmlContent) +
+ } + else + { +

Content not available.

+ } + } +
+ +@code { + [Parameter] + public string PostId { get; set; } = string.Empty; + + private Post? post; + private string? htmlContent; + private bool isLoading = true; + + protected override async Task OnInitializedAsync() + { + var postTask = DataService.GetPost(PostId); + var htmlTask = DataService.GetPostHtmlAsync(PostId); + + await Task.WhenAll(postTask, htmlTask); + + post = await postTask; + htmlContent = await htmlTask; + isLoading = false; + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index 18e7eca..697b5b1 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -8,6 +8,7 @@ @inject IToastService toastService @inject IDialogService DialogService @inject NavigationManager Navigation +@inject ILocalHtmlCache localHtmlCache Posts @@ -30,7 +31,7 @@ - + @if (String.IsNullOrEmpty(context!.NoteId)) @@ -41,6 +42,10 @@ { } + @if (localHtmlCache.IsHtmlCached(context!.RowKey)) + { + + } @@ -277,4 +282,6 @@ { showPublishedDate = !showPublishedDate; } + + private void ReadPost(string postId) => Navigation.NavigateTo($"postreader/{postId}"); } diff --git a/src/NoteBookmark.SharedUI/IDataService.cs b/src/NoteBookmark.SharedUI/IDataService.cs index 98b5598..13b7ad3 100644 --- a/src/NoteBookmark.SharedUI/IDataService.cs +++ b/src/NoteBookmark.SharedUI/IDataService.cs @@ -23,6 +23,7 @@ public interface IDataService Task ExtractPostDetailsAndSave(string url); Task DeletePost(string id); Task SaveReadingNotesMarkdown(string markdown, string number); + Task GetPostHtmlAsync(string postId); Task SyncAsync(); bool IsOffline { get; } bool CanSync { get; } diff --git a/src/NoteBookmark.SharedUI/ILocalHtmlCache.cs b/src/NoteBookmark.SharedUI/ILocalHtmlCache.cs new file mode 100644 index 0000000..800ef45 --- /dev/null +++ b/src/NoteBookmark.SharedUI/ILocalHtmlCache.cs @@ -0,0 +1,6 @@ +namespace NoteBookmark.SharedUI; + +public interface ILocalHtmlCache +{ + bool IsHtmlCached(string postId); +} diff --git a/src/NoteBookmark.SharedUI/PostNoteClient.cs b/src/NoteBookmark.SharedUI/PostNoteClient.cs index c680db0..c1721c6 100644 --- a/src/NoteBookmark.SharedUI/PostNoteClient.cs +++ b/src/NoteBookmark.SharedUI/PostNoteClient.cs @@ -178,6 +178,14 @@ public async Task SaveReadingNotesMarkdown(string markdown, string number) return response.IsSuccessStatusCode; } + public async Task GetPostHtmlAsync(string postId) + { + var response = await httpClient.GetAsync($"api/posts/{postId}/html"); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + public async Task> GetPostsModifiedAfter(DateTime modifiedAfter) { var encoded = System.Web.HttpUtility.UrlEncode(modifiedAfter.ToUniversalTime().ToString("O"));