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.Api.Tests/Endpoints/PostEndpointsTests.cs b/src/NoteBookmark.Api.Tests/Endpoints/PostEndpointsTests.cs index 9372cac..3e6c1ec 100644 --- a/src/NoteBookmark.Api.Tests/Endpoints/PostEndpointsTests.cs +++ b/src/NoteBookmark.Api.Tests/Endpoints/PostEndpointsTests.cs @@ -170,6 +170,36 @@ public async Task ExtractPostDetails_WithValidUrl_ReturnsOkWithPost() post.Title.Should().NotBeNullOrEmpty(); } + [Fact] + public async Task ExtractPostDetails_CalledTwiceForSameUrl_StoresSinglePostAndReturnsExisting() + { + // Arrange + var extractRequest = new + { + url = "https://example.com/blog/duplicate-check-" + Guid.NewGuid(), + tags = "test", + category = "Test" + }; + + // Act + var firstResponse = await _client.PostAsJsonAsync("/api/posts/extractPostDetails", extractRequest); + var secondResponse = await _client.PostAsJsonAsync("/api/posts/extractPostDetails", extractRequest); + + // Assert + firstResponse.StatusCode.Should().Be(HttpStatusCode.OK); + secondResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var firstPost = await firstResponse.Content.ReadFromJsonAsync(); + var secondPost = await secondResponse.Content.ReadFromJsonAsync(); + firstPost.Should().NotBeNull(); + secondPost.Should().NotBeNull(); + secondPost!.RowKey.Should().Be(firstPost!.RowKey); + + var unreadPosts = await _client.GetFromJsonAsync>("/api/posts/"); + unreadPosts.Should().NotBeNull(); + unreadPosts!.Count(p => p.Url == extractRequest.url).Should().Be(1); + } + [Fact] public async Task ExtractPostDetails_WithInvalidUrl_ReturnsBadRequest() { diff --git a/src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs b/src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs index 3642524..1802d66 100644 --- a/src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs +++ b/src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs @@ -53,6 +53,34 @@ public void GetPost_WhenPostDoesNotExist_ReturnsNull() result.Should().BeNull(); } + [Fact] + public void GetPostByUrl_WhenPostExists_ReturnsPost() + { + // Arrange + var testPost = CreateTestPost(); + testPost.Url = "https://example.com/by-url-" + Guid.NewGuid(); + var table = _fixture.TableServiceClient!.GetTableClient("Posts"); + table.CreateIfNotExists(); + table.AddEntity(testPost); + + // Act + var result = _dataStorageService.GetPostByUrl(testPost.Url); + + // Assert + result.Should().NotBeNull(); + result!.RowKey.Should().Be(testPost.RowKey); + } + + [Fact] + public void GetPostByUrl_WhenNoPostHasUrl_ReturnsNull() + { + // Act + var result = _dataStorageService.GetPostByUrl("https://example.com/missing-" + Guid.NewGuid()); + + // Assert + result.Should().BeNull(); + } + [Fact] public void SavePost_WhenNewPost_AddsPostSuccessfully() { diff --git a/src/NoteBookmark.Api/DataStorageService.cs b/src/NoteBookmark.Api/DataStorageService.cs index 6bfc50f..778dbce 100644 --- a/src/NoteBookmark.Api/DataStorageService.cs +++ b/src/NoteBookmark.Api/DataStorageService.cs @@ -141,6 +141,12 @@ public List GetPostIds() return post; } + public Post? GetPostByUrl(string url) + { + var tblPost = GetPostTable(); + return tblPost.Query(p => p.Url == url).FirstOrDefault(); + } + public bool SavePost(Post post) { var tblPost = GetPostTable(); diff --git a/src/NoteBookmark.Api/IDataStorageService.cs b/src/NoteBookmark.Api/IDataStorageService.cs index fbc8704..72b08ab 100644 --- a/src/NoteBookmark.Api/IDataStorageService.cs +++ b/src/NoteBookmark.Api/IDataStorageService.cs @@ -12,6 +12,8 @@ public interface IDataStorageService public Post? GetPost(string rowKey); + public Post? GetPostByUrl(string url); + public bool SavePost(Post post); public List GetSummaries(); diff --git a/src/NoteBookmark.Api/PostEndpoints.cs b/src/NoteBookmark.Api/PostEndpoints.cs index ec85fd2..ef5ebec 100644 --- a/src/NoteBookmark.Api/PostEndpoints.cs +++ b/src/NoteBookmark.Api/PostEndpoints.cs @@ -113,6 +113,15 @@ static async Task, BadRequest>> ExtractPostDetails( try { var decodeUrl = System.Net.WebUtility.UrlDecode(request.url); + + // The same URL can be submitted more than once (e.g. a prerendered page followed + // by its interactive render), so return the existing post instead of adding a duplicate. + var existingPost = dataStorageService.GetPostByUrl(decodeUrl); + if (existingPost != null) + { + return TypedResults.Ok(existingPost); + } + var post = await ExtractPostDetailsFromUrl(decodeUrl); if (post != null) { diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs index f51e4c3..52cf970 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs @@ -15,6 +15,9 @@ namespace NoteBookmark.BlazorApp.Tests.Tests; public sealed class QuickAddTests : BunitContext { + private static readonly RendererInfo Prerendering = new("Static", isInteractive: false); + private static readonly RendererInfo Interactive = new("Server", isInteractive: true); + private readonly Mock _dataServiceMock; public QuickAddTests() @@ -28,6 +31,8 @@ public QuickAddTests() Services.AddSingleton(_dataServiceMock.Object); Services.AddSingleton(new Mock().Object); Services.AddSingleton(new Mock().Object); + + SetRendererInfo(Interactive); } [Fact] @@ -128,4 +133,52 @@ public void QuickAdd_WithoutUrlParameter_DisplaysManualInput() cut.Markup.Should().Contain("Enter or paste URL"); cut.Markup.Should().Contain("Close Window"); } + + [Fact] + public void QuickAdd_WhenPrerenderedThenRenderedInteractively_SavesPostOnlyOnce() + { + const string testUrl = "https://example.com/bookmarklet-post"; + var samplePost = new Post + { + PartitionKey = "p", + RowKey = "bookmarklet-row-key", + Title = "Bookmarklet Post", + Author = "Jane Doe", + Url = testUrl + }; + + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)) + .ReturnsAsync(samplePost); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + // A page visit under InteractiveServer creates one instance for the prerender + // and a second, fresh instance once the circuit is interactive. + SetRendererInfo(Prerendering); + Render(); + + SetRendererInfo(Interactive); + var cut = Render(); + + cut.Markup.Should().Contain("Post saved successfully!"); + cut.Markup.Should().Contain("Bookmarklet Post"); + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(testUrl), Times.Once); + } + + [Fact] + public void QuickAdd_WhenPrerendering_ShowsProgressWithoutSaving() + { + const string testUrl = "https://example.com/prerender"; + SetRendererInfo(Prerendering); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Extracting and saving post details..."); + cut.Markup.Should().NotContain("Enter or paste URL"); + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(It.IsAny()), Times.Never); + } } diff --git a/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs new file mode 100644 index 0000000..d204146 --- /dev/null +++ b/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs @@ -0,0 +1,185 @@ +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"); + } + + [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)) + { + Directory.Delete(_appDataDirectory, recursive: true); + } + } + + private NewsFeedService CreateService(HttpMessageHandler handler) + { + var logger = new Mock>(); + 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) => + 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..4e7b3e0 100644 --- a/src/NoteBookmark.MauiApp/Components/Pages/Home.razor +++ b/src/NoteBookmark.MauiApp/Components/Pages/Home.razor @@ -1,27 +1,48 @@ @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 + NewsFeed.FeedUpdated += OnFeedUpdated; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + 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 new file mode 100644 index 0000000..26257c5 --- /dev/null +++ b/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs @@ -0,0 +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 new file mode 100644 index 0000000..99a1c73 --- /dev/null +++ b/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Markdig; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using Microsoft.Extensions.Logging; + +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 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, string appDataDirectory) + { + _httpClient = httpClient; + _logger = logger; + _documentsDirectory = Path.Combine(appDataDirectory, "docs"); + _assetsDirectory = Path.Combine(_documentsDirectory, "assets"); + _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 = GetImages(Markdown.Parse(markdown, MarkdownPipeline)) + .Select(image => TryGetAssetRelativePath(image.Url)) + .Where(path => path is not null) + .Cast() + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var downloadedAssets = new 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(); + } + + FeedUpdated?.Invoke(this, EventArgs.Empty); + } + + 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); + var document = Markdown.Parse(markdown, MarkdownPipeline); + foreach (var image in GetImages(document)) + { + var relativePath = TryGetAssetRelativePath(image.Url); + if (relativePath is null) + { + continue; + } + + var filePath = GetAssetFilePath(relativePath); + if (!File.Exists(filePath)) + { + continue; + } + + var imageBytes = await File.ReadAllBytesAsync(filePath); + image.Url = $"data:{GetImageMimeType(filePath)};base64,{Convert.ToBase64String(imageBytes)}"; + } + + var html = document.ToHtml(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; + } + + // 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 (string.IsNullOrWhiteSpace(imagePath) || 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); + } +} 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..ec25087 100644 --- a/src/NoteBookmark.MauiApp/MauiProgram.cs +++ b/src/NoteBookmark.MauiApp/MauiProgram.cs @@ -39,6 +39,13 @@ public static MauiHostingApp CreateMauiApp() sp => new NoteBookmark.MauiApp.Data.LocalHtmlStorageService(FileSystem.AppDataDirectory)); builder.Services.AddSingleton( sp => sp.GetRequiredService()); + // 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 => 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 @@ + diff --git a/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor b/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor index 7d836ad..27e1b99 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor @@ -91,6 +91,15 @@ var targetUrl = !string.IsNullOrWhiteSpace(Url) ? Url : AddUrl; if (!string.IsNullOrWhiteSpace(targetUrl) && targetUrl != _processedUrl) { + // During prerendering this instance is discarded and a new one is created once the + // interactive circuit starts, so saving here would add the post twice. Show the + // spinner and let the interactive render do the save. + if (!RendererInfo.IsInteractive) + { + isLoading = true; + return; + } + _processedUrl = targetUrl; await ProcessUrlAsync(targetUrl); }