From 84fecdef5fc29f53ead8b7f309bbeb7dfc875aad Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 24 Oct 2026 09:37:53 -0400 Subject: [PATCH 1/2] Add offline news feed to home page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/news.md | 3 + .../NewsFeedServiceTests.cs | 118 +++++++++ .../NoteBookmark.MauiApp.Tests.csproj | 2 + .../SyncServiceTests.cs | 18 +- .../Components/Pages/Home.razor | 20 +- .../Data/INewsFeedService.cs | 9 + .../Data/NewsFeedService.cs | 229 ++++++++++++++++++ src/NoteBookmark.MauiApp/Data/SyncService.cs | 16 +- src/NoteBookmark.MauiApp/MauiProgram.cs | 1 + .../NoteBookmark.MauiApp.csproj | 1 + 10 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 docs/news.md create mode 100644 src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs create mode 100644 src/NoteBookmark.MauiApp/Data/INewsFeedService.cs create mode 100644 src/NoteBookmark.MauiApp/Data/NewsFeedService.cs diff --git a/docs/news.md b/docs/news.md new file mode 100644 index 0000000..b9e55b0 --- /dev/null +++ b/docs/news.md @@ -0,0 +1,3 @@ +# News & Updates + +Welcome to NoteBookmark! Check back here for product updates. diff --git a/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs new file mode 100644 index 0000000..7c5cd12 --- /dev/null +++ b/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs @@ -0,0 +1,118 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NoteBookmark.MauiApp.Data; + +namespace NoteBookmark.MauiApp.Tests; + +public sealed class NewsFeedServiceTests : IDisposable +{ + private readonly string _appDataDirectory = Path.Combine(Path.GetTempPath(), $"NoteBookmark-News-{Guid.NewGuid():N}"); + + [Fact] + public async Task RefreshAsync_CachesMarkdownAndReferencedImagesForOfflineRendering() + { + var handler = new StubHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/docs/news.md", StringComparison.Ordinal)) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("# Updates\n\n![Logo](assets/logo.png)\n\n[Unsafe](javascript:alert(1))", Encoding.UTF8, "text/plain") + }; + } + + if (request.RequestUri.AbsolutePath.EndsWith("/docs/assets/logo.png", StringComparison.Ordinal)) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(new byte[] { 1, 2, 3 }) + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + var service = CreateService(handler); + + await service.RefreshAsync(); + var html = await service.GetHtmlAsync(); + + html.Should().Contain("

Updates

"); + html.Should().Contain("src=\"data:image/png;base64,AQID\""); + html.Should().Contain("href=\"#\""); + File.Exists(Path.Combine(_appDataDirectory, "docs", "news.md")).Should().BeTrue(); + File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "logo.png")).Should().BeTrue(); + } + + [Fact] + public async Task GetHtmlAsync_WhenNoFeedIsCached_LoadsRemoteFeed() + { + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("# Online feed", Encoding.UTF8, "text/plain") + }); + var service = CreateService(handler); + + (await service.GetHtmlAsync()).Should().Contain("

Online feed

"); + File.Exists(Path.Combine(_appDataDirectory, "docs", "news.md")).Should().BeTrue(); + } + + [Fact] + public async Task RefreshAsync_WhenRemoteRefreshFails_KeepsCachedFeed() + { + var requestCount = 0; + var handler = new StubHttpMessageHandler(_ => + { + requestCount++; + if (requestCount == 1) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("# Existing feed", Encoding.UTF8, "text/plain") + }; + } + + throw new HttpRequestException("Network unavailable."); + }); + var service = CreateService(handler); + await service.RefreshAsync(); + + var act = () => service.RefreshAsync(); + await act.Should().ThrowAsync(); + (await service.GetHtmlAsync()).Should().Contain("

Existing feed

"); + } + + [Fact] + public async Task GetHtmlAsync_WhenNoFeedIsCached_ReturnsFriendlyFallback() + { + var service = CreateService(new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))); + + (await service.GetHtmlAsync()).Should().Contain("Welcome to NoteBookmark"); + } + + public void Dispose() + { + if (Directory.Exists(_appDataDirectory)) + { + Directory.Delete(_appDataDirectory, recursive: true); + } + } + + private NewsFeedService CreateService(HttpMessageHandler handler) + { + var logger = new Mock>(); + return new NewsFeedService(new HttpClient(handler), logger.Object, _appDataDirectory); + } + + private sealed class StubHttpMessageHandler(Func responseFactory) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(responseFactory(request)); + } +} diff --git a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj index 5477005..3ccff0a 100644 --- a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj +++ b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj @@ -38,6 +38,8 @@ + + diff --git a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs index b1fe4f2..08f74aa 100644 --- a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs +++ b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs @@ -19,6 +19,7 @@ public class SyncServiceTests private readonly Mock _localDataServiceMock; private readonly Mock> _loggerMock; private readonly Mock _localHtmlStorageServiceMock; + private readonly Mock _newsFeedServiceMock; private readonly SyncService _sut; public SyncServiceTests() @@ -27,13 +28,15 @@ public SyncServiceTests() _localDataServiceMock = new Mock(); _loggerMock = new Mock>(); _localHtmlStorageServiceMock = new Mock(); + _newsFeedServiceMock = 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()); + _newsFeedServiceMock.Setup(s => s.RefreshAsync()).Returns(Task.CompletedTask); - _sut = new SyncService(_apiClientMock.Object, _localDataServiceMock.Object, _loggerMock.Object, _localHtmlStorageServiceMock.Object); + _sut = new SyncService(_apiClientMock.Object, _localDataServiceMock.Object, _loggerMock.Object, _localHtmlStorageServiceMock.Object, _newsFeedServiceMock.Object); SyncService.ClearInMemoryPreferences(); } @@ -249,6 +252,19 @@ public async Task SyncAsync_ShouldNotRunConcurrently() _localDataServiceMock.Verify(c => c.GetPendingSyncNotesAsync(), Times.Once); } + [Fact] + public async Task SyncAsync_ShouldRefreshNewsFeed() + { + _localDataServiceMock.Setup(c => c.GetPendingSyncPostsAsync()).ReturnsAsync(new List()); + _localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + + await _sut.SyncAsync(); + + _newsFeedServiceMock.Verify(s => s.RefreshAsync(), Times.Once); + } + [Fact] public async Task PullPhase_ShouldDeleteLocalPosts_WhenDeletedOnServer() { diff --git a/src/NoteBookmark.MauiApp/Components/Pages/Home.razor b/src/NoteBookmark.MauiApp/Components/Pages/Home.razor index bbcc34d..bec8a4d 100644 --- a/src/NoteBookmark.MauiApp/Components/Pages/Home.razor +++ b/src/NoteBookmark.MauiApp/Components/Pages/Home.razor @@ -1,19 +1,38 @@ @page "/" @inject IAuthService AuthService @inject NavigationManager Nav +@inject NoteBookmark.MauiApp.Data.INewsFeedService NewsFeed @implements IDisposable

Hello, @(AuthService.Username ?? "there")!

You are signed in to NoteBookmark.

+ +
+

News & Updates

+
@((MarkupString)newsHtml)
+
@code { + private string newsHtml = "

Welcome to NoteBookmark! Check back here for product updates.

"; + protected override void OnInitialized() { // No-op } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + newsHtml = await NewsFeed.GetHtmlAsync(); + await InvokeAsync(StateHasChanged); + } + private async Task LogoutAsync() { await AuthService.LogoutAsync(); @@ -24,4 +43,3 @@ // Cleanup resources if needed } } - diff --git a/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs b/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs new file mode 100644 index 0000000..a52de72 --- /dev/null +++ b/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace NoteBookmark.MauiApp.Data; + +public interface INewsFeedService +{ + Task RefreshAsync(); + Task GetHtmlAsync(); +} diff --git a/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs b/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs new file mode 100644 index 0000000..9bd9056 --- /dev/null +++ b/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs @@ -0,0 +1,229 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Markdig; +using Microsoft.Extensions.Logging; +#if !NOT_MAUI +using Microsoft.Maui.Storage; +#endif + +namespace NoteBookmark.MauiApp.Data; + +public sealed class NewsFeedService : INewsFeedService +{ + private const string NewsFeedUrl = "https://raw.githubusercontent.com/fboucher/NoteBookmark/v-next/docs/news.md"; + private const string NewsAssetsUrl = "https://raw.githubusercontent.com/fboucher/NoteBookmark/v-next/docs/assets/"; + private const string FallbackHtml = "

Welcome to NoteBookmark! Check back here for product updates.

"; + private static readonly Regex ImagePattern = new( + @"!\[(?[^\]]*)\]\((?[^)\s]+)(?:\s+""[^""]*"")?\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex HtmlUrlAttributePattern = new( + @"(?href|src)=""(?[^""]*)""", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() + .DisableHtml() + .Build(); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _documentsDirectory; + private readonly string _assetsDirectory; + private readonly string _newsFilePath; + private readonly System.Threading.SemaphoreSlim _refreshLock = new(1, 1); + + public NewsFeedService(HttpClient httpClient, ILogger logger) + : this(httpClient, logger, GetAppDataDirectory()) + { + } + + public NewsFeedService(HttpClient httpClient, ILogger logger, string appDataDirectory) + { + _httpClient = httpClient; + _logger = logger; + _documentsDirectory = Path.Combine(appDataDirectory, "docs"); + _assetsDirectory = Path.Combine(_documentsDirectory, "assets"); + _newsFilePath = Path.Combine(_documentsDirectory, "news.md"); + } + + public async Task RefreshAsync() + { + await _refreshLock.WaitAsync(); + try + { + var markdown = await _httpClient.GetStringAsync(NewsFeedUrl); + var images = ImagePattern.Matches(markdown) + .Select(match => match.Groups["path"].Value) + .Select(TryGetAssetRelativePath) + .Where(path => path is not null) + .Cast() + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var downloadedAssets = new System.Collections.Generic.List<(string Path, byte[] Content)>(); + + foreach (var relativePath in images) + { + var url = NewsAssetsUrl + string.Join( + "/", + relativePath.Split('/').Select(Uri.EscapeDataString)); + var content = await _httpClient.GetByteArrayAsync(url); + downloadedAssets.Add((GetAssetFilePath(relativePath), content)); + } + + foreach (var (path, content) in downloadedAssets) + { + await WriteAtomicallyAsync(path, content); + } + + await WriteAtomicallyAsync(_newsFilePath, System.Text.Encoding.UTF8.GetBytes(markdown)); + } + finally + { + _refreshLock.Release(); + } + } + + public async Task GetHtmlAsync() + { + if (!File.Exists(_newsFilePath)) + { + try + { + await RefreshAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to download the news feed; showing the welcome message."); + return FallbackHtml; + } + + if (!File.Exists(_newsFilePath)) + { + return FallbackHtml; + } + } + + try + { + var markdown = await File.ReadAllTextAsync(_newsFilePath); + markdown = ImagePattern.Replace(markdown, match => + { + var relativePath = TryGetAssetRelativePath(match.Groups["path"].Value); + if (relativePath is null) + { + return match.Value; + } + + var filePath = GetAssetFilePath(relativePath); + if (!File.Exists(filePath)) + { + return match.Value; + } + + var imageBytes = File.ReadAllBytes(filePath); + var dataUri = $"data:{GetImageMimeType(filePath)};base64,{Convert.ToBase64String(imageBytes)}"; + return $"![{match.Groups["alt"].Value}]({dataUri})"; + }); + + var html = Markdig.Markdown.ToHtml(markdown, MarkdownPipeline); + return SanitizeGeneratedHtml(html); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Unable to read the cached news feed."); + return FallbackHtml; + } + } + + private static string SanitizeGeneratedHtml(string html) => + HtmlUrlAttributePattern.Replace(html, match => + { + var attribute = match.Groups["attribute"].Value; + var value = WebUtility.HtmlDecode(match.Groups["value"].Value); + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) + { + return match.Value; + } + + var isAllowed = uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || (attribute.Equals("href", StringComparison.OrdinalIgnoreCase) + && uri.Scheme.Equals("mailto", StringComparison.OrdinalIgnoreCase)) + || (attribute.Equals("src", StringComparison.OrdinalIgnoreCase) + && uri.Scheme.Equals("data", StringComparison.OrdinalIgnoreCase) + && value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase)); + + return isAllowed ? match.Value : $"{attribute}=\"#\""; + }); + + private string GetAssetFilePath(string relativePath) + { + var path = Path.GetFullPath(Path.Combine(_assetsDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar))); + var assetsDirectory = Path.GetFullPath(_assetsDirectory) + Path.DirectorySeparatorChar; + if (!path.StartsWith(assetsDirectory, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("News feed image path must remain under docs/assets."); + } + + return path; + } + + private static string? TryGetAssetRelativePath(string imagePath) + { + if (Uri.TryCreate(imagePath, UriKind.Absolute, out _)) + { + return null; + } + + var path = Uri.UnescapeDataString(imagePath).Replace('\\', '/'); + if (path.StartsWith("./", StringComparison.Ordinal)) + { + path = path[2..]; + } + + if (!path.StartsWith("assets/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var relativePath = path["assets/".Length..]; + var segments = relativePath.Split('/'); + if (segments.Length == 0 || segments.Any(segment => string.IsNullOrWhiteSpace(segment) || segment is "." or "..")) + { + return null; + } + + return relativePath; + } + + private static string GetImageMimeType(string path) => + Path.GetExtension(path).ToLowerInvariant() switch + { + ".gif" => "image/gif", + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".svg" => "image/svg+xml", + ".webp" => "image/webp", + _ => "application/octet-stream" + }; + + private static async Task WriteAtomicallyAsync(string path, byte[] content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporaryPath = path + ".tmp"; + await File.WriteAllBytesAsync(temporaryPath, content); + File.Move(temporaryPath, path, overwrite: true); + } + + private static string GetAppDataDirectory() + { +#if !NOT_MAUI + return FileSystem.AppDataDirectory; +#else + return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); +#endif + } +} diff --git a/src/NoteBookmark.MauiApp/Data/SyncService.cs b/src/NoteBookmark.MauiApp/Data/SyncService.cs index ba0a877..bb6662a 100644 --- a/src/NoteBookmark.MauiApp/Data/SyncService.cs +++ b/src/NoteBookmark.MauiApp/Data/SyncService.cs @@ -25,7 +25,8 @@ public class SyncService( ISyncApiClient apiClient, ILocalDataService localDataService, ILogger logger, - ILocalHtmlStorageService localHtmlStorageService) : ISyncService + ILocalHtmlStorageService localHtmlStorageService, + INewsFeedService newsFeedService) : ISyncService { private const string LastSyncTimestampKey = "LastSyncTimestamp"; private const int MaxConcurrentRequests = 5; @@ -81,6 +82,7 @@ private async Task DoSyncAsync() await PullAsync(lastSync); await SyncHtmlAsync(); + await RefreshNewsFeedAsync(); await SetPreferenceAsync(LastSyncTimestampKey, DateTime.UtcNow.ToString("O")); SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Synchronization complete!", isComplete: true)); @@ -411,6 +413,18 @@ private async Task SyncHtmlAsync() } } + private async Task RefreshNewsFeedAsync() + { + try + { + await newsFeedService.RefreshAsync(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unable to refresh the news feed; keeping the cached copy."); + } + } + #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 6988992..bcbb8ba 100644 --- a/src/NoteBookmark.MauiApp/MauiProgram.cs +++ b/src/NoteBookmark.MauiApp/MauiProgram.cs @@ -39,6 +39,7 @@ public static MauiHostingApp CreateMauiApp() sp => new NoteBookmark.MauiApp.Data.LocalHtmlStorageService(FileSystem.AppDataDirectory)); builder.Services.AddSingleton( sp => sp.GetRequiredService()); + builder.Services.AddHttpClient(); builder.Services.AddTransient(); builder.Services.AddHttpClient(client => diff --git a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj index 53cfa87..2a6125c 100644 --- a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj +++ b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj @@ -90,6 +90,7 @@ + From 499b02165eb6826320483996d3932b58b34276ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:43:28 +0000 Subject: [PATCH 2/2] Address review feedback on the news feed - Register NewsFeedService once as a singleton through an explicit factory, so DI no longer has to pick between two constructors and sync and the home page share a single refresh lock and cache. - Discover and rewrite images from Markdig's parse tree instead of a regex, which adds reference-style images and paths with parentheses and ignores image syntax inside code blocks. - Raise FeedUpdated after a successful refresh and reload the home page feed when it fires, so content downloaded by sync shows up without navigating away. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HjU7ggv2Jnx9sqVXACepgC --- .../NewsFeedServiceTests.cs | 67 +++++++++++++++++++ .../Components/Pages/Home.razor | 15 +++-- .../Data/INewsFeedService.cs | 4 ++ .../Data/NewsFeedService.cs | 61 +++++++---------- src/NoteBookmark.MauiApp/MauiProgram.cs | 8 ++- 5 files changed, 113 insertions(+), 42 deletions(-) diff --git a/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs index 7c5cd12..d204146 100644 --- a/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs +++ b/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs @@ -96,6 +96,56 @@ public async Task GetHtmlAsync_WhenNoFeedIsCached_ReturnsFriendlyFallback() (await service.GetHtmlAsync()).Should().Contain("Welcome to NoteBookmark"); } + [Fact] + public async Task RefreshAsync_CachesReferenceStyleImagesAndPathsWithParentheses() + { + var service = CreateService(CreateFeedHandler( + "# Updates\n\n![Logo][logo]\n\n![Chart](assets/chart(1).png)\n\n[logo]: assets/logo.png", + "/docs/assets/logo.png", + "/docs/assets/chart%281%29.png")); + + await service.RefreshAsync(); + var html = await service.GetHtmlAsync(); + + html.Should().NotContain("assets/"); + html.Should().Contain("src=\"data:image/png;base64,AQID\" alt=\"Logo\""); + html.Should().Contain("src=\"data:image/png;base64,AQID\" alt=\"Chart\""); + File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "logo.png")).Should().BeTrue(); + File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "chart(1).png")).Should().BeTrue(); + } + + [Fact] + public async Task RefreshAsync_IgnoresImageSyntaxInsideCodeBlocks() + { + var service = CreateService(CreateFeedHandler( + "# Updates\n\n```markdown\n![Example](assets/missing.png)\n```\n\n`![Inline](assets/also-missing.png)`")); + + await service.RefreshAsync(); + var html = await service.GetHtmlAsync(); + + html.Should().Contain("![Example](assets/missing.png)"); + Directory.Exists(Path.Combine(_appDataDirectory, "docs", "assets")).Should().BeFalse(); + } + + [Fact] + public async Task RefreshAsync_RaisesFeedUpdatedOnlyAfterSuccessfulRefresh() + { + var fail = false; + var handler = new StubHttpMessageHandler(_ => fail + ? throw new HttpRequestException("Network unavailable.") + : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("# Feed", Encoding.UTF8, "text/plain") }); + var service = CreateService(handler); + var updates = 0; + service.FeedUpdated += (_, _) => updates++; + + await service.RefreshAsync(); + fail = true; + var act = () => service.RefreshAsync(); + + await act.Should().ThrowAsync(); + updates.Should().Be(1); + } + public void Dispose() { if (Directory.Exists(_appDataDirectory)) @@ -110,6 +160,23 @@ private NewsFeedService CreateService(HttpMessageHandler handler) return new NewsFeedService(new HttpClient(handler), logger.Object, _appDataDirectory); } + private static StubHttpMessageHandler CreateFeedHandler(string markdown, params string[] assetPaths) => + new(request => + { + var path = request.RequestUri!.AbsolutePath; + if (path.EndsWith("/docs/news.md", StringComparison.Ordinal)) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(markdown, Encoding.UTF8, "text/plain") + }; + } + + return Array.Exists(assetPaths, assetPath => path.EndsWith(assetPath, StringComparison.Ordinal)) + ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[] { 1, 2, 3 }) } + : new HttpResponseMessage(HttpStatusCode.NotFound); + }); + private sealed class StubHttpMessageHandler(Func responseFactory) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => diff --git a/src/NoteBookmark.MauiApp/Components/Pages/Home.razor b/src/NoteBookmark.MauiApp/Components/Pages/Home.razor index bec8a4d..4e7b3e0 100644 --- a/src/NoteBookmark.MauiApp/Components/Pages/Home.razor +++ b/src/NoteBookmark.MauiApp/Components/Pages/Home.razor @@ -19,7 +19,7 @@ protected override void OnInitialized() { - // No-op + NewsFeed.FeedUpdated += OnFeedUpdated; } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -29,17 +29,20 @@ return; } - newsHtml = await NewsFeed.GetHtmlAsync(); - await InvokeAsync(StateHasChanged); + await LoadNewsAsync(); } - private async Task LogoutAsync() + // Sync refreshes the feed on a background thread, so marshal the reload back to the renderer. + private void OnFeedUpdated(object? sender, EventArgs e) => _ = InvokeAsync(LoadNewsAsync); + + private async Task LoadNewsAsync() { - await AuthService.LogoutAsync(); + newsHtml = await NewsFeed.GetHtmlAsync(); + StateHasChanged(); } void IDisposable.Dispose() { - // Cleanup resources if needed + NewsFeed.FeedUpdated -= OnFeedUpdated; } } diff --git a/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs b/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs index a52de72..26257c5 100644 --- a/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs +++ b/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs @@ -1,9 +1,13 @@ +using System; using System.Threading.Tasks; namespace NoteBookmark.MauiApp.Data; public interface INewsFeedService { + /// Raised after a refresh has downloaded and cached a new copy of the feed. + event EventHandler? FeedUpdated; + Task RefreshAsync(); Task GetHtmlAsync(); } diff --git a/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs b/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs index 9bd9056..99a1c73 100644 --- a/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs +++ b/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; @@ -6,10 +7,9 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Markdig; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; using Microsoft.Extensions.Logging; -#if !NOT_MAUI -using Microsoft.Maui.Storage; -#endif namespace NoteBookmark.MauiApp.Data; @@ -18,9 +18,6 @@ public sealed class NewsFeedService : INewsFeedService private const string NewsFeedUrl = "https://raw.githubusercontent.com/fboucher/NoteBookmark/v-next/docs/news.md"; private const string NewsAssetsUrl = "https://raw.githubusercontent.com/fboucher/NoteBookmark/v-next/docs/assets/"; private const string FallbackHtml = "

Welcome to NoteBookmark! Check back here for product updates.

"; - private static readonly Regex ImagePattern = new( - @"!\[(?[^\]]*)\]\((?[^)\s]+)(?:\s+""[^""]*"")?\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex HtmlUrlAttributePattern = new( @"(?href|src)=""(?[^""]*)""", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); @@ -35,11 +32,6 @@ public sealed class NewsFeedService : INewsFeedService private readonly string _newsFilePath; private readonly System.Threading.SemaphoreSlim _refreshLock = new(1, 1); - public NewsFeedService(HttpClient httpClient, ILogger logger) - : this(httpClient, logger, GetAppDataDirectory()) - { - } - public NewsFeedService(HttpClient httpClient, ILogger logger, string appDataDirectory) { _httpClient = httpClient; @@ -49,20 +41,21 @@ public NewsFeedService(HttpClient httpClient, ILogger logger, s _newsFilePath = Path.Combine(_documentsDirectory, "news.md"); } + public event EventHandler? FeedUpdated; + public async Task RefreshAsync() { await _refreshLock.WaitAsync(); try { var markdown = await _httpClient.GetStringAsync(NewsFeedUrl); - var images = ImagePattern.Matches(markdown) - .Select(match => match.Groups["path"].Value) - .Select(TryGetAssetRelativePath) + var images = GetImages(Markdown.Parse(markdown, MarkdownPipeline)) + .Select(image => TryGetAssetRelativePath(image.Url)) .Where(path => path is not null) .Cast() .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); - var downloadedAssets = new System.Collections.Generic.List<(string Path, byte[] Content)>(); + var downloadedAssets = new List<(string Path, byte[] Content)>(); foreach (var relativePath in images) { @@ -84,6 +77,8 @@ public async Task RefreshAsync() { _refreshLock.Release(); } + + FeedUpdated?.Invoke(this, EventArgs.Empty); } public async Task GetHtmlAsync() @@ -109,26 +104,26 @@ public async Task GetHtmlAsync() try { var markdown = await File.ReadAllTextAsync(_newsFilePath); - markdown = ImagePattern.Replace(markdown, match => + var document = Markdown.Parse(markdown, MarkdownPipeline); + foreach (var image in GetImages(document)) { - var relativePath = TryGetAssetRelativePath(match.Groups["path"].Value); + var relativePath = TryGetAssetRelativePath(image.Url); if (relativePath is null) { - return match.Value; + continue; } var filePath = GetAssetFilePath(relativePath); if (!File.Exists(filePath)) { - return match.Value; + continue; } - var imageBytes = File.ReadAllBytes(filePath); - var dataUri = $"data:{GetImageMimeType(filePath)};base64,{Convert.ToBase64String(imageBytes)}"; - return $"![{match.Groups["alt"].Value}]({dataUri})"; - }); + var imageBytes = await File.ReadAllBytesAsync(filePath); + image.Url = $"data:{GetImageMimeType(filePath)};base64,{Convert.ToBase64String(imageBytes)}"; + } - var html = Markdig.Markdown.ToHtml(markdown, MarkdownPipeline); + var html = document.ToHtml(MarkdownPipeline); return SanitizeGeneratedHtml(html); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) @@ -171,9 +166,14 @@ private string GetAssetFilePath(string relativePath) return path; } - private static string? TryGetAssetRelativePath(string imagePath) + // Uses Markdig's parse tree so inline and reference-style images are found exactly as they render, + // and image syntax shown inside code blocks is ignored. + private static IEnumerable GetImages(MarkdownDocument document) => + document.Descendants().Where(link => link.IsImage); + + private static string? TryGetAssetRelativePath(string? imagePath) { - if (Uri.TryCreate(imagePath, UriKind.Absolute, out _)) + if (string.IsNullOrWhiteSpace(imagePath) || Uri.TryCreate(imagePath, UriKind.Absolute, out _)) { return null; } @@ -217,13 +217,4 @@ private static async Task WriteAtomicallyAsync(string path, byte[] content) await File.WriteAllBytesAsync(temporaryPath, content); File.Move(temporaryPath, path, overwrite: true); } - - private static string GetAppDataDirectory() - { -#if !NOT_MAUI - return FileSystem.AppDataDirectory; -#else - return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); -#endif - } } diff --git a/src/NoteBookmark.MauiApp/MauiProgram.cs b/src/NoteBookmark.MauiApp/MauiProgram.cs index bcbb8ba..ec25087 100644 --- a/src/NoteBookmark.MauiApp/MauiProgram.cs +++ b/src/NoteBookmark.MauiApp/MauiProgram.cs @@ -39,7 +39,13 @@ public static MauiHostingApp CreateMauiApp() sp => new NoteBookmark.MauiApp.Data.LocalHtmlStorageService(FileSystem.AppDataDirectory)); builder.Services.AddSingleton( sp => sp.GetRequiredService()); - builder.Services.AddHttpClient(); + // Singleton so sync and the home page share one refresh lock and one set of cache files. + builder.Services.AddHttpClient(nameof(NoteBookmark.MauiApp.Data.NewsFeedService)); + builder.Services.AddSingleton( + sp => new NoteBookmark.MauiApp.Data.NewsFeedService( + sp.GetRequiredService().CreateClient(nameof(NoteBookmark.MauiApp.Data.NewsFeedService)), + sp.GetRequiredService>(), + FileSystem.AppDataDirectory)); builder.Services.AddTransient(); builder.Services.AddHttpClient(client =>