From a970e5a623909b88649ffac1bea3f0758f0167f0 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 8 Aug 2026 08:34:06 -0400 Subject: [PATCH 01/25] fix(#173): use Id ?? RowKey for checking and opening offline post HTML (#179) --- .../Tests/PostsTests.cs | 33 +++++++++++++++++++ .../Components/Pages/Posts.razor | 4 +-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs index 26244cc..67605b3 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs @@ -127,3 +127,36 @@ public void Posts_RendersEmptyState_WhenNoPostsReturned() cut.Markup.Should().Contain("Nothing to see here"); } } + +public sealed class PostsHtmlCacheTests : BunitContext +{ + [Fact] + public void Posts_ChecksHtmlCacheWithPostId_WhenIdIsPresent() + { + this.AddFluentUI(); + this.AddAuthorization().SetAuthorized("testuser"); + + var dataServiceMock = new Mock(); + dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync([ + new PostL { PartitionKey = "p", RowKey = "row-key-456", Id = "custom-id-123", Title = "Post With Id", Url = "https://example.com/id", Date_published = "2025-01-15T00:00:00", is_read = false } + ]); + 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); + + var htmlCacheMock = new Mock(); + htmlCacheMock.Setup(c => c.IsHtmlCached("custom-id-123")).Returns(true); + + Services.AddSingleton(dataServiceMock.Object); + Services.AddSingleton(new Mock().Object); + Services.AddSingleton(new Mock().Object); + Services.AddSingleton(htmlCacheMock.Object); + + var cut = Render(); + + htmlCacheMock.Verify(c => c.IsHtmlCached("custom-id-123"), Times.Once); + cut.Markup.Should().Contain("Read post"); + } +} + diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index 697b5b1..54105af 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -42,9 +42,9 @@ { } - @if (localHtmlCache.IsHtmlCached(context!.RowKey)) + @if (localHtmlCache.IsHtmlCached(context!.Id ?? context!.RowKey)) { - + } From a5d25a207f57348a6f4e02fda4b3c6f3077f59af Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 8 Aug 2026 08:34:40 -0400 Subject: [PATCH 02/25] feat(#176): add sync progress bar and status updates for downloading offline post text (#180) --- .../SyncProgressEventArgs.cs | 18 ++++++++ .../SyncServiceTests.cs | 25 +++++++++++ .../Data/OfflineDataService.cs | 5 +++ src/NoteBookmark.MauiApp/Data/SyncService.cs | 41 ++++++++++++++----- .../Components/Pages/Posts.razor | 31 ++++++++++++++ src/NoteBookmark.SharedUI/IDataService.cs | 1 + src/NoteBookmark.SharedUI/PostNoteClient.cs | 1 + 7 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 src/NoteBookmark.Domain/SyncProgressEventArgs.cs diff --git a/src/NoteBookmark.Domain/SyncProgressEventArgs.cs b/src/NoteBookmark.Domain/SyncProgressEventArgs.cs new file mode 100644 index 0000000..fc7399a --- /dev/null +++ b/src/NoteBookmark.Domain/SyncProgressEventArgs.cs @@ -0,0 +1,18 @@ +using System; + +namespace NoteBookmark.Domain; + +public class SyncProgressEventArgs : EventArgs +{ + public int Current { get; } + public int Total { get; } + public string Status { get; } + public double Percentage => Total > 0 ? (double)Current / Total * 100 : 0; + + public SyncProgressEventArgs(int current, int total, string status) + { + Current = current; + Total = total; + Status = status; + } +} diff --git a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs index b4eb5e6..6cc0536 100644 --- a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs +++ b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs @@ -444,5 +444,30 @@ public async Task PushPhase_ShouldSyncNoteDirectlyAndClearCreatedOfflineFlag_Whe _localDataServiceMock.Verify(c => c.SaveNoteAsync(It.Is(n => !n.CreatedOffline), false), Times.Once); _localDataServiceMock.Verify(c => c.MarkSyncedAsync("note1", false), Times.Once); } + + [Fact] + public async Task SyncAsync_ShouldRaiseSyncProgressChanged_WhenDownloadingPostHtml() + { + var post1 = new Post { Id = "post1", RowKey = "post1", PartitionKey = "pk", Title = "Post 1", is_read = false }; + var post2 = new Post { Id = "post2", RowKey = "post2", PartitionKey = "pk", Title = "Post 2", is_read = false }; + + _localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List()); + _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List { post1, post2 }); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetPostHtmlAsync(It.IsAny())).ReturnsAsync("Post content"); + + _localHtmlStorageServiceMock.Setup(s => s.GetCachedPostIds()).Returns(new List()); + _localHtmlStorageServiceMock.Setup(s => s.IsPostHtmlCached(It.IsAny())).Returns(false); + + var progressEvents = new List(); + _sut.SyncProgressChanged += (sender, args) => progressEvents.Add(args); + + await _sut.SyncAsync(); + + progressEvents.Should().NotBeEmpty(); + progressEvents.Should().Contain(e => e.Status.Contains("Downloading offline text")); + progressEvents.Last().Status.Should().Be("Synchronization complete!"); + } } diff --git a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs index 115efe8..1136a47 100644 --- a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs @@ -279,6 +279,11 @@ public async Task DeletePost(string id) => localHtmlStorageService.GetPostHtmlAsync(postId); public Task SyncAsync() => syncService.SyncAsync(); + public event EventHandler? SyncProgressChanged + { + add => syncService.SyncProgressChanged += value; + remove => syncService.SyncProgressChanged -= value; + } public bool IsOffline => connectivity.NetworkAccess != NetworkAccess.Internet; public bool CanSync => true; diff --git a/src/NoteBookmark.MauiApp/Data/SyncService.cs b/src/NoteBookmark.MauiApp/Data/SyncService.cs index 7f5821c..4f52f3d 100644 --- a/src/NoteBookmark.MauiApp/Data/SyncService.cs +++ b/src/NoteBookmark.MauiApp/Data/SyncService.cs @@ -17,6 +17,7 @@ public interface ISyncService Task SyncAsync(); bool IsSyncing { get; } event EventHandler? ConflictDetected; + event EventHandler? SyncProgressChanged; } public class SyncService( @@ -30,6 +31,7 @@ public class SyncService( public bool IsSyncing => _isSyncing; public event EventHandler? ConflictDetected; + public event EventHandler? SyncProgressChanged; public async Task SyncAsync() { @@ -38,6 +40,7 @@ public async Task SyncAsync() _isSyncing = true; try { + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Starting synchronization...")); var lastSyncStr = await GetPreferenceAsync(LastSyncTimestampKey); DateTime? lastSync = null; if (!string.IsNullOrEmpty(lastSyncStr) && DateTime.TryParse(lastSyncStr, out var parsed)) @@ -45,11 +48,16 @@ public async Task SyncAsync() lastSync = parsed.ToUniversalTime(); } + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Pushing local changes...")); await PushAsync(lastSync); + + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Pulling remote changes...")); await PullAsync(lastSync); + await SyncHtmlAsync(); await SetPreferenceAsync(LastSyncTimestampKey, DateTime.UtcNow.ToString("O")); + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Synchronization complete!")); } finally { @@ -245,22 +253,33 @@ private async Task SyncHtmlAsync() } // Download HTML for unread posts not yet cached - foreach (var post in posts.Where(p => p.is_read != true)) + var unreadToDownload = posts.Where(p => p.is_read != true && !localHtmlStorageService.IsPostHtmlCached(p.Id ?? p.RowKey)).ToList(); + int total = unreadToDownload.Count; + + if (total > 0) { - var id = post.Id ?? post.RowKey; - if (localHtmlStorageService.IsPostHtmlCached(id)) continue; + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, total, $"Downloading offline text (0/{total})...")); - try + for (int i = 0; i < unreadToDownload.Count; i++) { - var html = await apiClient.GetPostHtmlAsync(id); - if (html != null) + var post = unreadToDownload[i]; + var id = post.Id ?? post.RowKey; + + try { - await localHtmlStorageService.SavePostHtmlAsync(id, html); + 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); + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to download HTML for post {PostId}", id); + } + + int current = i + 1; + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(current, total, $"Downloading offline text ({current}/{total})...")); } } } diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index 54105af..b451b65 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -9,6 +9,7 @@ @inject IDialogService DialogService @inject NavigationManager Navigation @inject ILocalHtmlCache localHtmlCache +@implements IDisposable Posts @@ -23,6 +24,16 @@ Sync } + @if (isSyncing || !string.IsNullOrEmpty(syncProgressStatus)) + { + + + @if (!string.IsNullOrEmpty(syncProgressStatus)) + { + @syncProgressStatus + } + + } Read Only UnRead Only @@ -84,9 +95,13 @@ private PaginationState pagination = new PaginationState { ItemsPerPage = 20 }; private string titleFilter = string.Empty; private bool showPublishedDate = false; + private int syncProgressCurrent = 0; + private int syncProgressTotal = 0; + private string syncProgressStatus = string.Empty; protected override async Task OnInitializedAsync() { + client.SyncProgressChanged += OnSyncProgressChanged; await LoadPosts(); _ = StartBackgroundSync(); } @@ -284,4 +299,20 @@ } private void ReadPost(string postId) => Navigation.NavigateTo($"postreader/{postId}"); + + private void OnSyncProgressChanged(object? sender, SyncProgressEventArgs e) + { + InvokeAsync(() => + { + syncProgressCurrent = e.Current; + syncProgressTotal = e.Total; + syncProgressStatus = e.Status; + StateHasChanged(); + }); + } + + public void Dispose() + { + client.SyncProgressChanged -= OnSyncProgressChanged; + } } diff --git a/src/NoteBookmark.SharedUI/IDataService.cs b/src/NoteBookmark.SharedUI/IDataService.cs index 13b7ad3..0de89de 100644 --- a/src/NoteBookmark.SharedUI/IDataService.cs +++ b/src/NoteBookmark.SharedUI/IDataService.cs @@ -25,6 +25,7 @@ public interface IDataService Task SaveReadingNotesMarkdown(string markdown, string number); Task GetPostHtmlAsync(string postId); Task SyncAsync(); + event System.EventHandler? SyncProgressChanged; bool IsOffline { get; } bool CanSync { get; } } diff --git a/src/NoteBookmark.SharedUI/PostNoteClient.cs b/src/NoteBookmark.SharedUI/PostNoteClient.cs index 7d0e215..4e3a493 100644 --- a/src/NoteBookmark.SharedUI/PostNoteClient.cs +++ b/src/NoteBookmark.SharedUI/PostNoteClient.cs @@ -209,6 +209,7 @@ public async Task> GetNotesModifiedAfter(DateTime modifiedAfter) } public Task SyncAsync() => Task.CompletedTask; + public event EventHandler? SyncProgressChanged { add { } remove { } } public bool IsOffline => false; public bool CanSync => false; } From 2c97bd1a6599637741017343cc151ad2689a6d00 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Tue, 11 Aug 2026 06:49:10 -0400 Subject: [PATCH 03/25] should have done that long ago --- AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..30ec80c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Essential Coding & Workflow Rules + +## 1. Git & Workflow Rules +* **Branching**: Always create and work in a clearly identified branch prefixed with `feature/`, `bug/`, or `doc/` (e.g., `feature/issue-42-description`). +* **Issue Management**: When working on an issue, before starting any work, apply the `in-progress` label to the issue on GitHub. +* **Pull Requests**: Conclude all work by opening a Pull Request targeted to merge into the `v-next` branch. +* **No Auto-Merging**: **NEVER merge a PR or branch.** Merging is strictly the user's responsibility unless explicitly and unambiguously instructed otherwise. + +## 2. Coding & Testing Essentials +* **Nullable Reference Types**: Treat all nullable compiler warnings as errors. +* **Smart Unit Testing**: Write smart, meaningful unit tests for any new or modified functionality to verify behavior and prevent regressions. From 2677eb16802d457dd093dbc46fdc2067b2e50ba3 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Thu, 13 Aug 2026 06:57:50 -0400 Subject: [PATCH 04/25] fix: resolve Android MAUI app crash when opening URLs (issue #177) (#181) --- .../Helpers/BlazorTestContextExtensions.cs | 1 + src/NoteBookmark.BlazorApp/Program.cs | 1 + .../NoteBookmark.MauiApp.Tests.csproj | 1 + .../UrlLauncherTests.cs | 53 +++++++++++++++++++ .../Data/MauiUrlLauncher.cs | 48 +++++++++++++++++ src/NoteBookmark.MauiApp/MauiProgram.cs | 1 + .../Components/Pages/Posts.razor | 3 +- .../Components/Pages/Summaries.razor | 3 +- .../Components/Pages/SummaryEditor.razor | 4 +- src/NoteBookmark.SharedUI/IUrlLauncher.cs | 6 +++ src/NoteBookmark.SharedUI/JsUrlLauncher.cs | 30 +++++++++++ 11 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 src/NoteBookmark.MauiApp.Tests/UrlLauncherTests.cs create mode 100644 src/NoteBookmark.MauiApp/Data/MauiUrlLauncher.cs create mode 100644 src/NoteBookmark.SharedUI/IUrlLauncher.cs create mode 100644 src/NoteBookmark.SharedUI/JsUrlLauncher.cs diff --git a/src/NoteBookmark.BlazorApp.Tests/Helpers/BlazorTestContextExtensions.cs b/src/NoteBookmark.BlazorApp.Tests/Helpers/BlazorTestContextExtensions.cs index 56fab82..473fd1b 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Helpers/BlazorTestContextExtensions.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Helpers/BlazorTestContextExtensions.cs @@ -17,6 +17,7 @@ public static BunitContext AddFluentUI(this BunitContext ctx) { ctx.JSInterop.Mode = JSRuntimeMode.Loose; ctx.Services.AddFluentUIComponents(); + ctx.Services.AddScoped(); return ctx; } diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index 774d266..7699cbb 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -19,6 +19,7 @@ }); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); +builder.Services.AddScoped(); // 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 d0eb9dc..2c68b45 100644 --- a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj +++ b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj @@ -37,6 +37,7 @@ + diff --git a/src/NoteBookmark.MauiApp.Tests/UrlLauncherTests.cs b/src/NoteBookmark.MauiApp.Tests/UrlLauncherTests.cs new file mode 100644 index 0000000..1585967 --- /dev/null +++ b/src/NoteBookmark.MauiApp.Tests/UrlLauncherTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using Microsoft.JSInterop; +using Moq; +using NoteBookmark.MauiApp.Data; +using NoteBookmark.SharedUI; +using Xunit; + +namespace NoteBookmark.MauiApp.Tests; + +public class UrlLauncherTests +{ + [Fact] + public async Task JsUrlLauncher_WithNullOrWhitespaceUrl_DoesNotInvokeJs() + { + var jsMock = new Mock(); + var launcher = new JsUrlLauncher(jsMock.Object); + + await launcher.OpenUrlAsync(null); + await launcher.OpenUrlAsync(" "); + + jsMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task JsUrlLauncher_WithValidUrl_InvokesWindowOpen() + { + var jsMock = new Mock(); + jsMock.Setup(x => x.InvokeAsync("open", It.IsAny())) + .ReturnsAsync(null!); + + var launcher = new JsUrlLauncher(jsMock.Object); + + await launcher.OpenUrlAsync("https://example.com"); + + jsMock.Verify(x => x.InvokeAsync("open", It.Is(args => + args.Length == 2 && (string)args[0] == "https://example.com" && (string)args[1] == "_blank" + )), Times.Once); + } + + [Fact] + public async Task MauiUrlLauncher_WithNullOrInvalidUrl_DoesNotThrow() + { + var launcher = new MauiUrlLauncher(); + + var act1 = async () => await launcher.OpenUrlAsync(null); + var act2 = async () => await launcher.OpenUrlAsync("not-a-valid-url"); + var act3 = async () => await launcher.OpenUrlAsync("https://example.com"); + + await act1.Should().NotThrowAsync(); + await act2.Should().NotThrowAsync(); + await act3.Should().NotThrowAsync(); + } +} diff --git a/src/NoteBookmark.MauiApp/Data/MauiUrlLauncher.cs b/src/NoteBookmark.MauiApp/Data/MauiUrlLauncher.cs new file mode 100644 index 0000000..b8fb62f --- /dev/null +++ b/src/NoteBookmark.MauiApp/Data/MauiUrlLauncher.cs @@ -0,0 +1,48 @@ +#if !NOT_MAUI +using Microsoft.Maui.ApplicationModel.DataTransfer; +#endif +using NoteBookmark.SharedUI; + +namespace NoteBookmark.MauiApp.Data; + +public class MauiUrlLauncher : IUrlLauncher +{ +#if !NOT_MAUI + private readonly IBrowser _browser; + + public MauiUrlLauncher(IBrowser? browser = null) + { + _browser = browser ?? Browser.Default; + } +#else + public MauiUrlLauncher() + { + } +#endif + + public async Task OpenUrlAsync(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return; + } + + try + { +#if !NOT_MAUI + await _browser.OpenAsync(uri, BrowserLaunchMode.SystemPreferred); +#else + await Task.CompletedTask; +#endif + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to launch URL in MAUI browser: {ex.Message}"); + } + } +} diff --git a/src/NoteBookmark.MauiApp/MauiProgram.cs b/src/NoteBookmark.MauiApp/MauiProgram.cs index 486f68c..3c74ed6 100644 --- a/src/NoteBookmark.MauiApp/MauiProgram.cs +++ b/src/NoteBookmark.MauiApp/MauiProgram.cs @@ -31,6 +31,7 @@ public static MauiHostingApp CreateMauiApp() #endif builder.Services.AddSingleton(Microsoft.Maui.Networking.Connectivity.Current); + builder.Services.AddSingleton(); // Data Layer builder.Services.AddSingleton(); diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index b451b65..7d375ee 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -9,6 +9,7 @@ @inject IDialogService DialogService @inject NavigationManager Navigation @inject ILocalHtmlCache localHtmlCache +@inject IUrlLauncher urlLauncher @implements IDisposable Posts @@ -130,7 +131,7 @@ private async Task OpenUrlInNewWindow(string? url) { - await jsRuntime.InvokeVoidAsync("open", url, "_blank"); + await urlLauncher.OpenUrlAsync(url); } private async Task CreateNoteForPost(string postId) diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Summaries.razor b/src/NoteBookmark.SharedUI/Components/Pages/Summaries.razor index 776e7de..307d729 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Summaries.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Summaries.razor @@ -5,6 +5,7 @@ @inject IDataService client @inject NavigationManager Navigation @inject IJSRuntime jsRuntime +@inject IUrlLauncher urlLauncher Summaries @@ -49,6 +50,6 @@ private async Task OpenUrlInNewWindow(string? url) { - await jsRuntime.InvokeVoidAsync("open", url, "_blank"); + await urlLauncher.OpenUrlAsync(url); } } diff --git a/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor b/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor index b620153..ec4fafa 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor @@ -10,6 +10,7 @@ @inject IJSRuntime jsRuntime @inject IToastService toastService @inject SummaryService aiService +@inject IUrlLauncher urlLauncher

SummaryEditor

@@ -138,7 +139,7 @@ else{ private async Task OpenUrlInNewWindow(string? url) { - await jsRuntime.InvokeVoidAsync("open", url, "_blank"); + await urlLauncher.OpenUrlAsync(url); } private void AddExtraNote(string category) @@ -273,5 +274,4 @@ else{ isGenarating = false; } } - } diff --git a/src/NoteBookmark.SharedUI/IUrlLauncher.cs b/src/NoteBookmark.SharedUI/IUrlLauncher.cs new file mode 100644 index 0000000..b73d036 --- /dev/null +++ b/src/NoteBookmark.SharedUI/IUrlLauncher.cs @@ -0,0 +1,6 @@ +namespace NoteBookmark.SharedUI; + +public interface IUrlLauncher +{ + Task OpenUrlAsync(string? url); +} diff --git a/src/NoteBookmark.SharedUI/JsUrlLauncher.cs b/src/NoteBookmark.SharedUI/JsUrlLauncher.cs new file mode 100644 index 0000000..209d65e --- /dev/null +++ b/src/NoteBookmark.SharedUI/JsUrlLauncher.cs @@ -0,0 +1,30 @@ +using Microsoft.JSInterop; + +namespace NoteBookmark.SharedUI; + +public class JsUrlLauncher : IUrlLauncher +{ + private readonly IJSRuntime _jsRuntime; + + public JsUrlLauncher(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public async Task OpenUrlAsync(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return; + } + + try + { + await _jsRuntime.InvokeVoidAsync("open", url, "_blank"); + } + catch + { + // Ignore JS interop exceptions when opening URL fails + } + } +} From 1acc7d6f5a698a4b334daf654f5a69eaedeca701 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 15 Aug 2026 06:49:41 -0400 Subject: [PATCH 05/25] feat: add up/down reordering for categories and notes in summary editor (issue #92) (#183) Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> --- .../Tests/ReadingNotesReorderingTests.cs | 115 ++++++++++++++++++ src/NoteBookmark.Domain/ReadingNotes.cs | 80 ++++++++++++ .../Components/Pages/SummaryEditor.razor | 55 +++++++-- 3 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 src/NoteBookmark.BlazorApp.Tests/Tests/ReadingNotesReorderingTests.cs diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/ReadingNotesReorderingTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/ReadingNotesReorderingTests.cs new file mode 100644 index 0000000..2b6a2d9 --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/ReadingNotesReorderingTests.cs @@ -0,0 +1,115 @@ +using FluentAssertions; +using NoteBookmark.Domain; +using Xunit; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +public class ReadingNotesReorderingTests +{ + private ReadingNotes CreateSampleReadingNotes() + { + var rn = new ReadingNotes("1") + { + Title = "Reading Notes #1" + }; + rn.Notes["Category A"] = new List + { + new ReadingNote { Title = "Note A1", RowKey = "a1" }, + new ReadingNote { Title = "Note A2", RowKey = "a2" }, + new ReadingNote { Title = "Note A3", RowKey = "a3" } + }; + rn.Notes["Category B"] = new List + { + new ReadingNote { Title = "Note B1", RowKey = "b1" } + }; + rn.Notes["Category C"] = new List + { + new ReadingNote { Title = "Note C1", RowKey = "c1" } + }; + return rn; + } + + [Fact] + public void MoveCategoryUp_SwapsCategoryWithPrevious() + { + var rn = CreateSampleReadingNotes(); + + bool moved = rn.MoveCategoryUp("Category B"); + + moved.Should().BeTrue(); + rn.Notes.Keys.Should().ContainInConsecutiveOrder("Category B", "Category A", "Category C"); + } + + [Fact] + public void MoveCategoryUp_OnFirstCategory_ReturnsFalse() + { + var rn = CreateSampleReadingNotes(); + + bool moved = rn.MoveCategoryUp("Category A"); + + moved.Should().BeFalse(); + rn.Notes.Keys.Should().ContainInConsecutiveOrder("Category A", "Category B", "Category C"); + } + + [Fact] + public void MoveCategoryDown_SwapsCategoryWithNext() + { + var rn = CreateSampleReadingNotes(); + + bool moved = rn.MoveCategoryDown("Category A"); + + moved.Should().BeTrue(); + rn.Notes.Keys.Should().ContainInConsecutiveOrder("Category B", "Category A", "Category C"); + } + + [Fact] + public void MoveCategoryDown_OnLastCategory_ReturnsFalse() + { + var rn = CreateSampleReadingNotes(); + + bool moved = rn.MoveCategoryDown("Category C"); + + moved.Should().BeFalse(); + rn.Notes.Keys.Should().ContainInConsecutiveOrder("Category A", "Category B", "Category C"); + } + + [Fact] + public void MoveNoteUp_SwapsNoteWithPrevious() + { + var rn = CreateSampleReadingNotes(); + + bool moved = rn.MoveNoteUp("Category A", 1); + + moved.Should().BeTrue(); + rn.Notes["Category A"].Select(n => n.Title).Should().ContainInConsecutiveOrder("Note A2", "Note A1", "Note A3"); + } + + [Fact] + public void MoveNoteDown_SwapsNoteWithNext() + { + var rn = CreateSampleReadingNotes(); + + bool moved = rn.MoveNoteDown("Category A", 0); + + moved.Should().BeTrue(); + rn.Notes["Category A"].Select(n => n.Title).Should().ContainInConsecutiveOrder("Note A2", "Note A1", "Note A3"); + } + + [Fact] + public void ReorderedNotes_ReflectsInMarkdownGeneration() + { + var rn = CreateSampleReadingNotes(); + rn.MoveCategoryUp("Category B"); + rn.MoveNoteUp("Category A", 1); + + string md = rn.ToMarkDown(); + + int catBPos = md.IndexOf("## Category B"); + int catAPos = md.IndexOf("## Category A"); + catBPos.Should().BeLessThan(catAPos); + + int noteA2Pos = md.IndexOf("Note A2"); + int noteA1Pos = md.IndexOf("Note A1"); + noteA2Pos.Should().BeLessThan(noteA1Pos); + } +} diff --git a/src/NoteBookmark.Domain/ReadingNotes.cs b/src/NoteBookmark.Domain/ReadingNotes.cs index 299018d..156f83b 100644 --- a/src/NoteBookmark.Domain/ReadingNotes.cs +++ b/src/NoteBookmark.Domain/ReadingNotes.cs @@ -38,6 +38,86 @@ public ReadingNotes(string number) public string Intro { get; set; } = string.Empty; public Dictionary> Notes { get; set; } + public bool MoveCategoryUp(string category) + { + if (Notes == null || !Notes.ContainsKey(category)) return false; + + var keys = Notes.Keys.ToList(); + int index = keys.IndexOf(category); + if (index <= 0) return false; + + var newDict = new Dictionary>(); + for (int i = 0; i < keys.Count; i++) + { + if (i == index - 1) + { + newDict[category] = Notes[category]; + newDict[keys[i]] = Notes[keys[i]]; + } + else if (i == index) + { + continue; + } + else + { + newDict[keys[i]] = Notes[keys[i]]; + } + } + Notes = newDict; + return true; + } + + public bool MoveCategoryDown(string category) + { + if (Notes == null || !Notes.ContainsKey(category)) return false; + + var keys = Notes.Keys.ToList(); + int index = keys.IndexOf(category); + if (index < 0 || index >= keys.Count - 1) return false; + + var newDict = new Dictionary>(); + for (int i = 0; i < keys.Count; i++) + { + if (i == index) + { + newDict[keys[i + 1]] = Notes[keys[i + 1]]; + newDict[category] = Notes[category]; + } + else if (i == index + 1) + { + continue; + } + else + { + newDict[keys[i]] = Notes[keys[i]]; + } + } + Notes = newDict; + return true; + } + + public bool MoveNoteUp(string category, int noteIndex) + { + if (Notes == null || !Notes.TryGetValue(category, out var list)) return false; + if (noteIndex <= 0 || noteIndex >= list.Count) return false; + + var note = list[noteIndex]; + list.RemoveAt(noteIndex); + list.Insert(noteIndex - 1, note); + return true; + } + + public bool MoveNoteDown(string category, int noteIndex) + { + if (Notes == null || !Notes.TryGetValue(category, out var list)) return false; + if (noteIndex < 0 || noteIndex >= list.Count - 1) return false; + + var note = list[noteIndex]; + list.RemoveAt(noteIndex); + list.Insert(noteIndex + 1, note); + return true; + } + public string GetAllUniqueTags(){ var uniqueTags = new HashSet(); diff --git a/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor b/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor index ec4fafa..2c2d256 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor @@ -46,34 +46,49 @@ else{ IconStart="@(new Icons.Filled.Size20.StarEmphasis())">Generate
- @foreach (var note in readingNotes!.Notes) + @{ + var categoryKeys = readingNotes!.Notes.Keys.ToList(); + } + @for (int catIdx = 0; catIdx < categoryKeys.Count; catIdx++) { - string category = note.Key; - List rnList = note.Value; + string category = categoryKeys[catIdx]; + List rnList = readingNotes.Notes[category]; + bool isFirstCategory = catIdx == 0; + bool isLastCategory = catIdx == categoryKeys.Count - 1;
- +

@category

- + + +
- @foreach (ReadingNote rn in rnList) + @for (int noteIdx = 0; noteIdx < rnList.Count; noteIdx++) { -
- + var rn = rnList[noteIdx]; + int currentNoteIndex = noteIdx; + bool isFirstNote = noteIdx == 0; + bool isLastNote = noteIdx == rnList.Count - 1; +
+ + + + + + - +
- } Add Note
@@ -274,4 +289,24 @@ else{ isGenarating = false; } } + + private void MoveCategoryUp(string category) + { + readingNotes?.MoveCategoryUp(category); + } + + private void MoveCategoryDown(string category) + { + readingNotes?.MoveCategoryDown(category); + } + + private void MoveNoteUp(string category, int index) + { + readingNotes?.MoveNoteUp(category, index); + } + + private void MoveNoteDown(string category, int index) + { + readingNotes?.MoveNoteDown(category, index); + } } From 007a4d45822e00ecc0612fbe54a18c2a5a2a96cc Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 15 Aug 2026 06:50:17 -0400 Subject: [PATCH 06/25] feat: add text size slider and fix text wrapping on offline reader page (issue #178) (#182) --- .../Tests/PostReaderTests.cs | 66 +++++++++++++++++++ .../Components/Pages/PostReader.razor | 17 ++++- .../Components/Pages/PostReader.razor.css | 30 +++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs create mode 100644 src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor.css diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs new file mode 100644 index 0000000..b480ab3 --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs @@ -0,0 +1,66 @@ +using Bunit; +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; +using Xunit; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +public sealed class PostReaderTests : BunitContext +{ + private readonly Mock _dataServiceMock; + + public PostReaderTests() + { + this.AddFluentUI(); + this.AddAuthorization().SetAuthorized("testuser"); + + _dataServiceMock = new Mock(); + _dataServiceMock.Setup(s => s.GetPost("p1")).ReturnsAsync(new Post + { + PartitionKey = "p", + RowKey = "p1", + Title = "Test Offline Article Title", + Author = "Frank Boucher", + Date_published = "2026-01-01T00:00:00" + }); + _dataServiceMock.Setup(s => s.GetPostHtmlAsync("p1")).ReturnsAsync("

Hello offline reader world

"); + + Services.AddSingleton(_dataServiceMock.Object); + } + + [Fact] + public void PostReader_RendersTitleAndContentAndSlider() + { + var cut = Render(ps => ps.Add(p => p.PostId, "p1")); + + cut.Markup.Should().Contain("Test Offline Article Title"); + cut.Markup.Should().Contain("Frank Boucher"); + cut.Markup.Should().Contain("Hello offline reader world"); + cut.Markup.Should().Contain("reader-content"); + cut.Markup.Should().Contain("Text size:"); + + var slider = cut.FindComponent>(); + slider.Instance.Min.Should().Be(8); + slider.Instance.Max.Should().Be(56); + } + + [Fact] + public void PostReader_SliderValueChange_UpdatesContentFontSize() + { + var cut = Render(ps => ps.Add(p => p.PostId, "p1")); + + var contentDivBefore = cut.Find("div.reader-content"); + contentDivBefore.GetAttribute("style").Should().Contain("font-size: 16px;"); + + var slider = cut.FindComponent>(); + cut.InvokeAsync(() => slider.Instance.ValueChanged.InvokeAsync(24)); + + var contentDivAfter = cut.Find("div.reader-content"); + contentDivAfter.GetAttribute("style").Should().Contain("font-size: 24px;"); + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor index 9c086e5..57af22a 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor @@ -8,7 +8,7 @@ @(post?.Title ?? "Reading...") - + @post.Title +

@post.Title

@if (!string.IsNullOrEmpty(post.Author)) { @@ -38,7 +38,7 @@ @if (htmlContent != null) { -
+
@((MarkupString)htmlContent)
} @@ -46,6 +46,16 @@ {

Content not available.

} + + + + + + Text size: + @(textSize)px + + + } @@ -56,6 +66,7 @@ private Post? post; private string? htmlContent; private bool isLoading = true; + private int textSize = 16; protected override async Task OnInitializedAsync() { diff --git a/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor.css b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor.css new file mode 100644 index 0000000..e80f390 --- /dev/null +++ b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor.css @@ -0,0 +1,30 @@ +.reader-content { + width: 100%; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + box-sizing: border-box; +} + +.reader-content ::deep * { + max-width: 100%; + box-sizing: border-box; +} + +.reader-content ::deep img { + max-width: 100%; + height: auto; +} + +.reader-content ::deep pre, +.reader-content ::deep code { + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; +} + +.reader-content ::deep table { + display: block; + max-width: 100%; + overflow-x: auto; +} From 5077f44b930e3f0a01d7bd6b6958a1b2f941481e Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 15 Aug 2026 07:04:37 -0400 Subject: [PATCH 07/25] bumb version --- src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj index 21558e0..4e2da8e 100644 --- a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj +++ b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj @@ -44,9 +44,9 @@ c5m.notebookmark.mauiapp - 1.3.2 - 5 - 1.3.2 + 1.3.3 + 6 + 1.3.3 None From ff8ecd1ea815472b8e1c2d523d914653ba072df9 Mon Sep 17 00:00:00 2001 From: Frank Boucher <2404846+fboucher@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:35:36 -0400 Subject: [PATCH 08/25] feat: implement async background post content extraction to save HTML in blob storage (#185) Co-authored-by: fboucher-os --- .../Endpoints/PostExtractionTests.cs | 74 ++++++++++++++++ .../Fixtures/FakePostParserClient.cs | 13 +++ .../Fixtures/NoteBookmarkApiTestFactory.cs | 3 + src/NoteBookmark.Api/IPostParserClient.cs | 9 ++ src/NoteBookmark.Api/PostEndpoints.cs | 10 ++- .../PostExtractionBackgroundWorker.cs | 87 +++++++++++++++++++ src/NoteBookmark.Api/PostExtractionQueue.cs | 34 ++++++++ src/NoteBookmark.Api/PostParserClient.cs | 56 ++++++++++++ src/NoteBookmark.Api/Program.cs | 5 ++ 9 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/NoteBookmark.Api.Tests/Endpoints/PostExtractionTests.cs create mode 100644 src/NoteBookmark.Api.Tests/Fixtures/FakePostParserClient.cs create mode 100644 src/NoteBookmark.Api/IPostParserClient.cs create mode 100644 src/NoteBookmark.Api/PostExtractionBackgroundWorker.cs create mode 100644 src/NoteBookmark.Api/PostExtractionQueue.cs create mode 100644 src/NoteBookmark.Api/PostParserClient.cs diff --git a/src/NoteBookmark.Api.Tests/Endpoints/PostExtractionTests.cs b/src/NoteBookmark.Api.Tests/Endpoints/PostExtractionTests.cs new file mode 100644 index 0000000..4fb5e35 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Endpoints/PostExtractionTests.cs @@ -0,0 +1,74 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NoteBookmark.Api.Tests.Fixtures; +using NoteBookmark.Domain; +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Xunit; +using Azure.Storage.Blobs; + +namespace NoteBookmark.Api.Tests.Endpoints; + +public class PostExtractionTests : IClassFixture +{ + private readonly NoteBookmarkApiTestFactory _factory; + private readonly HttpClient _client; + + public PostExtractionTests(NoteBookmarkApiTestFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + [Fact] + public async Task ExtractPostDetails_TriggersBackgroundWorkerAndSavesHtmlToBlobStorage() + { + // Arrange + var url = "https://example.com/blog/test-post-" + Guid.NewGuid(); + var extractRequest = new + { + url = url, + tags = "test", + category = "Test" + }; + + // Act - Call the API to extract metadata and save the post + var response = await _client.PostAsJsonAsync("/api/posts/extractPostDetails", extractRequest); + + // Assert API response is OK + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var post = await response.Content.ReadFromJsonAsync(); + post.Should().NotBeNull(); + var postId = post!.Id ?? post.RowKey; + postId.Should().NotBeNullOrEmpty(); + + // Since the extraction happens asynchronously in a BackgroundWorker, + // we poll the blob storage for a short time to verify the file was created. + var blobServiceClient = _factory.Services.GetRequiredService(); + var containerClient = blobServiceClient.GetBlobContainerClient("cleanedposts"); + var blobClient = containerClient.GetBlobClient($"{postId}.html"); + + // Wait up to 5 seconds for the background worker to process + bool blobExists = false; + for (int i = 0; i < 25; i++) + { + if (await blobClient.ExistsAsync()) + { + blobExists = true; + break; + } + await Task.Delay(200); + } + + blobExists.Should().BeTrue("HTML content should be processed by the background worker and saved to blob storage"); + + // Verify the content saved matches the fake content + var downloadResult = await blobClient.DownloadContentAsync(); + var content = downloadResult.Value.Content.ToString(); + content.Should().Contain(url); + } +} diff --git a/src/NoteBookmark.Api.Tests/Fixtures/FakePostParserClient.cs b/src/NoteBookmark.Api.Tests/Fixtures/FakePostParserClient.cs new file mode 100644 index 0000000..5dc2e2e --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Fixtures/FakePostParserClient.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace NoteBookmark.Api.Tests.Fixtures; + +public class FakePostParserClient : IPostParserClient +{ + public Task ExtractContentAsync(string url, CancellationToken cancellationToken = default) + { + // Return a mock HTML snippet for testing + return Task.FromResult($"
Extracted HTML content for {url}
"); + } +} diff --git a/src/NoteBookmark.Api.Tests/Fixtures/NoteBookmarkApiTestFactory.cs b/src/NoteBookmark.Api.Tests/Fixtures/NoteBookmarkApiTestFactory.cs index da53256..cd0686c 100644 --- a/src/NoteBookmark.Api.Tests/Fixtures/NoteBookmarkApiTestFactory.cs +++ b/src/NoteBookmark.Api.Tests/Fixtures/NoteBookmarkApiTestFactory.cs @@ -34,6 +34,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(new TableServiceClient(connectionString)); services.AddSingleton(new BlobServiceClient(connectionString)); } + + // Register FakePostParserClient for integration tests + services.AddSingleton(); }); } diff --git a/src/NoteBookmark.Api/IPostParserClient.cs b/src/NoteBookmark.Api/IPostParserClient.cs new file mode 100644 index 0000000..72507ab --- /dev/null +++ b/src/NoteBookmark.Api/IPostParserClient.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace NoteBookmark.Api; + +public interface IPostParserClient +{ + Task ExtractContentAsync(string url, CancellationToken cancellationToken = default); +} diff --git a/src/NoteBookmark.Api/PostEndpoints.cs b/src/NoteBookmark.Api/PostEndpoints.cs index 87c1bb2..66a8a7e 100644 --- a/src/NoteBookmark.Api/PostEndpoints.cs +++ b/src/NoteBookmark.Api/PostEndpoints.cs @@ -94,7 +94,11 @@ static Results SavePost(Post post, TableServiceClient tblClient, } return TypedResults.BadRequest(); } - static async Task, BadRequest>> ExtractPostDetails(ExtractPostRequest request, TableServiceClient tblClient, BlobServiceClient blobClient) + static async Task, BadRequest>> ExtractPostDetails( + ExtractPostRequest request, + TableServiceClient tblClient, + BlobServiceClient blobClient, + PostExtractionQueue queue) { var dataStorageService = new DataStorageService(tblClient, blobClient); @@ -105,6 +109,10 @@ static async Task, BadRequest>> ExtractPostDetails(ExtractPostR if (post != null) { dataStorageService.SavePost(post); + + // Queue background HTML extraction task + queue.QueueBackgroundWorkItem(new ExtractionTask(post.Id ?? post.RowKey, post.Url ?? decodeUrl)); + return TypedResults.Ok(post); } return TypedResults.BadRequest(); diff --git a/src/NoteBookmark.Api/PostExtractionBackgroundWorker.cs b/src/NoteBookmark.Api/PostExtractionBackgroundWorker.cs new file mode 100644 index 0000000..801f507 --- /dev/null +++ b/src/NoteBookmark.Api/PostExtractionBackgroundWorker.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.Storage.Blobs; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace NoteBookmark.Api; + +public class PostExtractionBackgroundWorker : BackgroundService +{ + private readonly PostExtractionQueue _queue; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public PostExtractionBackgroundWorker( + PostExtractionQueue queue, + IServiceProvider serviceProvider, + ILogger logger) + { + _queue = queue; + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Post Extraction Background Worker started."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var task = await _queue.DequeueAsync(stoppingToken); + _logger.LogInformation("Processing extraction for Post: {PostId}, URL: {Url}", task.PostId, task.Url); + + await ProcessExtractionAsync(task, stoppingToken); + } + catch (OperationCanceledException) + { + // Normal shutdown + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred executing background extraction task."); + } + } + + _logger.LogInformation("Post Extraction Background Worker stopped."); + } + + private async Task ProcessExtractionAsync(ExtractionTask task, CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var parserClient = scope.ServiceProvider.GetRequiredService(); + var blobServiceClient = scope.ServiceProvider.GetRequiredService(); + + try + { + var content = await parserClient.ExtractContentAsync(task.Url, cancellationToken); + if (string.IsNullOrEmpty(content)) + { + _logger.LogWarning("No content returned for URL: {Url}. Skipping blob upload.", task.Url); + return; + } + + var containerClient = blobServiceClient.GetBlobContainerClient("cleanedposts"); + await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + + var blobClient = containerClient.GetBlobClient($"{task.PostId}.html"); + + byte[] contentBytes = Encoding.UTF8.GetBytes(content); + using var stream = new MemoryStream(contentBytes); + + await blobClient.UploadAsync(stream, overwrite: true, cancellationToken: cancellationToken); + _logger.LogInformation("Successfully saved extracted HTML for Post {PostId} to Blob Storage.", task.PostId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process extraction for Post {PostId} / URL: {Url}", task.PostId, task.Url); + } + } +} diff --git a/src/NoteBookmark.Api/PostExtractionQueue.cs b/src/NoteBookmark.Api/PostExtractionQueue.cs new file mode 100644 index 0000000..b1698a5 --- /dev/null +++ b/src/NoteBookmark.Api/PostExtractionQueue.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace NoteBookmark.Api; + +public record ExtractionTask(string PostId, string Url); + +public class PostExtractionQueue +{ + private readonly Channel _queue; + + public PostExtractionQueue() + { + // Unbounded channel is simple and suitable for this task queue. + _queue = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + } + + public void QueueBackgroundWorkItem(ExtractionTask task) + { + ArgumentNullException.ThrowIfNull(task); + _queue.Writer.TryWrite(task); + } + + public async ValueTask DequeueAsync(CancellationToken cancellationToken) + { + return await _queue.Reader.ReadAsync(cancellationToken); + } +} diff --git a/src/NoteBookmark.Api/PostParserClient.cs b/src/NoteBookmark.Api/PostParserClient.cs new file mode 100644 index 0000000..2562eb9 --- /dev/null +++ b/src/NoteBookmark.Api/PostParserClient.cs @@ -0,0 +1,56 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace NoteBookmark.Api; + +public class PostParserClient : IPostParserClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public PostParserClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + // Configure base address or default headers if needed, but since URL is fully specified we can just configure it or call it directly. + if (_httpClient.BaseAddress == null) + { + _httpClient.BaseAddress = new Uri("https://azpostlight-parser.azurewebsites.net/"); + } + } + + public async Task ExtractContentAsync(string url, CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("Calling parser API for URL: {Url}", url); + var requestBody = new { url = url }; + var response = await _httpClient.PostAsJsonAsync("parser", requestBody, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Parser API returned error status: {StatusCode}", response.StatusCode); + return null; + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return result?.Content; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to extract content for URL: {Url}", url); + return null; + } + } + + private class ParserResponse + { + [JsonPropertyName("content")] + public string? Content { get; set; } + } +} diff --git a/src/NoteBookmark.Api/Program.cs b/src/NoteBookmark.Api/Program.cs index 6def1b2..55fd0f6 100644 --- a/src/NoteBookmark.Api/Program.cs +++ b/src/NoteBookmark.Api/Program.cs @@ -15,6 +15,11 @@ // Register data storage service builder.Services.AddScoped(); +// Register background extraction queue and worker +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + // Register AI settings provider builder.Services.AddScoped(); From aaa38800ed909212468ad4eff89e4def8bfdf05b Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 15 Aug 2026 09:35:52 -0400 Subject: [PATCH 09/25] feat: improve sync html download, always display read post button, and remove ILocalHtmlCache (#186) * feat: implement async background post content extraction to save HTML in blob storage * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache --- .../Tests/PostsTests.cs | 27 +------------------ .../AlwaysAvailableHtmlCache.cs | 8 ------ src/NoteBookmark.BlazorApp/Program.cs | 1 - .../Data/LocalHtmlStorageService.cs | 10 +++---- src/NoteBookmark.MauiApp/MauiProgram.cs | 2 -- .../Components/Pages/Posts.razor | 6 +---- src/NoteBookmark.SharedUI/ILocalHtmlCache.cs | 6 ----- 7 files changed, 7 insertions(+), 53 deletions(-) delete mode 100644 src/NoteBookmark.BlazorApp/AlwaysAvailableHtmlCache.cs delete mode 100644 src/NoteBookmark.SharedUI/ILocalHtmlCache.cs diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs index 67605b3..70ee8ea 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs @@ -39,7 +39,6 @@ public PostsTests() Services.AddSingleton(_dataServiceMock.Object); Services.AddSingleton(new Mock().Object); Services.AddSingleton(new Mock().Object); - Services.AddSingleton(new NoteBookmark.BlazorApp.AlwaysAvailableHtmlCache()); } [Fact] @@ -126,36 +125,12 @@ public void Posts_RendersEmptyState_WhenNoPostsReturned() cut.Markup.Should().Contain("Nothing to see here"); } -} -public sealed class PostsHtmlCacheTests : BunitContext -{ [Fact] - public void Posts_ChecksHtmlCacheWithPostId_WhenIdIsPresent() + public void Posts_ReadPostButton_IsAlwaysRendered() { - this.AddFluentUI(); - this.AddAuthorization().SetAuthorized("testuser"); - - var dataServiceMock = new Mock(); - dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync([ - new PostL { PartitionKey = "p", RowKey = "row-key-456", Id = "custom-id-123", Title = "Post With Id", Url = "https://example.com/id", Date_published = "2025-01-15T00:00:00", is_read = false } - ]); - 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); - - var htmlCacheMock = new Mock(); - htmlCacheMock.Setup(c => c.IsHtmlCached("custom-id-123")).Returns(true); - - Services.AddSingleton(dataServiceMock.Object); - Services.AddSingleton(new Mock().Object); - Services.AddSingleton(new Mock().Object); - Services.AddSingleton(htmlCacheMock.Object); - var cut = Render(); - htmlCacheMock.Verify(c => c.IsHtmlCached("custom-id-123"), Times.Once); cut.Markup.Should().Contain("Read post"); } } diff --git a/src/NoteBookmark.BlazorApp/AlwaysAvailableHtmlCache.cs b/src/NoteBookmark.BlazorApp/AlwaysAvailableHtmlCache.cs deleted file mode 100644 index 2bed86b..0000000 --- a/src/NoteBookmark.BlazorApp/AlwaysAvailableHtmlCache.cs +++ /dev/null @@ -1,8 +0,0 @@ -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 7699cbb..06f72ef 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -18,7 +18,6 @@ client.BaseAddress = new Uri("https+http://api"); }); builder.Services.AddTransient(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); builder.Services.AddScoped(); // Register server-side AI settings provider (direct database access, unmasked) diff --git a/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs b/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs index 30e7d53..7f4b08c 100644 --- a/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs +++ b/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs @@ -2,16 +2,18 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using NoteBookmark.SharedUI; namespace NoteBookmark.MauiApp.Data; -public class LocalHtmlStorageService(string baseDirectory) : ILocalHtmlStorageService, ILocalHtmlCache +public class LocalHtmlStorageService(string baseDirectory) : ILocalHtmlStorageService { private string FilePath(string postId) => Path.Combine(baseDirectory, $"{postId}.html"); public async Task SavePostHtmlAsync(string postId, string html) - => await File.WriteAllTextAsync(FilePath(postId), html); + { + Directory.CreateDirectory(baseDirectory); + await File.WriteAllTextAsync(FilePath(postId), html); + } public async Task GetPostHtmlAsync(string postId) { @@ -22,8 +24,6 @@ public async Task SavePostHtmlAsync(string postId, string html) 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); diff --git a/src/NoteBookmark.MauiApp/MauiProgram.cs b/src/NoteBookmark.MauiApp/MauiProgram.cs index 3c74ed6..d458a55 100644 --- a/src/NoteBookmark.MauiApp/MauiProgram.cs +++ b/src/NoteBookmark.MauiApp/MauiProgram.cs @@ -39,8 +39,6 @@ public static MauiHostingApp CreateMauiApp() 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.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index 7d375ee..3308e1d 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -8,7 +8,6 @@ @inject IToastService toastService @inject IDialogService DialogService @inject NavigationManager Navigation -@inject ILocalHtmlCache localHtmlCache @inject IUrlLauncher urlLauncher @implements IDisposable @@ -54,10 +53,7 @@ { } - @if (localHtmlCache.IsHtmlCached(context!.Id ?? context!.RowKey)) - { - - } + diff --git a/src/NoteBookmark.SharedUI/ILocalHtmlCache.cs b/src/NoteBookmark.SharedUI/ILocalHtmlCache.cs deleted file mode 100644 index 800ef45..0000000 --- a/src/NoteBookmark.SharedUI/ILocalHtmlCache.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace NoteBookmark.SharedUI; - -public interface ILocalHtmlCache -{ - bool IsHtmlCached(string postId); -} From 369139ced2d0a0a0bfd228e5309bde2f347a2f43 Mon Sep 17 00:00:00 2001 From: Frank Boucher <2404846+fboucher@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:45:03 -0400 Subject: [PATCH 10/25] feat: configure protected Azure Function Parser API and key propagation (#187) * feat: configure protected Azure Function Parser API and key propagation * bump: version to 1.4.0 --------- Co-authored-by: fboucher-os --- Directory.Build.props | 2 +- .../ResearchService.cs | 4 +- .../Services/PostParserClientTests.cs | 163 ++++++++++++++++++ src/NoteBookmark.Api/PostParserClient.cs | 24 ++- src/NoteBookmark.AppHost/AppHost.cs | 28 ++- src/NoteBookmark.AppHost/appsettings.json | 4 + .../NoteBookmark.MauiApp.csproj | 6 + 7 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index a218b45..328c501 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.3.2 + 1.4.0 net10.0 enable enable diff --git a/src/NoteBookmark.AIServices/ResearchService.cs b/src/NoteBookmark.AIServices/ResearchService.cs index 69ac93a..3b4d750 100644 --- a/src/NoteBookmark.AIServices/ResearchService.cs +++ b/src/NoteBookmark.AIServices/ResearchService.cs @@ -151,7 +151,9 @@ private async Task SaveToFile(string prefix, string responseContent) { string datetime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm"); string fileName = $"{prefix}_{datetime}.json"; - string folderPath = "Data"; + // Use the app's sandboxed data directory so this works on Android/iOS as well as desktop + string folderPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Data"); Directory.CreateDirectory(folderPath); string filePath = Path.Combine(folderPath, fileName); await File.WriteAllTextAsync(filePath, responseContent); diff --git a/src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs b/src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs new file mode 100644 index 0000000..dea9f8a --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq.Protected; + +namespace NoteBookmark.Api.Tests.Services; + +public class PostParserClientTests +{ + private readonly Mock _mockConfig; + private readonly Mock _mockHandler; + + public PostParserClientTests() + { + _mockConfig = new Mock(); + _mockHandler = new Mock(MockBehavior.Strict); + + // Default config setups + _mockConfig.Setup(c => c["Parser:BaseUrl"]).Returns((string?)null); + _mockConfig.Setup(c => c["Parser:ApiKey"]).Returns((string?)null); + } + + private PostParserClient CreateSut(HttpClient httpClient) => + new(httpClient, _mockConfig.Object, NullLogger.Instance); + + [Fact] + public async Task ExtractContentAsync_WithDefaults_CallsDefaultUrlWithoutApiKey() + { + // Arrange + var expectedUrl = "https://azpostlight-parser.azurewebsites.net/api/parser"; + var sourceUrl = "https://example.com/blog-post"; + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString() == expectedUrl && + !req.Headers.Contains("x-functions-key")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"content\":\"extracted blog content\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().Be("extracted blog content"); + _mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => req.RequestUri != null && req.RequestUri.ToString() == expectedUrl), + ItExpr.IsAny()); + } + + [Fact] + public async Task ExtractContentAsync_WithApiKey_SendsXFunctionsKeyHeader() + { + // Arrange + var expectedUrl = "https://azpostlight-parser.azurewebsites.net/api/parser"; + var sourceUrl = "https://example.com/blog-post"; + var apiKey = "test-api-key-123"; + + _mockConfig.Setup(c => c["Parser:ApiKey"]).Returns(apiKey); + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString() == expectedUrl && + req.Headers.Contains("x-functions-key") && + string.Join("", req.Headers.GetValues("x-functions-key")) == apiKey), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"content\":\"content with auth\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().Be("content with auth"); + } + + [Fact] + public async Task ExtractContentAsync_WithCustomUrl_CallsCustomUrl() + { + // Arrange + var customUrl = "https://my-custom-parser.com/api/parser"; + var sourceUrl = "https://example.com/blog-post"; + + _mockConfig.Setup(c => c["Parser:BaseUrl"]).Returns(customUrl); + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString() == customUrl), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"content\":\"custom url content\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().Be("custom url content"); + } + + [Fact] + public async Task ExtractContentAsync_ParserReturnsErrorCode_ReturnsNull() + { + // Arrange + var sourceUrl = "https://example.com/blog-post"; + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.InternalServerError + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().BeNull(); + } +} diff --git a/src/NoteBookmark.Api/PostParserClient.cs b/src/NoteBookmark.Api/PostParserClient.cs index 2562eb9..91f1507 100644 --- a/src/NoteBookmark.Api/PostParserClient.cs +++ b/src/NoteBookmark.Api/PostParserClient.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace NoteBookmark.Api; @@ -11,17 +12,14 @@ namespace NoteBookmark.Api; public class PostParserClient : IPostParserClient { private readonly HttpClient _httpClient; + private readonly IConfiguration _config; private readonly ILogger _logger; - public PostParserClient(HttpClient httpClient, ILogger logger) + public PostParserClient(HttpClient httpClient, IConfiguration config, ILogger logger) { _httpClient = httpClient; + _config = config; _logger = logger; - // Configure base address or default headers if needed, but since URL is fully specified we can just configure it or call it directly. - if (_httpClient.BaseAddress == null) - { - _httpClient.BaseAddress = new Uri("https://azpostlight-parser.azurewebsites.net/"); - } } public async Task ExtractContentAsync(string url, CancellationToken cancellationToken = default) @@ -30,7 +28,19 @@ public PostParserClient(HttpClient httpClient, ILogger logger) { _logger.LogInformation("Calling parser API for URL: {Url}", url); var requestBody = new { url = url }; - var response = await _httpClient.PostAsJsonAsync("parser", requestBody, cancellationToken); + + var endpoint = _config["Parser:BaseUrl"] ?? "https://azpostlight-parser.azurewebsites.net/api/parser"; + var apiKey = _config["Parser:ApiKey"]; + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Content = JsonContent.Create(requestBody); + + if (!string.IsNullOrEmpty(apiKey)) + { + request.Headers.Add("x-functions-key", apiKey); + } + + var response = await _httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index 531e157..024f1f8 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -5,6 +5,12 @@ var builder = DistributedApplication.CreateBuilder(args); +var parserUrl = builder.Configuration["Parser:BaseUrl"] + ?? Environment.GetEnvironmentVariable("PARSER_BASE_URL") + ?? "https://azpostlight-parser.azurewebsites.net/api/parser"; +var parserKey = builder.Configuration["Parser:ApiKey"] + ?? Environment.GetEnvironmentVariable("PARSER_API_KEY"); + // Load docker-compose environment var compose = builder.AddDockerComposeEnvironment("docker-env"); @@ -24,12 +30,19 @@ var tables = noteStorage.AddTables("nb-tables"); var blobs = noteStorage.AddBlobs("nb-blobs"); - var api = builder.AddProject("api") + var apiBuilder = builder.AddProject("api") .WithReference(tables) .WithReference(blobs) .WaitFor(tables) .WaitFor(blobs) - .PublishAsDockerComposeService((resource, service) => + .WithEnvironment("Parser__BaseUrl", parserUrl); + + if (!string.IsNullOrEmpty(parserKey)) + { + apiBuilder = apiBuilder.WithEnvironment("Parser__ApiKey", parserKey); + } + + var api = apiBuilder.PublishAsDockerComposeService((resource, service) => { service.ContainerName = "notebookmark-api"; }); @@ -58,12 +71,19 @@ var tables = noteStorage.AddTables("nb-tables"); var blobs = noteStorage.AddBlobs("nb-blobs"); - var api = builder.AddProject("api") + var apiBuilder = builder.AddProject("api") .WithReference(tables) .WithReference(blobs) .WaitFor(tables) .WaitFor(blobs) - .PublishAsDockerComposeService((resource, service) => + .WithEnvironment("Parser__BaseUrl", parserUrl); + + if (!string.IsNullOrEmpty(parserKey)) + { + apiBuilder = apiBuilder.WithEnvironment("Parser__ApiKey", parserKey); + } + + var api = apiBuilder.PublishAsDockerComposeService((resource, service) => { service.ContainerName = "notebookmark-api"; }); diff --git a/src/NoteBookmark.AppHost/appsettings.json b/src/NoteBookmark.AppHost/appsettings.json index 219b4a5..b3b4cb6 100644 --- a/src/NoteBookmark.AppHost/appsettings.json +++ b/src/NoteBookmark.AppHost/appsettings.json @@ -9,6 +9,10 @@ "AppSettings": { "REKA_API_KEY": "KEY_HERE" }, + "Parser": { + "BaseUrl": "https://azpostlight-parser.azurewebsites.net/api/parser", + "ApiKey": "KEY_HERE" + }, "Keycloak": { "Authority": "http://localhost:8080/realms/notebookmark", "ClientId": "notebookmark", diff --git a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj index 4e2da8e..fce28f8 100644 --- a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj +++ b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj @@ -97,8 +97,14 @@ + + + + None + + - 1.3.3 - 6 - 1.3.3 + 1.4.2 + 7 + 1.4.2 None diff --git a/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor index 57af22a..ff1005f 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor @@ -9,10 +9,16 @@ @(post?.Title ?? "Reading...") - - Back + + + Back + + Text size: + @(textSize)px + + @if (isLoading) @@ -51,6 +57,10 @@ + Back + Text size: @(textSize)px diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index 3308e1d..0dbebea 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -115,14 +115,22 @@ try { + isSyncing = true; await client.SyncAsync(); await LoadPosts(); - StateHasChanged(); } catch (Exception) { // Ignore background sync errors } + finally + { + isSyncing = false; + syncProgressStatus = string.Empty; + syncProgressCurrent = 0; + syncProgressTotal = 0; + StateHasChanged(); + } } private async Task OpenUrlInNewWindow(string? url) @@ -287,6 +295,10 @@ finally { isSyncing = false; + syncProgressStatus = string.Empty; + syncProgressCurrent = 0; + syncProgressTotal = 0; + StateHasChanged(); } } From 322848d0a3351a6190f401c49232bcfde2b4691f Mon Sep 17 00:00:00 2001 From: Frank Boucher <2404846+fboucher@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:18:52 -0400 Subject: [PATCH 16/25] ci: include version in android apk filename (#193) Co-authored-by: fboucher-os --- .github/workflows/build-android-apk.yml | 27 +++++++++++++++++++++++++ docs/maui-android-install.md | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-android-apk.yml b/.github/workflows/build-android-apk.yml index 9943b68..3c0d842 100644 --- a/.github/workflows/build-android-apk.yml +++ b/.github/workflows/build-android-apk.yml @@ -48,6 +48,33 @@ jobs: ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + - name: Rename APK with version + run: | + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + VERSION="${{ github.ref_name }}" + else + VERSION_NUM=$(grep -oPm1 "(?<=)[^<]+" src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj || true) + if [ -z "$VERSION_NUM" ]; then + VERSION_NUM=$(grep -oPm1 "(?<=)[^<]+" Directory.Build.props) + fi + VERSION="v${VERSION_NUM#v}" + fi + echo "Using version: $VERSION" + + PUBLISH_DIR="src/NoteBookmark.MauiApp/bin/Release/net10.0-android/publish" + for apk in "$PUBLISH_DIR"/*.apk; do + if [ -f "$apk" ]; then + filename=$(basename "$apk") + if [[ "$filename" == *"-Signed.apk" ]]; then + new_filename="${filename%-Signed.apk}-${VERSION}-Signed.apk" + else + new_filename="${filename%.apk}-${VERSION}.apk" + fi + echo "Renaming $apk to $PUBLISH_DIR/$new_filename" + mv "$apk" "$PUBLISH_DIR/$new_filename" + fi + done + - name: Upload APK artifact uses: actions/upload-artifact@v4 with: diff --git a/docs/maui-android-install.md b/docs/maui-android-install.md index 101cefd..8aa9ebf 100644 --- a/docs/maui-android-install.md +++ b/docs/maui-android-install.md @@ -9,7 +9,7 @@ This guide walks you through installing (often called "sideloading") the NoteBoo 2. Tap on the **Actions** tab at the top. 3. Select the latest successful **Build Android APK** run. 4. Scroll down to the **Artifacts** section and download the **`notebookmark-android-apk`** ZIP file. -5. Extract the ZIP file to retrieve the **`.apk`** installer file (e.g., `NoteBookmark.MauiApp.apk`). +5. Extract the ZIP file to retrieve the **`.apk`** installer file (e.g., `c5m.notebookmark.mauiapp-v1.4.2-Signed.apk`). --- From 305d527dc7ca925a552c390032b6593f6560cb88 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 19 Sep 2026 09:09:10 -0400 Subject: [PATCH 17/25] Fix document sync stuck and improve sync progress and offline fallback (fixes #196) (#199) --- .../Tests/PostsTests.cs | 30 +++++ .../SyncProgressEventArgs.cs | 4 +- .../SyncServiceTests.cs | 110 +++++++++++++++ .../Data/OfflineDataService.cs | 47 +++++-- src/NoteBookmark.MauiApp/Data/SyncService.cs | 125 ++++++++++++++++-- .../Components/Pages/Posts.razor | 30 +++-- src/NoteBookmark.SharedUI/IDataService.cs | 1 + src/NoteBookmark.SharedUI/PostNoteClient.cs | 1 + 8 files changed, 315 insertions(+), 33 deletions(-) diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs index 6c3c6db..c4e5ec9 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs @@ -162,5 +162,35 @@ public void Posts_DisplaysCleaningStatus_WhenSyncProgressChangedFired() cut.Markup.Should().Contain("Cleaning..."); } + + [Fact] + public void Posts_SyncProgressChanged_WhenIsComplete_ReloadsPosts() + { + var cut = Render(); + + _dataServiceMock.Invocations.Clear(); + + cut.InvokeAsync(() => + { + _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Synchronization complete!", isComplete: true)); + }); + + _dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce); + } + + [Fact] + public void Posts_SyncButton_DisabledAndLoadingReflectsIsSyncing() + { + _dataServiceMock.SetupGet(s => s.CanSync).Returns(true); + _dataServiceMock.SetupGet(s => s.IsSyncing).Returns(true); + + var cut = Render(); + + var buttons = cut.FindComponents(); + var syncButton = buttons.FirstOrDefault(b => b.Instance.Title == "Sync posts and comments"); + syncButton.Should().NotBeNull(); + syncButton!.Instance.Disabled.Should().BeTrue(); + syncButton.Instance.Loading.Should().BeTrue(); + } } diff --git a/src/NoteBookmark.Domain/SyncProgressEventArgs.cs b/src/NoteBookmark.Domain/SyncProgressEventArgs.cs index fc7399a..ee7be29 100644 --- a/src/NoteBookmark.Domain/SyncProgressEventArgs.cs +++ b/src/NoteBookmark.Domain/SyncProgressEventArgs.cs @@ -7,12 +7,14 @@ public class SyncProgressEventArgs : EventArgs public int Current { get; } public int Total { get; } public string Status { get; } + public bool IsComplete { get; } public double Percentage => Total > 0 ? (double)Current / Total * 100 : 0; - public SyncProgressEventArgs(int current, int total, string status) + public SyncProgressEventArgs(int current, int total, string status, bool isComplete = false) { Current = current; Total = total; Status = status; + IsComplete = isComplete; } } diff --git a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs index 1ad1706..4204ab5 100644 --- a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs +++ b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs @@ -470,6 +470,116 @@ public async Task SyncAsync_ShouldRaiseSyncProgressChanged_WhenDownloadingPostHt progressEvents.Should().Contain(e => e.Status == "Downloading 1 of 2 posts..." && e.Current == 1 && e.Total == 2); progressEvents.Should().Contain(e => e.Status == "Downloading 2 of 2 posts..." && e.Current == 2 && e.Total == 2); progressEvents.Last().Status.Should().Be("Synchronization complete!"); + progressEvents.Last().IsComplete.Should().BeTrue(); + } + + [Fact] + public async Task PullPhase_ReadPosts_ShouldNotCallGetPost_AndShouldSaveDirectly() + { + var readPostL = new PostL + { + Id = "read1", + RowKey = "read1", + PartitionKey = "pk", + Title = "Read Post", + is_read = true, + DateModified = DateTime.UtcNow + }; + + _localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List()); + _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)).ReturnsAsync(new List { readPostL }); + _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + + await _sut.SyncAsync(); + + // GetPost should NOT be called for read posts + _apiClientMock.Verify(c => c.GetPost("read1"), Times.Never); + _localDataServiceMock.Verify(c => c.SavePostAsync(It.Is(p => p.Id == "read1" && p.is_read == true), false), Times.Once); + } + + [Fact] + public async Task PullPhase_UnreadPost_WhenGetPostFails_ShouldFallbackToBasicPost() + { + var unreadPostL = new PostL + { + Id = "unread1", + RowKey = "unread1", + PartitionKey = "pk", + Title = "Unread Post", + is_read = false, + DateModified = DateTime.UtcNow + }; + + _localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List()); + _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)).ReturnsAsync(new List { unreadPostL }); + _apiClientMock.Setup(c => c.GetPost("unread1")).ThrowsAsync(new System.Net.Http.HttpRequestException("404 Not Found")); + _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + + await _sut.SyncAsync(); + + // Should fall back and save basic post without throwing + _localDataServiceMock.Verify(c => c.SavePostAsync(It.Is(p => p.Id == "unread1" && p.Title == "Unread Post"), false), Times.Once); + } + + [Fact] + public async Task PullPhase_ShouldReportProgress_WhenPullingPosts() + { + var postL1 = new PostL { Id = "p1", RowKey = "p1", PartitionKey = "pk", Title = "Post 1", is_read = true, DateModified = DateTime.UtcNow }; + var postL2 = new PostL { Id = "p2", RowKey = "p2", PartitionKey = "pk", Title = "Post 2", is_read = true, DateModified = DateTime.UtcNow }; + + _localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List()); + _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)).ReturnsAsync(new List { postL1, postL2 }); + _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + + var progressEvents = new List(); + _sut.SyncProgressChanged += (sender, args) => progressEvents.Add(args); + + await _sut.SyncAsync(); + + progressEvents.Should().Contain(e => e.Status == "Pulling 0 of 2 posts..." && e.Current == 0 && e.Total == 2); + progressEvents.Should().Contain(e => e.Status == "Pulling 1 of 2 posts..." && e.Current == 1 && e.Total == 2); + progressEvents.Should().Contain(e => e.Status == "Pulling 2 of 2 posts..." && e.Current == 2 && e.Total == 2); + } + + [Fact] + public async Task SyncAsync_WhenFails_ShouldRaiseSyncProgressChangedWithIsCompleteAndFailureStatus() + { + _localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ThrowsAsync(new InvalidOperationException("DB error")); + + var progressEvents = new List(); + _sut.SyncProgressChanged += (sender, args) => progressEvents.Add(args); + + Func act = async () => await _sut.SyncAsync(); + await act.Should().ThrowAsync(); + + progressEvents.Should().NotBeEmpty(); + var lastEvent = progressEvents.Last(); + lastEvent.IsComplete.Should().BeTrue(); + lastEvent.Status.Should().Contain("Sync failed: DB error"); + } + + [Fact] + public async Task IsSyncing_ShouldReflectActiveSyncTask() + { + var tcs = new TaskCompletionSource>(); + _localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).Returns(tcs.Task); + + _sut.IsSyncing.Should().BeFalse(); + + var syncTask = _sut.SyncAsync(); + + _sut.IsSyncing.Should().BeTrue(); + + tcs.SetResult(new List()); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + + await syncTask; + + _sut.IsSyncing.Should().BeFalse(); } } diff --git a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs index 1136a47..7e38d03 100644 --- a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs @@ -178,9 +178,16 @@ public async Task DeleteNote(string noteId) { if (IsOnline) { - var post = await apiClient.GetPost(id); - if (post != null) await localDataService.SavePostAsync(post); - return post; + try + { + var post = await apiClient.GetPost(id); + if (post != null) await localDataService.SavePostAsync(post); + return post; + } + catch + { + return await localDataService.GetPostAsync(id); + } } else { @@ -243,6 +250,34 @@ public async Task ExtractPostDetailsAndSave(string url) return false; // Can't extract offline } + public async Task GetPostHtmlAsync(string postId) + { + var localHtml = await localHtmlStorageService.GetPostHtmlAsync(postId); + if (!string.IsNullOrEmpty(localHtml)) + { + return localHtml; + } + + if (IsOnline) + { + try + { + var remoteHtml = await apiClient.GetPostHtmlAsync(postId); + if (!string.IsNullOrEmpty(remoteHtml)) + { + await localHtmlStorageService.SavePostHtmlAsync(postId, remoteHtml); + return remoteHtml; + } + } + catch + { + // Fall back to null if remote fetch fails + } + } + + return null; + } + public async Task DeletePost(string id) { if (IsOnline) @@ -273,17 +308,13 @@ 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 event EventHandler? SyncProgressChanged { add => syncService.SyncProgressChanged += value; remove => syncService.SyncProgressChanged -= value; } + public bool IsSyncing => syncService.IsSyncing; public bool IsOffline => connectivity.NetworkAccess != NetworkAccess.Internet; public bool CanSync => true; diff --git a/src/NoteBookmark.MauiApp/Data/SyncService.cs b/src/NoteBookmark.MauiApp/Data/SyncService.cs index 495b749..a82698b 100644 --- a/src/NoteBookmark.MauiApp/Data/SyncService.cs +++ b/src/NoteBookmark.MauiApp/Data/SyncService.cs @@ -27,17 +27,39 @@ public class SyncService( ILocalHtmlStorageService localHtmlStorageService) : ISyncService { private const string LastSyncTimestampKey = "LastSyncTimestamp"; - private bool _isSyncing; + private readonly object _syncLock = new(); + private Task? _currentSyncTask; + + public bool IsSyncing + { + get + { + lock (_syncLock) + { + return _currentSyncTask != null && !_currentSyncTask.IsCompleted; + } + } + } - public bool IsSyncing => _isSyncing; public event EventHandler? ConflictDetected; public event EventHandler? SyncProgressChanged; - public async Task SyncAsync() + public Task SyncAsync() { - if (_isSyncing) return; + lock (_syncLock) + { + if (_currentSyncTask != null && !_currentSyncTask.IsCompleted) + { + return _currentSyncTask; + } - _isSyncing = true; + _currentSyncTask = DoSyncAsync(); + return _currentSyncTask; + } + } + + private async Task DoSyncAsync() + { try { SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Starting synchronization...")); @@ -57,11 +79,13 @@ public async Task SyncAsync() await SyncHtmlAsync(); await SetPreferenceAsync(LastSyncTimestampKey, DateTime.UtcNow.ToString("O")); - SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Synchronization complete!")); + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Synchronization complete!", isComplete: true)); } - finally + catch (Exception ex) { - _isSyncing = false; + logger.LogError(ex, "Synchronization failed."); + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, $"Sync failed: {ex.Message}", isComplete: true)); + throw; } } @@ -191,6 +215,8 @@ private async Task PullAsync(DateTime? lastSync) // 2. Any post that was deleted on the online database while offline should be deleted locally. var localPosts = await localDataService.GetPostsAsync() ?? new List(); + var localPostMap = localPosts.ToDictionary(p => p.Id ?? p.RowKey); + foreach (var localPost in localPosts) { var id = localPost.Id ?? localPost.RowKey; @@ -198,26 +224,97 @@ private async Task PullAsync(DateTime? lastSync) { await localDataService.DeletePostAsync(id, isPendingSync: false); await localDataService.MarkSyncedAsync(id, isPost: true); + localPostMap.Remove(id); } } // 3. Pull new/modified posts + var postsToPull = new List(); foreach (var remotePostL in allRemotePosts) { var id = remotePostL.Id ?? remotePostL.RowKey; - var localPost = await localDataService.GetPostAsync(id); - if (localPost is null || remotePostL.DateModified > localPost.DateModified) + if (!localPostMap.TryGetValue(id, out var lp)) + { + lp = await localDataService.GetPostAsync(id); + } + + if (lp is null || remotePostL.DateModified > lp.DateModified) + { + postsToPull.Add(remotePostL); + } + } + + if (postsToPull.Count > 0) + { + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, postsToPull.Count, $"Pulling 0 of {postsToPull.Count} posts...")); + + for (int i = 0; i < postsToPull.Count; i++) { - var fullPost = await apiClient.GetPost(id); - if (fullPost is not null) + var remotePostL = postsToPull[i]; + var id = remotePostL.Id ?? remotePostL.RowKey; + + Post postToSave; + if (remotePostL.is_read == true) { - await localDataService.SavePostAsync(fullPost, isPendingSync: false); + postToSave = new Post + { + Id = id, + RowKey = remotePostL.RowKey, + PartitionKey = remotePostL.PartitionKey, + Title = remotePostL.Title, + Url = remotePostL.Url, + Date_published = remotePostL.Date_published, + Excerpt = remotePostL.Excerpt, + is_read = remotePostL.is_read, + DateModified = remotePostL.DateModified + }; } + else + { + try + { + var fullPost = await apiClient.GetPost(id); + postToSave = fullPost ?? new Post + { + Id = id, + RowKey = remotePostL.RowKey, + PartitionKey = remotePostL.PartitionKey, + Title = remotePostL.Title, + Url = remotePostL.Url, + Date_published = remotePostL.Date_published, + Excerpt = remotePostL.Excerpt, + is_read = remotePostL.is_read, + DateModified = remotePostL.DateModified + }; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to retrieve full post for {PostId}, saving summary metadata", id); + postToSave = new Post + { + Id = id, + RowKey = remotePostL.RowKey, + PartitionKey = remotePostL.PartitionKey, + Title = remotePostL.Title, + Url = remotePostL.Url, + Date_published = remotePostL.Date_published, + Excerpt = remotePostL.Excerpt, + is_read = remotePostL.is_read, + DateModified = remotePostL.DateModified + }; + } + } + + await localDataService.SavePostAsync(postToSave, isPendingSync: false); + localPostMap[id] = postToSave; + + int current = i + 1; + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(current, postsToPull.Count, $"Pulling {current} of {postsToPull.Count} posts...")); } } // 4. Pull notes modified since lastSync - var remoteNotes = await apiClient.GetNotesModifiedAfter(lastSync ?? DateTime.MinValue); + var remoteNotes = await apiClient.GetNotesModifiedAfter(lastSync ?? DateTime.MinValue) ?? new List(); if (remoteNotes.Any()) { var pendingNotes = await localDataService.GetPendingSyncNotesAsync(); diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index 0dbebea..bcf310b 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -21,10 +21,10 @@ @if (client.CanSync) { - Sync + Sync } - @if (isSyncing || !string.IsNullOrEmpty(syncProgressStatus)) + @if (isSyncing || client.IsSyncing || !string.IsNullOrEmpty(syncProgressStatus)) { @@ -126,9 +126,6 @@ finally { isSyncing = false; - syncProgressStatus = string.Empty; - syncProgressCurrent = 0; - syncProgressTotal = 0; StateHasChanged(); } } @@ -279,7 +276,7 @@ private async Task SyncNow() { - if (isSyncing) return; + if (isSyncing || client.IsSyncing) return; isSyncing = true; try { @@ -295,9 +292,6 @@ finally { isSyncing = false; - syncProgressStatus = string.Empty; - syncProgressCurrent = 0; - syncProgressTotal = 0; StateHasChanged(); } } @@ -311,12 +305,28 @@ private void OnSyncProgressChanged(object? sender, SyncProgressEventArgs e) { - InvokeAsync(() => + InvokeAsync(async () => { syncProgressCurrent = e.Current; syncProgressTotal = e.Total; syncProgressStatus = e.Status; StateHasChanged(); + + if (e.IsComplete) + { + isSyncing = false; + await LoadPosts(); + StateHasChanged(); + + await Task.Delay(2500); + if (!client.IsSyncing && syncProgressStatus == e.Status) + { + syncProgressStatus = string.Empty; + syncProgressCurrent = 0; + syncProgressTotal = 0; + StateHasChanged(); + } + } }); } diff --git a/src/NoteBookmark.SharedUI/IDataService.cs b/src/NoteBookmark.SharedUI/IDataService.cs index 0de89de..b5a0887 100644 --- a/src/NoteBookmark.SharedUI/IDataService.cs +++ b/src/NoteBookmark.SharedUI/IDataService.cs @@ -26,6 +26,7 @@ public interface IDataService Task GetPostHtmlAsync(string postId); Task SyncAsync(); event System.EventHandler? SyncProgressChanged; + bool IsSyncing { get; } bool IsOffline { get; } bool CanSync { get; } } diff --git a/src/NoteBookmark.SharedUI/PostNoteClient.cs b/src/NoteBookmark.SharedUI/PostNoteClient.cs index 4e3a493..b0245a8 100644 --- a/src/NoteBookmark.SharedUI/PostNoteClient.cs +++ b/src/NoteBookmark.SharedUI/PostNoteClient.cs @@ -210,6 +210,7 @@ public async Task> GetNotesModifiedAfter(DateTime modifiedAfter) public Task SyncAsync() => Task.CompletedTask; public event EventHandler? SyncProgressChanged { add { } remove { } } + public bool IsSyncing => false; public bool IsOffline => false; public bool CanSync => false; } From d51dd46991bcab4c375da104480a1d20e279f54d Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 19 Sep 2026 09:09:38 -0400 Subject: [PATCH 18/25] fix: constrain reader text size slider and add step buttons (fixes #195) (#198) --- .../Tests/PostReaderTests.cs | 116 +++++++++++++++++- .../Components/Pages/PostReader.razor | 61 ++++++++- 2 files changed, 169 insertions(+), 8 deletions(-) diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs index 382a6eb..3541174 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs @@ -34,7 +34,7 @@ public PostReaderTests() } [Fact] - public void PostReader_RendersTitleAndContentAndSlidersAndBackButtonsAtTopAndBottom() + public void PostReader_RendersTitleAndContentAndSlidersAndButtonsAtTopAndBottom() { var cut = Render(ps => ps.Add(p => p.PostId, "p1")); @@ -46,15 +46,25 @@ public void PostReader_RendersTitleAndContentAndSlidersAndBackButtonsAtTopAndBot var sliders = cut.FindComponents>(); sliders.Should().HaveCount(2); - sliders[0].Instance.Min.Should().Be(8); - sliders[0].Instance.Max.Should().Be(56); - sliders[1].Instance.Min.Should().Be(8); - sliders[1].Instance.Max.Should().Be(56); + sliders[0].Instance.Min.Should().Be(12); + sliders[0].Instance.Max.Should().Be(25); + sliders[1].Instance.Min.Should().Be(12); + sliders[1].Instance.Max.Should().Be(25); var backButtons = cut.FindComponents() .Where(b => b.Instance.Title == "Back to posts") .ToList(); backButtons.Should().HaveCount(2); + + var decreaseButtons = cut.FindComponents() + .Where(b => b.Instance.Title == "Decrease text size") + .ToList(); + decreaseButtons.Should().HaveCount(2); + + var increaseButtons = cut.FindComponents() + .Where(b => b.Instance.Title == "Increase text size") + .ToList(); + increaseButtons.Should().HaveCount(2); } [Fact] @@ -83,4 +93,100 @@ public void PostReader_BottomSliderValueChange_UpdatesContentFontSize() var contentDivAfter = cut.Find("div.reader-content"); contentDivAfter.GetAttribute("style").Should().Contain("font-size: 20px;"); } + + [Fact] + public void PostReader_TopSliderValueChange_ClampsOutOfBounds() + { + var cut = Render(ps => ps.Add(p => p.PostId, "p1")); + + var sliders = cut.FindComponents>(); + cut.InvokeAsync(() => sliders[0].Instance.ValueChanged.InvokeAsync(5)); + + var contentDivAfterMin = cut.Find("div.reader-content"); + contentDivAfterMin.GetAttribute("style").Should().Contain("font-size: 12px;"); + + cut.InvokeAsync(() => sliders[0].Instance.ValueChanged.InvokeAsync(50)); + + var contentDivAfterMax = cut.Find("div.reader-content"); + contentDivAfterMax.GetAttribute("style").Should().Contain("font-size: 25px;"); + } + + [Fact] + public void PostReader_TopButtons_IncrementAndDecrement_UpdatesFontSize() + { + var cut = Render(ps => ps.Add(p => p.PostId, "p1")); + + var decreaseButton = cut.FindComponents() + .First(b => b.Instance.Title == "Decrease text size"); + var increaseButton = cut.FindComponents() + .First(b => b.Instance.Title == "Increase text size"); + + // Initial text size is 16px + cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 16px;"); + + // Increment to 17px + cut.InvokeAsync(() => increaseButton.Find("fluent-button").Click()); + cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 17px;"); + + // Decrement back to 16px + cut.InvokeAsync(() => decreaseButton.Find("fluent-button").Click()); + cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 16px;"); + } + + [Fact] + public void PostReader_BottomButtons_IncrementAndDecrement_UpdatesFontSize() + { + var cut = Render(ps => ps.Add(p => p.PostId, "p1")); + + var decreaseButtons = cut.FindComponents() + .Where(b => b.Instance.Title == "Decrease text size") + .ToList(); + var increaseButtons = cut.FindComponents() + .Where(b => b.Instance.Title == "Increase text size") + .ToList(); + + // Use bottom buttons (index 1) + var bottomIncrease = increaseButtons[1]; + var bottomDecrease = decreaseButtons[1]; + + // Increment from 16 to 17 + cut.InvokeAsync(() => bottomIncrease.Find("fluent-button").Click()); + cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 17px;"); + + // Decrement back from 17 to 16 + cut.InvokeAsync(() => bottomDecrease.Find("fluent-button").Click()); + cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 16px;"); + } + + [Fact] + public void PostReader_Buttons_DisabledAtBoundaries() + { + var cut = Render(ps => ps.Add(p => p.PostId, "p1")); + + var sliders = cut.FindComponents>(); + + // Set to minimum (12) + cut.InvokeAsync(() => sliders[0].Instance.ValueChanged.InvokeAsync(12)); + + var decreaseButtonsAtMin = cut.FindComponents() + .Where(b => b.Instance.Title == "Decrease text size") + .ToList(); + decreaseButtonsAtMin.Should().OnlyContain(b => b.Instance.Disabled == true); + + // Clicking decrease at min does not go below 12 + cut.InvokeAsync(() => decreaseButtonsAtMin[0].Find("fluent-button").Click()); + cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 12px;"); + + // Set to maximum (25) + cut.InvokeAsync(() => sliders[0].Instance.ValueChanged.InvokeAsync(25)); + + var increaseButtonsAtMax = cut.FindComponents() + .Where(b => b.Instance.Title == "Increase text size") + .ToList(); + increaseButtonsAtMax.Should().OnlyContain(b => b.Instance.Disabled == true); + + // Clicking increase at max does not exceed 25 + cut.InvokeAsync(() => increaseButtonsAtMax[0].Find("fluent-button").Click()); + cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 25px;"); + } } diff --git a/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor index ff1005f..a870d4f 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor @@ -18,7 +18,19 @@ Text size: @(textSize)px - + + + + + @if (isLoading) @@ -64,19 +76,62 @@ Text size: @(textSize)px - + + + + + } @code { + public const int MinTextSize = 12; + public const int MaxTextSize = 25; + [Parameter] public string PostId { get; set; } = string.Empty; private Post? post; private string? htmlContent; private bool isLoading = true; - private int textSize = 16; + private int _textSize = 16; + private int textSize + { + get => _textSize; + set + { + var clamped = Math.Clamp(value, MinTextSize, MaxTextSize); + if (_textSize != clamped) + { + _textSize = clamped; + } + } + } + + private void DecreaseTextSize() + { + if (textSize > MinTextSize) + { + textSize--; + } + } + + private void IncreaseTextSize() + { + if (textSize < MaxTextSize) + { + textSize++; + } + } protected override async Task OnInitializedAsync() { From 239cbc90317b626bf47a34003b0263eee1b45d57 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 19 Sep 2026 09:48:14 -0400 Subject: [PATCH 19/25] feat: Add markdown saving for reading notes and bump version to 1.4.3 --- src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj | 6 +++++- src/NoteBookmark.MauiApp/Data/OfflineDataService.cs | 3 ++- src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj | 6 +++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj b/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj index 7519709..ce78b0a 100644 --- a/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj +++ b/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj @@ -1,5 +1,9 @@ - + + + 1.4.3 + + diff --git a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs index 7e38d03..5834142 100644 --- a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs @@ -173,7 +173,8 @@ public async Task DeleteNote(string noteId) public Task CreateReadingNotes() => apiClient.CreateReadingNotes(); public Task GetReadingNotes(string number) => apiClient.GetReadingNotes(number); public Task SaveReadingNotes(ReadingNotes readingNotes) => apiClient.SaveReadingNotes(readingNotes); - + public Task SaveReadingNotesMarkdown(string markdown, string number) => apiClient.SaveReadingNotesMarkdown(markdown, number); + public async Task GetPost(string id) { if (IsOnline) diff --git a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj index 680d90d..b4140b5 100644 --- a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj +++ b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj @@ -44,9 +44,9 @@ c5m.notebookmark.mauiapp - 1.4.2 - 7 - 1.4.2 + 1.4.3 + 8 + 1.4.3 None From 47f9e5900145782bc3edaa53d3d7251833858db7 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 19 Sep 2026 10:53:27 -0400 Subject: [PATCH 20/25] feat: add MAUI storage tab (#200) * feat: add MAUI storage tab for offline posts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: refine MAUI storage controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: highlight storage actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LocalHtmlStorageServiceTests.cs | 41 ++++ .../NoteBookmark.MauiApp.Tests.csproj | 2 + .../StorageServiceTests.cs | 69 ++++++ .../Components/Layout/NavMenu.razor | 1 + .../Components/Pages/Storage.razor | 232 ++++++++++++++++++ .../Data/StorageService.cs | 66 +++++ src/NoteBookmark.MauiApp/MauiProgram.cs | 1 + 7 files changed, 412 insertions(+) create mode 100644 src/NoteBookmark.MauiApp.Tests/LocalHtmlStorageServiceTests.cs create mode 100644 src/NoteBookmark.MauiApp.Tests/StorageServiceTests.cs create mode 100644 src/NoteBookmark.MauiApp/Components/Pages/Storage.razor create mode 100644 src/NoteBookmark.MauiApp/Data/StorageService.cs diff --git a/src/NoteBookmark.MauiApp.Tests/LocalHtmlStorageServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/LocalHtmlStorageServiceTests.cs new file mode 100644 index 0000000..4325075 --- /dev/null +++ b/src/NoteBookmark.MauiApp.Tests/LocalHtmlStorageServiceTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using NoteBookmark.MauiApp.Data; + +namespace NoteBookmark.MauiApp.Tests; + +public class LocalHtmlStorageServiceTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), $"notebookmark-{Guid.NewGuid():N}"); + + [Fact] + public async Task SaveAndReadPostHtml_ShouldPersistContent() + { + var service = new LocalHtmlStorageService(_directory); + + await service.SavePostHtmlAsync("post-1", "

Hello

"); + + service.IsPostHtmlCached("post-1").Should().BeTrue(); + (await service.GetPostHtmlAsync("post-1")).Should().Be("

Hello

"); + service.GetCachedPostIds().Should().ContainSingle("post-1"); + } + + [Fact] + public async Task RemovePostHtml_ShouldRemoveCachedContent() + { + var service = new LocalHtmlStorageService(_directory); + await service.SavePostHtmlAsync("post-1", "

Hello

"); + + service.RemovePostHtml("post-1"); + + service.IsPostHtmlCached("post-1").Should().BeFalse(); + (await service.GetPostHtmlAsync("post-1")).Should().BeNull(); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj index 2c68b45..5477005 100644 --- a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj +++ b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj @@ -35,6 +35,8 @@ + + diff --git a/src/NoteBookmark.MauiApp.Tests/StorageServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/StorageServiceTests.cs new file mode 100644 index 0000000..a9dc067 --- /dev/null +++ b/src/NoteBookmark.MauiApp.Tests/StorageServiceTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using Moq; +using NoteBookmark.Domain; +using NoteBookmark.MauiApp.Data; +using NoteBookmark.SharedUI; + +namespace NoteBookmark.MauiApp.Tests; + +public class StorageServiceTests +{ + private readonly Mock _dataService = new(); + private readonly Mock _localStorage = new(); + private readonly StorageService _sut; + + public StorageServiceTests() + { + _sut = new StorageService(_dataService.Object, _localStorage.Object); + } + + [Fact] + public async Task DownloadPostAsync_ShouldDownloadMissingContent() + { + _localStorage.SetupSequence(s => s.IsPostHtmlCached("post-1")) + .Returns(false) + .Returns(false) + .Returns(true); + _dataService.Setup(s => s.GetPostHtmlAsync("post-1")).ReturnsAsync("

Post

"); + + var result = await _sut.DownloadPostAsync("post-1"); + + result.Should().BeTrue(); + _dataService.Verify(s => s.GetPostHtmlAsync("post-1"), Times.Once); + _localStorage.Verify(s => s.SavePostHtmlAsync("post-1", "

Post

"), Times.Once); + } + + [Fact] + public async Task DownloadPostAsync_ShouldNotDownloadCachedContent() + { + _localStorage.Setup(s => s.IsPostHtmlCached("post-1")).Returns(true); + + var result = await _sut.DownloadPostAsync("post-1"); + + result.Should().BeTrue(); + _dataService.Verify(s => s.GetPostHtmlAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DownloadPostAsync_ShouldShareAnInProgressDownload() + { + var gate = new TaskCompletionSource(); + _localStorage.Setup(s => s.IsPostHtmlCached("post-1")).Returns(false); + _dataService.Setup(s => s.GetPostHtmlAsync("post-1")).Returns(gate.Task); + + var first = _sut.DownloadPostAsync("post-1"); + var second = _sut.DownloadPostAsync("post-1"); + gate.SetResult("

Post

"); + + (await Task.WhenAll(first, second)).Should().OnlyContain(result => !result); + _dataService.Verify(s => s.GetPostHtmlAsync("post-1"), Times.Once); + } + + [Fact] + public void DeletePost_ShouldRemoveLocalContent() + { + _sut.DeletePost("post-1"); + + _localStorage.Verify(s => s.RemovePostHtml("post-1"), Times.Once); + } +} diff --git a/src/NoteBookmark.MauiApp/Components/Layout/NavMenu.razor b/src/NoteBookmark.MauiApp/Components/Layout/NavMenu.razor index 9f79ad7..024e0c3 100644 --- a/src/NoteBookmark.MauiApp/Components/Layout/NavMenu.razor +++ b/src/NoteBookmark.MauiApp/Components/Layout/NavMenu.razor @@ -18,6 +18,7 @@ Summaries Generate Summary Search + Storage App Settings Server Settings diff --git a/src/NoteBookmark.MauiApp/Components/Pages/Storage.razor b/src/NoteBookmark.MauiApp/Components/Pages/Storage.razor new file mode 100644 index 0000000..d6fc441 --- /dev/null +++ b/src/NoteBookmark.MauiApp/Components/Pages/Storage.razor @@ -0,0 +1,232 @@ +@page "/storage" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@using NoteBookmark.Domain +@using NoteBookmark.SharedUI +@using Microsoft.FluentUI.AspNetCore.Components +@inject IDataService DataService +@inject IStorageService StorageService +@inject IToastService ToastService +@implements IDisposable + +Storage + +

Storage

+ + + + Sync + + + @if (isSyncing || DataService.IsSyncing || !string.IsNullOrEmpty(syncProgressStatus)) + { + + + @if (!string.IsNullOrEmpty(syncProgressStatus)) + { + @syncProgressStatus + } + + } + + @if (isLoading) + { +

Loading unread posts...

+ } + else if (posts.Count == 0) + { +

No unread posts.

+ } + else + { + + + @{ + var postId = context!.Id ?? context.RowKey; + } + + + + + + @{ + var postId = context!.Id ?? context.RowKey; + } + @if (IsDownloading(postId)) + { + + } + else + { + @(IsCached(postId) ? "Available offline" : "Not downloaded") + } + + + } +
+ +@code { + private readonly List posts = []; + private readonly HashSet cachedPostIds = []; + private readonly HashSet downloadingPostIds = []; + private IQueryable PostItems => posts.AsQueryable(); + private bool isLoading = true; + private bool isSyncing; + private int syncProgressCurrent; + private int syncProgressTotal; + private string syncProgressStatus = string.Empty; + + protected override async Task OnInitializedAsync() + { + DataService.SyncProgressChanged += OnSyncProgressChanged; + await LoadPostsAsync(); + } + + private async Task LoadPostsAsync() + { + isLoading = true; + var unreadPosts = await DataService.GetUnreadPosts(); + posts.Clear(); + posts.AddRange(unreadPosts); + RefreshCachedPostIds(); + isLoading = false; + } + + private void RefreshCachedPostIds() + { + cachedPostIds.Clear(); + foreach (var post in posts) + { + var postId = post.Id ?? post.RowKey; + if (StorageService.IsPostCached(postId)) + { + cachedPostIds.Add(postId); + } + } + } + + private bool IsCached(string postId) => cachedPostIds.Contains(postId); + + private bool IsDownloading(string postId) => downloadingPostIds.Contains(postId); + + private bool IsDownloadDisabled(string postId) => + IsCached(postId) || IsDownloading(postId) || DataService.IsOffline; + + private bool IsDeleteDisabled(string postId) => + !IsCached(postId) || IsDownloading(postId); + + private Appearance GetDownloadAppearance(string postId) => + IsDownloadDisabled(postId) ? Appearance.Lightweight : Appearance.Accent; + + private Appearance GetDeleteAppearance(string postId) => + IsDeleteDisabled(postId) ? Appearance.Lightweight : Appearance.Accent; + + private async Task SyncNow() + { + if (isSyncing || DataService.IsSyncing) + { + return; + } + + isSyncing = true; + try + { + await DataService.SyncAsync(); + await LoadPostsAsync(); + ToastService.ShowSuccess("Synchronization completed successfully!"); + } + catch (Exception ex) + { + ToastService.ShowError($"Synchronization failed: {ex.Message}"); + } + finally + { + isSyncing = false; + await InvokeAsync(StateHasChanged); + } + } + + private async Task DownloadPost(string postId) + { + if (downloadingPostIds.Contains(postId) || StorageService.IsPostCached(postId)) + { + return; + } + + downloadingPostIds.Add(postId); + await InvokeAsync(StateHasChanged); + try + { + if (await StorageService.DownloadPostAsync(postId)) + { + RefreshCachedPostIds(); + ToastService.ShowSuccess("Post downloaded for offline reading."); + } + else + { + ToastService.ShowError("The post content could not be downloaded."); + } + } + catch (Exception ex) + { + ToastService.ShowError($"Download failed: {ex.Message}"); + } + finally + { + downloadingPostIds.Remove(postId); + await InvokeAsync(StateHasChanged); + } + } + + private async Task DeletePost(string postId) + { + if (!StorageService.IsPostCached(postId)) + { + return; + } + + try + { + StorageService.DeletePost(postId); + RefreshCachedPostIds(); + ToastService.ShowSuccess("Local post file deleted."); + } + catch (Exception ex) + { + ToastService.ShowError($"Delete failed: {ex.Message}"); + } + + await InvokeAsync(StateHasChanged); + } + + private void OnSyncProgressChanged(object? sender, SyncProgressEventArgs e) + { + _ = InvokeAsync(() => + { + syncProgressCurrent = e.Current; + syncProgressTotal = e.Total; + syncProgressStatus = e.Status; + StateHasChanged(); + }); + } + + public void Dispose() + { + DataService.SyncProgressChanged -= OnSyncProgressChanged; + } +} diff --git a/src/NoteBookmark.MauiApp/Data/StorageService.cs b/src/NoteBookmark.MauiApp/Data/StorageService.cs new file mode 100644 index 0000000..a2992b1 --- /dev/null +++ b/src/NoteBookmark.MauiApp/Data/StorageService.cs @@ -0,0 +1,66 @@ +using System.Collections.Concurrent; +using NoteBookmark.SharedUI; + +namespace NoteBookmark.MauiApp.Data; + +public interface IStorageService +{ + bool IsPostCached(string postId); + Task DownloadPostAsync(string postId); + void DeletePost(string postId); +} + +public sealed class StorageService( + IDataService dataService, + ILocalHtmlStorageService localHtmlStorageService) : IStorageService +{ + private readonly ConcurrentDictionary>> _downloads = new(); + + public bool IsPostCached(string postId) => + localHtmlStorageService.IsPostHtmlCached(postId); + + public async Task DownloadPostAsync(string postId) + { + var download = _downloads.GetOrAdd( + postId, + id => new Lazy>( + () => DownloadPostCoreAsync(id), + LazyThreadSafetyMode.ExecutionAndPublication)); + + try + { + return await download.Value; + } + finally + { + ((ICollection>>>)_downloads) + .Remove(new KeyValuePair>>(postId, download)); + } + } + + public void DeletePost(string postId) + { + localHtmlStorageService.RemovePostHtml(postId); + } + + private async Task DownloadPostCoreAsync(string postId) + { + if (localHtmlStorageService.IsPostHtmlCached(postId)) + { + return true; + } + + var html = await dataService.GetPostHtmlAsync(postId); + if (string.IsNullOrEmpty(html)) + { + return false; + } + + if (!localHtmlStorageService.IsPostHtmlCached(postId)) + { + await localHtmlStorageService.SavePostHtmlAsync(postId, html); + } + + return localHtmlStorageService.IsPostHtmlCached(postId); + } +} diff --git a/src/NoteBookmark.MauiApp/MauiProgram.cs b/src/NoteBookmark.MauiApp/MauiProgram.cs index d458a55..6988992 100644 --- a/src/NoteBookmark.MauiApp/MauiProgram.cs +++ b/src/NoteBookmark.MauiApp/MauiProgram.cs @@ -68,6 +68,7 @@ public static MauiHostingApp CreateMauiApp() ; builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); From 9d762193f2eda30c0fb55875b85020dc73ef7b90 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 19 Sep 2026 11:33:46 -0400 Subject: [PATCH 21/25] chore: bump application version to 1.4.3 (#201) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 24fd94d..b82672e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.4.2 + 1.4.3 net10.0 enable enable From 34548d001bbc895daa24a9b0246cbe3091fc37ec Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sun, 20 Sep 2026 08:44:18 -0400 Subject: [PATCH 22/25] fix(ci): fix timeout and rate limit failures in OpenCode Daily Triage workflow (#204) (#205) * v1.4.3 - Enhances MAUI sync, local storage, and reader (#202) * fix(#173): use Id ?? RowKey for checking and opening offline post HTML (#179) * feat(#176): add sync progress bar and status updates for downloading offline post text (#180) * should have done that long ago * fix: resolve Android MAUI app crash when opening URLs (issue #177) (#181) * feat: add up/down reordering for categories and notes in summary editor (issue #92) (#183) Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> * feat: add text size slider and fix text wrapping on offline reader page (issue #178) (#182) * bumb version * feat: implement async background post content extraction to save HTML in blob storage (#185) Co-authored-by: fboucher-os * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache (#186) * feat: implement async background post content extraction to save HTML in blob storage * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache * feat: configure protected Azure Function Parser API and key propagation (#187) * feat: configure protected Azure Function Parser API and key propagation * bump: version to 1.4.0 --------- Co-authored-by: fboucher-os * update missing env variable * fix * chore: bump version to 1.4.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(#190): update actions/cache to v5 for Node.js 24 compatibility in OpenCode triage workflow (#191) * Update version to 1.4.2, add sync progress tracking, and reader page controls (#192) * chore: bump version to 1.4.2 * feat: show detailed sync progress messages and progress bar on Posts page * feat: add back button and text size slider at both top and bottom of reader page * ci: include version in android apk filename (#193) Co-authored-by: fboucher-os * Fix document sync stuck and improve sync progress and offline fallback (fixes #196) (#199) * fix: constrain reader text size slider and add step buttons (fixes #195) (#198) * feat: Add markdown saving for reading notes and bump version to 1.4.3 * feat: add MAUI storage tab (#200) * feat: add MAUI storage tab for offline posts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: refine MAUI storage controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: highlight storage actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: bump application version to 1.4.3 (#201) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: fboucher-os Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ci): prevent timeout and rate limits in opencode daily triage (#204) --------- Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/opencode-triage.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/opencode-triage.yml b/.github/workflows/opencode-triage.yml index 72c2308..3fcb2fc 100644 --- a/.github/workflows/opencode-triage.yml +++ b/.github/workflows/opencode-triage.yml @@ -8,6 +8,7 @@ on: jobs: triage: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: id-token: write contents: write @@ -20,7 +21,7 @@ jobs: - name: Get opencode version id: version run: | - VERSION=$(curl -sf https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4) + VERSION=$(curl -sf -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4) echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT - name: Cache opencode @@ -47,9 +48,9 @@ jobs: PROMPT: | You are a triage agent. Triaging open GitHub issues in this repo. - 1. Use `gh issue list --state open --json number,title,body,labels,createdAt` to list open issues. - 2. Skip any issue that already has a state label (ready-for-agent, ready-for-human, go:needs-research, go:no, wontfix). - 3. For each unlabeled issue, read it with `gh issue view `. + 1. Use `gh issue list --state open --limit 5 --json number,title,body,labels,createdAt` to list open issues. + 2. Skip any issue that already has a state label (ready-for-agent, ready-for-human, go:needs-research, go:no, wontfix, in-progress). + 3. For each unlabeled issue (max 5 per run), read it with `gh issue view `. 4. Classify as `type:bug` or `type:feature`. 5. Assign a state: - `go:needs-research` — needs more info or investigation @@ -68,5 +69,6 @@ jobs: Rules: - Never re-triage an issue that already has a state label. - Never modify closed issues. + - Process at most 5 issues per daily run to avoid rate limits. - When in doubt, use `go:needs-research`. - Keep briefs behavioral (what, not how). No file paths or line numbers. From 39e20c8b1ccd8a9f0317439272559849efb567c9 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sun, 20 Sep 2026 10:48:10 -0400 Subject: [PATCH 23/25] feat(ui): add dedicated quickadd popup and MinimalLayout for bookmarklet (#203) (#207) * v1.4.3 - Enhances MAUI sync, local storage, and reader (#202) * fix(#173): use Id ?? RowKey for checking and opening offline post HTML (#179) * feat(#176): add sync progress bar and status updates for downloading offline post text (#180) * should have done that long ago * fix: resolve Android MAUI app crash when opening URLs (issue #177) (#181) * feat: add up/down reordering for categories and notes in summary editor (issue #92) (#183) Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> * feat: add text size slider and fix text wrapping on offline reader page (issue #178) (#182) * bumb version * feat: implement async background post content extraction to save HTML in blob storage (#185) Co-authored-by: fboucher-os * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache (#186) * feat: implement async background post content extraction to save HTML in blob storage * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache * feat: configure protected Azure Function Parser API and key propagation (#187) * feat: configure protected Azure Function Parser API and key propagation * bump: version to 1.4.0 --------- Co-authored-by: fboucher-os * update missing env variable * fix * chore: bump version to 1.4.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(#190): update actions/cache to v5 for Node.js 24 compatibility in OpenCode triage workflow (#191) * Update version to 1.4.2, add sync progress tracking, and reader page controls (#192) * chore: bump version to 1.4.2 * feat: show detailed sync progress messages and progress bar on Posts page * feat: add back button and text size slider at both top and bottom of reader page * ci: include version in android apk filename (#193) Co-authored-by: fboucher-os * Fix document sync stuck and improve sync progress and offline fallback (fixes #196) (#199) * fix: constrain reader text size slider and add step buttons (fixes #195) (#198) * feat: Add markdown saving for reading notes and bump version to 1.4.3 * feat: add MAUI storage tab (#200) * feat: add MAUI storage tab for offline posts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: refine MAUI storage controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: highlight storage actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: bump application version to 1.4.3 (#201) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: fboucher-os Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixes CI timeout and rate limits in triage workflow (#206) * fix(#173): use Id ?? RowKey for checking and opening offline post HTML (#179) * feat(#176): add sync progress bar and status updates for downloading offline post text (#180) * should have done that long ago * fix: resolve Android MAUI app crash when opening URLs (issue #177) (#181) * feat: add up/down reordering for categories and notes in summary editor (issue #92) (#183) Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> * feat: add text size slider and fix text wrapping on offline reader page (issue #178) (#182) * bumb version * feat: implement async background post content extraction to save HTML in blob storage (#185) Co-authored-by: fboucher-os * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache (#186) * feat: implement async background post content extraction to save HTML in blob storage * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache * feat: configure protected Azure Function Parser API and key propagation (#187) * feat: configure protected Azure Function Parser API and key propagation * bump: version to 1.4.0 --------- Co-authored-by: fboucher-os * update missing env variable * fix * chore: bump version to 1.4.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(#190): update actions/cache to v5 for Node.js 24 compatibility in OpenCode triage workflow (#191) * Update version to 1.4.2, add sync progress tracking, and reader page controls (#192) * chore: bump version to 1.4.2 * feat: show detailed sync progress messages and progress bar on Posts page * feat: add back button and text size slider at both top and bottom of reader page * ci: include version in android apk filename (#193) Co-authored-by: fboucher-os * Fix document sync stuck and improve sync progress and offline fallback (fixes #196) (#199) * fix: constrain reader text size slider and add step buttons (fixes #195) (#198) * feat: Add markdown saving for reading notes and bump version to 1.4.3 * feat: add MAUI storage tab (#200) * feat: add MAUI storage tab for offline posts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: refine MAUI storage controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: highlight storage actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: bump application version to 1.4.3 (#201) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ci): fix timeout and rate limit failures in OpenCode Daily Triage workflow (#204) (#205) * v1.4.3 - Enhances MAUI sync, local storage, and reader (#202) * fix(#173): use Id ?? RowKey for checking and opening offline post HTML (#179) * feat(#176): add sync progress bar and status updates for downloading offline post text (#180) * should have done that long ago * fix: resolve Android MAUI app crash when opening URLs (issue #177) (#181) * feat: add up/down reordering for categories and notes in summary editor (issue #92) (#183) Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> * feat: add text size slider and fix text wrapping on offline reader page (issue #178) (#182) * bumb version * feat: implement async background post content extraction to save HTML in blob storage (#185) Co-authored-by: fboucher-os * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache (#186) * feat: implement async background post content extraction to save HTML in blob storage * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache * feat: configure protected Azure Function Parser API and key propagation (#187) * feat: configure protected Azure Function Parser API and key propagation * bump: version to 1.4.0 --------- Co-authored-by: fboucher-os * update missing env variable * fix * chore: bump version to 1.4.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(#190): update actions/cache to v5 for Node.js 24 compatibility in OpenCode triage workflow (#191) * Update version to 1.4.2, add sync progress tracking, and reader page controls (#192) * chore: bump version to 1.4.2 * feat: show detailed sync progress messages and progress bar on Posts page * feat: add back button and text size slider at both top and bottom of reader page * ci: include version in android apk filename (#193) Co-authored-by: fboucher-os * Fix document sync stuck and improve sync progress and offline fallback (fixes #196) (#199) * fix: constrain reader text size slider and add step buttons (fixes #195) (#198) * feat: Add markdown saving for reading notes and bump version to 1.4.3 * feat: add MAUI storage tab (#200) * feat: add MAUI storage tab for offline posts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: refine MAUI storage controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: highlight storage actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: bump application version to 1.4.3 (#201) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: fboucher-os Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ci): prevent timeout and rate limits in opencode daily triage (#204) --------- Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: fboucher-os Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(ui): add browser bookmarklet support for saving URLs (#203) * feat(ui): add dedicated quickadd popup and MinimalLayout for bookmarklet (#203) --------- Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/PostsTests.cs | 393 +++++----- .../Tests/QuickAddTests.cs | 131 ++++ .../Data/OfflineDataService.cs | 686 +++++++++--------- .../Components/Pages/Posts.razor | 674 ++++++++--------- .../Components/Pages/QuickAdd.razor | 162 +++++ .../Components/Pages/Settings.razor | 27 +- .../Components/Shared/SuggestionList.razor | 2 +- src/NoteBookmark.SharedUI/IDataService.cs | 64 +- src/NoteBookmark.SharedUI/PostNoteClient.cs | 8 +- 9 files changed, 1235 insertions(+), 912 deletions(-) create mode 100644 src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs create mode 100644 src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs index c4e5ec9..2ebc1bc 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs @@ -1,196 +1,197 @@ -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); - } - - [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"); - } - - [Fact] - public void Posts_ReadPostButton_IsAlwaysRendered() - { - var cut = Render(); - - cut.Markup.Should().Contain("Read post"); - } - - [Fact] - public void Posts_DisplaysSyncProgress_WhenSyncProgressChangedFired() - { - var cut = Render(); - - cut.InvokeAsync(() => - { - _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(1, 6, "Downloading 1 of 6 posts...")); - }); - - cut.Markup.Should().Contain("Downloading 1 of 6 posts..."); - var progress = cut.FindComponent(); - progress.Instance.Value.Should().Be(1); - progress.Instance.Max.Should().Be(6); - } - - [Fact] - public void Posts_DisplaysCleaningStatus_WhenSyncProgressChangedFired() - { - var cut = Render(); - - cut.InvokeAsync(() => - { - _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Cleaning...")); - }); - - cut.Markup.Should().Contain("Cleaning..."); - } - - [Fact] - public void Posts_SyncProgressChanged_WhenIsComplete_ReloadsPosts() - { - var cut = Render(); - - _dataServiceMock.Invocations.Clear(); - - cut.InvokeAsync(() => - { - _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Synchronization complete!", isComplete: true)); - }); - - _dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce); - } - - [Fact] - public void Posts_SyncButton_DisabledAndLoadingReflectsIsSyncing() - { - _dataServiceMock.SetupGet(s => s.CanSync).Returns(true); - _dataServiceMock.SetupGet(s => s.IsSyncing).Returns(true); - - var cut = Render(); - - var buttons = cut.FindComponents(); - var syncButton = buttons.FirstOrDefault(b => b.Instance.Title == "Sync posts and comments"); - syncButton.Should().NotBeNull(); - syncButton!.Instance.Disabled.Should().BeTrue(); - syncButton.Instance.Loading.Should().BeTrue(); - } -} - +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Components; +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); + } + + [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"); + } + + [Fact] + public void Posts_ReadPostButton_IsAlwaysRendered() + { + var cut = Render(); + + cut.Markup.Should().Contain("Read post"); + } + + [Fact] + public void Posts_DisplaysSyncProgress_WhenSyncProgressChangedFired() + { + var cut = Render(); + + cut.InvokeAsync(() => + { + _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(1, 6, "Downloading 1 of 6 posts...")); + }); + + cut.Markup.Should().Contain("Downloading 1 of 6 posts..."); + var progress = cut.FindComponent(); + progress.Instance.Value.Should().Be(1); + progress.Instance.Max.Should().Be(6); + } + + [Fact] + public void Posts_DisplaysCleaningStatus_WhenSyncProgressChangedFired() + { + var cut = Render(); + + cut.InvokeAsync(() => + { + _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Cleaning...")); + }); + + cut.Markup.Should().Contain("Cleaning..."); + } + + [Fact] + public void Posts_SyncProgressChanged_WhenIsComplete_ReloadsPosts() + { + var cut = Render(); + + _dataServiceMock.Invocations.Clear(); + + cut.InvokeAsync(() => + { + _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Synchronization complete!", isComplete: true)); + }); + + _dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce); + } + + [Fact] + public void Posts_SyncButton_DisabledAndLoadingReflectsIsSyncing() + { + _dataServiceMock.SetupGet(s => s.CanSync).Returns(true); + _dataServiceMock.SetupGet(s => s.IsSyncing).Returns(true); + + var cut = Render(); + + var buttons = cut.FindComponents(); + var syncButton = buttons.FirstOrDefault(b => b.Instance.Title == "Sync posts and comments"); + syncButton.Should().NotBeNull(); + syncButton!.Instance.Disabled.Should().BeTrue(); + syncButton.Instance.Loading.Should().BeTrue(); + } +} + diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs new file mode 100644 index 0000000..f51e4c3 --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs @@ -0,0 +1,131 @@ +using Bunit; +using Bunit.TestDoubles; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +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; +using Xunit; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +public sealed class QuickAddTests : BunitContext +{ + private readonly Mock _dataServiceMock; + + public QuickAddTests() + { + this.AddFluentUI(); + this.AddAuthorization().SetAuthorized("testuser"); + + _dataServiceMock = new Mock(); + _dataServiceMock.SetupGet(s => s.IsOffline).Returns(false); + + Services.AddSingleton(_dataServiceMock.Object); + Services.AddSingleton(new Mock().Object); + Services.AddSingleton(new Mock().Object); + } + + [Fact] + public void QuickAdd_WithUrlParameter_ExtractsAndDisplaysPostDetails() + { + const string testUrl = "https://example.com/my-post"; + var samplePost = new Post + { + PartitionKey = "p", + RowKey = "12345", + Title = "Awesome .NET 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)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Post saved successfully!"); + cut.Markup.Should().Contain("Awesome .NET Post"); + cut.Markup.Should().Contain("Jane Doe"); + cut.Markup.Should().Contain("Close Window"); + cut.Markup.Should().Contain("Edit"); + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(testUrl), Times.Once); + } + + [Fact] + public void QuickAdd_WhenEditClicked_NavigatesToPostEditorLight() + { + const string testUrl = "https://example.com/edit-post"; + var samplePost = new Post + { + PartitionKey = "p", + RowKey = "post-row-key-999", + Title = "Post to Edit", + Author = "Author Name", + Url = testUrl + }; + + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)) + .ReturnsAsync(samplePost); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + var editButton = cut.FindAll("fluent-button").FirstOrDefault(e => e.TextContent.Contains("Edit")); + editButton.Should().NotBeNull(); + editButton!.Click(); + + nav.Uri.Should().EndWith("posteditorlight/post-row-key-999"); + } + + [Fact] + public void QuickAdd_WhenOffline_DisplaysErrorMessage() + { + const string testUrl = "https://example.com/offline"; + _dataServiceMock.SetupGet(s => s.IsOffline).Returns(true); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Cannot extract posts while offline."); + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(It.IsAny()), Times.Never); + } + + [Fact] + public void QuickAdd_WhenExtractionFails_DisplaysErrorMessage() + { + const string testUrl = "https://example.com/fail"; + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)) + .ReturnsAsync((Post?)null); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Failed to extract and save post details"); + } + + [Fact] + public void QuickAdd_WithoutUrlParameter_DisplaysManualInput() + { + var nav = Services.GetRequiredService(); + nav.NavigateTo("http://localhost/quickadd"); + + var cut = Render(); + + cut.Markup.Should().Contain("Enter or paste URL"); + cut.Markup.Should().Contain("Close Window"); + } +} diff --git a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs index 5834142..c14d03f 100644 --- a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs @@ -1,343 +1,343 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Maui.Networking; -using NoteBookmark.Domain; -using NoteBookmark.SharedUI; - -namespace NoteBookmark.MauiApp.Data; - -public class OfflineDataService(PostNoteClient apiClient, ILocalDataService localDataService, IConnectivity connectivity, ISyncService syncService, ILocalHtmlStorageService localHtmlStorageService) : IDataService -{ - private bool IsOnline => connectivity.NetworkAccess == NetworkAccess.Internet; - - public async Task> GetUnreadPosts() - { - if (IsOnline) - { - var posts = await apiClient.GetUnreadPosts(); - await MergeLocalNotesIntoRemotePosts(posts); - return posts; - } - else - { - var allPosts = await localDataService.GetPostsAsync(); - var allNotes = await localDataService.GetNotesAsync(); - - return allPosts.Where(p => p.is_read != true).Select(p => { - var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); - return new PostL - { - Id = p.Id, - Title = p.Title, - Date_published = p.Date_published, - Url = p.Url, - Excerpt = p.Excerpt, - is_read = p.is_read, - PartitionKey = p.PartitionKey, - RowKey = p.RowKey, - NoteId = note?.RowKey, - Note = note?.Comment, - DateModified = p.DateModified - }; - }).ToList(); - } - } - - public async Task> GetReadPosts() - { - if (IsOnline) - { - var posts = await apiClient.GetReadPosts(); - await MergeLocalNotesIntoRemotePosts(posts); - return posts; - } - else - { - var allPosts = await localDataService.GetPostsAsync(); - var allNotes = await localDataService.GetNotesAsync(); - - return allPosts.Where(p => p.is_read == true).Select(p => { - var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); - return new PostL - { - Id = p.Id, - Title = p.Title, - Date_published = p.Date_published, - Url = p.Url, - Excerpt = p.Excerpt, - is_read = p.is_read, - PartitionKey = p.PartitionKey, - RowKey = p.RowKey, - NoteId = note?.RowKey, - Note = note?.Comment, - DateModified = p.DateModified - }; - }).ToList(); - } - } - - public async Task> GetSummaries() - { - if (IsOnline) - { - var summaries = await apiClient.GetSummaries(); - await localDataService.SaveSummariesAsync(summaries); - return summaries; - } - else - { - return await localDataService.GetSummariesAsync(); - } - } - - public async Task CreateNote(Note note) - { - note.DateModified = DateTime.UtcNow; - if (IsOnline) - { - await apiClient.CreateNote(note); - await localDataService.SaveNoteAsync(note); - var post = await localDataService.GetPostAsync(note.PostId!); - if (post != null) - { - post.is_read = true; - await localDataService.SavePostAsync(post, isPendingSync: false); - } - } - else - { - var settings = await localDataService.GetSettingsAsync(); - note.PartitionKey = settings?.ReadingNotesCounter ?? note.PartitionKey; - note.CreatedOffline = true; - await localDataService.SaveNoteAsync(note, isPendingSync: true); - var post = await localDataService.GetPostAsync(note.PostId!); - if (post != null) - { - post.is_read = true; - post.DateModified = DateTime.UtcNow; - await localDataService.SavePostAsync(post, isPendingSync: true); - } - } - } - - public async Task GetNote(string noteId) - { - if (IsOnline) - { - var note = await apiClient.GetNote(noteId); - if (note != null) await localDataService.SaveNoteAsync(note); - return note; - } - else - { - return await localDataService.GetNoteAsync(noteId); - } - } - - public async Task UpdateNote(Note note) - { - note.DateModified = DateTime.UtcNow; - if (IsOnline) - { - var success = await apiClient.UpdateNote(note); - if (success) await localDataService.SaveNoteAsync(note); - return success; - } - else - { - await localDataService.SaveNoteAsync(note, isPendingSync: true); - return true; - } - } - - public async Task DeleteNote(string noteId) - { - if (IsOnline) - { - var success = await apiClient.DeleteNote(noteId); - if (success) - { - await localDataService.DeleteNoteAsync(noteId); - } - return success; - } - else - { - await localDataService.DeleteNoteAsync(noteId, isPendingSync: true); - return true; - } - } - - public Task CreateReadingNotes() => apiClient.CreateReadingNotes(); - public Task GetReadingNotes(string number) => apiClient.GetReadingNotes(number); - public Task SaveReadingNotes(ReadingNotes readingNotes) => apiClient.SaveReadingNotes(readingNotes); - public Task SaveReadingNotesMarkdown(string markdown, string number) => apiClient.SaveReadingNotesMarkdown(markdown, number); - - public async Task GetPost(string id) - { - if (IsOnline) - { - try - { - var post = await apiClient.GetPost(id); - if (post != null) await localDataService.SavePostAsync(post); - return post; - } - catch - { - return await localDataService.GetPostAsync(id); - } - } - else - { - return await localDataService.GetPostAsync(id); - } - } - - public async Task SavePost(Post post) - { - post.DateModified = DateTime.UtcNow; - if (IsOnline) - { - var success = await apiClient.SavePost(post); - if (success) await localDataService.SavePostAsync(post); - return success; - } - else - { - await localDataService.SavePostAsync(post, isPendingSync: true); - return true; - } - } - - public async Task GetSettings() - { - if (IsOnline) - { - var settings = await apiClient.GetSettings(); - if (settings != null) await localDataService.SaveSettingsAsync(settings); - return settings; - } - else - { - return await localDataService.GetSettingsAsync(); - } - } - - public async Task SaveSettings(Settings settings) - { - if (IsOnline) - { - var success = await apiClient.SaveSettings(settings); - if (success) await localDataService.SaveSettingsAsync(settings); - return success; - } - else - { - // For now, offline settings saves don't sync. - await localDataService.SaveSettingsAsync(settings); - return true; - } - } - - public async Task ExtractPostDetailsAndSave(string url) - { - if (IsOnline) - { - return await apiClient.ExtractPostDetailsAndSave(url); - } - return false; // Can't extract offline - } - - public async Task GetPostHtmlAsync(string postId) - { - var localHtml = await localHtmlStorageService.GetPostHtmlAsync(postId); - if (!string.IsNullOrEmpty(localHtml)) - { - return localHtml; - } - - if (IsOnline) - { - try - { - var remoteHtml = await apiClient.GetPostHtmlAsync(postId); - if (!string.IsNullOrEmpty(remoteHtml)) - { - await localHtmlStorageService.SavePostHtmlAsync(postId, remoteHtml); - return remoteHtml; - } - } - catch - { - // Fall back to null if remote fetch fails - } - } - - return null; - } - - public async Task DeletePost(string id) - { - if (IsOnline) - { - var success = await apiClient.DeletePost(id); - if (success) - { - var post = await localDataService.GetPostAsync(id); - if (post != null) - { - post.is_read = true; // Simulating what API does - post.DateModified = DateTime.UtcNow; - await localDataService.SavePostAsync(post); - } - } - return success; - } - else - { - var post = await localDataService.GetPostAsync(id); - if (post != null) - { - post.is_read = true; - post.DateModified = DateTime.UtcNow; - await localDataService.SavePostAsync(post, isPendingSync: true); - } - return true; - } - } - - public Task SyncAsync() => syncService.SyncAsync(); - public event EventHandler? SyncProgressChanged - { - add => syncService.SyncProgressChanged += value; - remove => syncService.SyncProgressChanged -= value; - } - public bool IsSyncing => syncService.IsSyncing; - public bool IsOffline => connectivity.NetworkAccess != NetworkAccess.Internet; - public bool CanSync => true; - - private async Task MergeLocalNotesIntoRemotePosts(List remotePosts) - { - if (remotePosts == null || !remotePosts.Any()) return; - - var localNotes = await localDataService.GetNotesAsync(); - if (localNotes == null || !localNotes.Any()) return; - - var localNotesByPostId = localNotes - .GroupBy(n => n.PostId!) - .ToDictionary(g => g.Key, g => g.First()); - - foreach (var post in remotePosts) - { - var id = post.Id ?? post.RowKey; - if (localNotesByPostId.TryGetValue(id, out var localNote)) - { - post.Note = localNote.Comment; - post.NoteId = localNote.RowKey; - } - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Maui.Networking; +using NoteBookmark.Domain; +using NoteBookmark.SharedUI; + +namespace NoteBookmark.MauiApp.Data; + +public class OfflineDataService(PostNoteClient apiClient, ILocalDataService localDataService, IConnectivity connectivity, ISyncService syncService, ILocalHtmlStorageService localHtmlStorageService) : IDataService +{ + private bool IsOnline => connectivity.NetworkAccess == NetworkAccess.Internet; + + public async Task> GetUnreadPosts() + { + if (IsOnline) + { + var posts = await apiClient.GetUnreadPosts(); + await MergeLocalNotesIntoRemotePosts(posts); + return posts; + } + else + { + var allPosts = await localDataService.GetPostsAsync(); + var allNotes = await localDataService.GetNotesAsync(); + + return allPosts.Where(p => p.is_read != true).Select(p => { + var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); + return new PostL + { + Id = p.Id, + Title = p.Title, + Date_published = p.Date_published, + Url = p.Url, + Excerpt = p.Excerpt, + is_read = p.is_read, + PartitionKey = p.PartitionKey, + RowKey = p.RowKey, + NoteId = note?.RowKey, + Note = note?.Comment, + DateModified = p.DateModified + }; + }).ToList(); + } + } + + public async Task> GetReadPosts() + { + if (IsOnline) + { + var posts = await apiClient.GetReadPosts(); + await MergeLocalNotesIntoRemotePosts(posts); + return posts; + } + else + { + var allPosts = await localDataService.GetPostsAsync(); + var allNotes = await localDataService.GetNotesAsync(); + + return allPosts.Where(p => p.is_read == true).Select(p => { + var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); + return new PostL + { + Id = p.Id, + Title = p.Title, + Date_published = p.Date_published, + Url = p.Url, + Excerpt = p.Excerpt, + is_read = p.is_read, + PartitionKey = p.PartitionKey, + RowKey = p.RowKey, + NoteId = note?.RowKey, + Note = note?.Comment, + DateModified = p.DateModified + }; + }).ToList(); + } + } + + public async Task> GetSummaries() + { + if (IsOnline) + { + var summaries = await apiClient.GetSummaries(); + await localDataService.SaveSummariesAsync(summaries); + return summaries; + } + else + { + return await localDataService.GetSummariesAsync(); + } + } + + public async Task CreateNote(Note note) + { + note.DateModified = DateTime.UtcNow; + if (IsOnline) + { + await apiClient.CreateNote(note); + await localDataService.SaveNoteAsync(note); + var post = await localDataService.GetPostAsync(note.PostId!); + if (post != null) + { + post.is_read = true; + await localDataService.SavePostAsync(post, isPendingSync: false); + } + } + else + { + var settings = await localDataService.GetSettingsAsync(); + note.PartitionKey = settings?.ReadingNotesCounter ?? note.PartitionKey; + note.CreatedOffline = true; + await localDataService.SaveNoteAsync(note, isPendingSync: true); + var post = await localDataService.GetPostAsync(note.PostId!); + if (post != null) + { + post.is_read = true; + post.DateModified = DateTime.UtcNow; + await localDataService.SavePostAsync(post, isPendingSync: true); + } + } + } + + public async Task GetNote(string noteId) + { + if (IsOnline) + { + var note = await apiClient.GetNote(noteId); + if (note != null) await localDataService.SaveNoteAsync(note); + return note; + } + else + { + return await localDataService.GetNoteAsync(noteId); + } + } + + public async Task UpdateNote(Note note) + { + note.DateModified = DateTime.UtcNow; + if (IsOnline) + { + var success = await apiClient.UpdateNote(note); + if (success) await localDataService.SaveNoteAsync(note); + return success; + } + else + { + await localDataService.SaveNoteAsync(note, isPendingSync: true); + return true; + } + } + + public async Task DeleteNote(string noteId) + { + if (IsOnline) + { + var success = await apiClient.DeleteNote(noteId); + if (success) + { + await localDataService.DeleteNoteAsync(noteId); + } + return success; + } + else + { + await localDataService.DeleteNoteAsync(noteId, isPendingSync: true); + return true; + } + } + + public Task CreateReadingNotes() => apiClient.CreateReadingNotes(); + public Task GetReadingNotes(string number) => apiClient.GetReadingNotes(number); + public Task SaveReadingNotes(ReadingNotes readingNotes) => apiClient.SaveReadingNotes(readingNotes); + public Task SaveReadingNotesMarkdown(string markdown, string number) => apiClient.SaveReadingNotesMarkdown(markdown, number); + + public async Task GetPost(string id) + { + if (IsOnline) + { + try + { + var post = await apiClient.GetPost(id); + if (post != null) await localDataService.SavePostAsync(post); + return post; + } + catch + { + return await localDataService.GetPostAsync(id); + } + } + else + { + return await localDataService.GetPostAsync(id); + } + } + + public async Task SavePost(Post post) + { + post.DateModified = DateTime.UtcNow; + if (IsOnline) + { + var success = await apiClient.SavePost(post); + if (success) await localDataService.SavePostAsync(post); + return success; + } + else + { + await localDataService.SavePostAsync(post, isPendingSync: true); + return true; + } + } + + public async Task GetSettings() + { + if (IsOnline) + { + var settings = await apiClient.GetSettings(); + if (settings != null) await localDataService.SaveSettingsAsync(settings); + return settings; + } + else + { + return await localDataService.GetSettingsAsync(); + } + } + + public async Task SaveSettings(Settings settings) + { + if (IsOnline) + { + var success = await apiClient.SaveSettings(settings); + if (success) await localDataService.SaveSettingsAsync(settings); + return success; + } + else + { + // For now, offline settings saves don't sync. + await localDataService.SaveSettingsAsync(settings); + return true; + } + } + + public async Task ExtractPostDetailsAndSave(string url) + { + if (IsOnline) + { + return await apiClient.ExtractPostDetailsAndSave(url); + } + return null; // Can't extract offline + } + + public async Task GetPostHtmlAsync(string postId) + { + var localHtml = await localHtmlStorageService.GetPostHtmlAsync(postId); + if (!string.IsNullOrEmpty(localHtml)) + { + return localHtml; + } + + if (IsOnline) + { + try + { + var remoteHtml = await apiClient.GetPostHtmlAsync(postId); + if (!string.IsNullOrEmpty(remoteHtml)) + { + await localHtmlStorageService.SavePostHtmlAsync(postId, remoteHtml); + return remoteHtml; + } + } + catch + { + // Fall back to null if remote fetch fails + } + } + + return null; + } + + public async Task DeletePost(string id) + { + if (IsOnline) + { + var success = await apiClient.DeletePost(id); + if (success) + { + var post = await localDataService.GetPostAsync(id); + if (post != null) + { + post.is_read = true; // Simulating what API does + post.DateModified = DateTime.UtcNow; + await localDataService.SavePostAsync(post); + } + } + return success; + } + else + { + var post = await localDataService.GetPostAsync(id); + if (post != null) + { + post.is_read = true; + post.DateModified = DateTime.UtcNow; + await localDataService.SavePostAsync(post, isPendingSync: true); + } + return true; + } + } + + public Task SyncAsync() => syncService.SyncAsync(); + public event EventHandler? SyncProgressChanged + { + add => syncService.SyncProgressChanged += value; + remove => syncService.SyncProgressChanged -= value; + } + public bool IsSyncing => syncService.IsSyncing; + public bool IsOffline => connectivity.NetworkAccess != NetworkAccess.Internet; + public bool CanSync => true; + + private async Task MergeLocalNotesIntoRemotePosts(List remotePosts) + { + if (remotePosts == null || !remotePosts.Any()) return; + + var localNotes = await localDataService.GetNotesAsync(); + if (localNotes == null || !localNotes.Any()) return; + + var localNotesByPostId = localNotes + .GroupBy(n => n.PostId!) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var post in remotePosts) + { + var id = post.Id ?? post.RowKey; + if (localNotesByPostId.TryGetValue(id, out var localNote)) + { + post.Note = localNote.Comment; + post.NoteId = localNote.RowKey; + } + } + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index bcf310b..69ef426 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -1,337 +1,337 @@ -@page "/posts" -@attribute [Authorize] -@using Microsoft.AspNetCore.Authorization -@using NoteBookmark.Domain -@using Microsoft.FluentUI.AspNetCore.Components -@inject IDataService client -@inject IJSRuntime jsRuntime -@inject IToastService toastService -@inject IDialogService DialogService -@inject NavigationManager Navigation -@inject IUrlLauncher urlLauncher -@implements IDisposable - -Posts - -

Posts

- - - - - - @if (client.CanSync) - { - Sync - } - - @if (isSyncing || client.IsSyncing || !string.IsNullOrEmpty(syncProgressStatus)) - { - - - @if (!string.IsNullOrEmpty(syncProgressStatus)) - { - @syncProgressStatus - } - - } - - Read Only - UnRead Only - - - - - - - - - @if (String.IsNullOrEmpty(context!.NoteId)) - { - - } - else - { - - } - - - - - - - -@if(showPublishedDate) -{ - -} - - - - - -   Nothing to see here. Carry on! - - - - - - - -@code { - private IQueryable? posts; - IQueryable? filteredUrlList => posts?.Where(x => x.Title!.Contains(titleFilter, StringComparison.CurrentCultureIgnoreCase)); - private GridSort defSort = GridSort.ByDescending(c => c.Date_published); - private string newPostUrl = string.Empty; - private bool showRead = false; - private PaginationState pagination = new PaginationState { ItemsPerPage = 20 }; - private string titleFilter = string.Empty; - private bool showPublishedDate = false; - private int syncProgressCurrent = 0; - private int syncProgressTotal = 0; - private string syncProgressStatus = string.Empty; - - protected override async Task OnInitializedAsync() - { - client.SyncProgressChanged += OnSyncProgressChanged; - await LoadPosts(); - _ = StartBackgroundSync(); - } - - private async Task LoadPosts() - { - List loadedPosts = showRead ? await client.GetReadPosts(): await client.GetUnreadPosts(); - posts = loadedPosts.AsQueryable(); - } - - private async Task StartBackgroundSync() - { - if (client.IsOffline) return; - - try - { - isSyncing = true; - await client.SyncAsync(); - await LoadPosts(); - } - catch (Exception) - { - // Ignore background sync errors - } - finally - { - isSyncing = false; - StateHasChanged(); - } - } - - private async Task OpenUrlInNewWindow(string? url) - { - await urlLauncher.OpenUrlAsync(url); - } - - private async Task CreateNoteForPost(string postId) - { - var newNote = new Note { PostId = postId }; - - IDialogReference dialog = await DialogService.ShowDialogAsync(newNote, new DialogParameters(){ - Title = "Add a note", - PreventDismissOnOverlayClick = true, - PreventScroll = true, - PrimaryAction = "Save", - SecondaryAction = "Cancel" - }); - - var result = await dialog.Result; - if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) - { - if (dialogResult.Action == "Save" && dialogResult.Note != null) - { - await client.CreateNote(dialogResult.Note); - toastService.ShowSuccess("Note created successfully!"); - await LoadPosts(); - } - } - } - - private void EditNote(string postId) - { - Navigation.NavigateTo($"posteditor/{postId}"); - } - - private async Task EditNoteForPost(string noteId) - { - try - { - var existingNote = await client.GetNote(noteId); - if (existingNote == null) - { - toastService.ShowError("Note not found."); - return; - } - - IDialogReference dialog = await DialogService.ShowDialogAsync(existingNote, new DialogParameters(){ - Title = "Edit note", - PreventDismissOnOverlayClick = true, - PreventScroll = true, - }); - - var result = await dialog.Result; - if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) - { - if (dialogResult.Action == "Delete" && dialogResult.Note != null) - { - var deleteResult = await client.DeleteNote(dialogResult.Note.RowKey); - if (deleteResult) - { - toastService.ShowSuccess("Note deleted successfully!"); - await LoadPosts(); - } - else - { - toastService.ShowError("Failed to delete note. Please try again."); - } - } - else if (dialogResult.Action == "Save" && dialogResult.Note != null) - { - var updateResult = await client.UpdateNote(dialogResult.Note); - if (updateResult) - { - toastService.ShowSuccess("Note updated successfully!"); - await LoadPosts(); - } - else - { - toastService.ShowError("Failed to update note. Please try again."); - } - } - } - } - catch (Exception) - { - toastService.ShowError("An error occurred. Please try again."); - } - } - - private async Task AddNewPost() - { - if (!string.IsNullOrEmpty(newPostUrl)) - { - var result = await client.ExtractPostDetailsAndSave(newPostUrl); - if (result) - { - await LoadPosts(); - newPostUrl = string.Empty; - toastService.ShowSuccess("Post added successfully!"); - } - else - { - toastService.ShowError("Failed to add post. Please try again."); - } - } - } - - private async Task DeletePost(string postId) - { - var result = await client.DeletePost(postId); - if (result) - { - await LoadPosts(); - toastService.ShowSuccess("Post deleted successfully!"); - } - else - { - toastService.ShowError("Failed to delete post. Please try again."); - } - } - - private async Task OnShowReadChanged(bool value) - { - showRead = value; - await LoadPosts(); - } - - private void HandleTitleFilter(ChangeEventArgs args) - { - if (args.Value is string value) - { - titleFilter = value; - } - } - - private void HandleClearTitleFilter() - { - if (string.IsNullOrWhiteSpace(titleFilter)) - { - titleFilter = string.Empty; - } - } - - private bool isSyncing = false; - - private async Task SyncNow() - { - if (isSyncing || client.IsSyncing) return; - isSyncing = true; - try - { - toastService.ShowInfo("Synchronization started..."); - await client.SyncAsync(); - toastService.ShowSuccess("Synchronization completed successfully!"); - await LoadPosts(); - } - catch (Exception ex) - { - toastService.ShowError($"Synchronization failed: {ex.Message}"); - } - finally - { - isSyncing = false; - StateHasChanged(); - } - } - - private void ToggleShowPublishedDate() - { - showPublishedDate = !showPublishedDate; - } - - private void ReadPost(string postId) => Navigation.NavigateTo($"postreader/{postId}"); - - private void OnSyncProgressChanged(object? sender, SyncProgressEventArgs e) - { - InvokeAsync(async () => - { - syncProgressCurrent = e.Current; - syncProgressTotal = e.Total; - syncProgressStatus = e.Status; - StateHasChanged(); - - if (e.IsComplete) - { - isSyncing = false; - await LoadPosts(); - StateHasChanged(); - - await Task.Delay(2500); - if (!client.IsSyncing && syncProgressStatus == e.Status) - { - syncProgressStatus = string.Empty; - syncProgressCurrent = 0; - syncProgressTotal = 0; - StateHasChanged(); - } - } - }); - } - - public void Dispose() - { - client.SyncProgressChanged -= OnSyncProgressChanged; - } -} +@page "/posts" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@using NoteBookmark.Domain +@using Microsoft.FluentUI.AspNetCore.Components +@inject IDataService client +@inject IJSRuntime jsRuntime +@inject IToastService toastService +@inject IDialogService DialogService +@inject NavigationManager Navigation +@inject IUrlLauncher urlLauncher +@implements IDisposable + +Posts + +

Posts

+ + + + + + @if (client.CanSync) + { + Sync + } + + @if (isSyncing || client.IsSyncing || !string.IsNullOrEmpty(syncProgressStatus)) + { + + + @if (!string.IsNullOrEmpty(syncProgressStatus)) + { + @syncProgressStatus + } + + } + + Read Only + UnRead Only + + + + + + + + + @if (String.IsNullOrEmpty(context!.NoteId)) + { + + } + else + { + + } + + + + + + + +@if(showPublishedDate) +{ + +} + + + + + +   Nothing to see here. Carry on! + + + + + + + +@code { + private IQueryable? posts; + IQueryable? filteredUrlList => posts?.Where(x => x.Title!.Contains(titleFilter, StringComparison.CurrentCultureIgnoreCase)); + private GridSort defSort = GridSort.ByDescending(c => c.Date_published); + private string newPostUrl = string.Empty; + private bool showRead = false; + private PaginationState pagination = new PaginationState { ItemsPerPage = 20 }; + private string titleFilter = string.Empty; + private bool showPublishedDate = false; + private int syncProgressCurrent = 0; + private int syncProgressTotal = 0; + private string syncProgressStatus = string.Empty; + + protected override async Task OnInitializedAsync() + { + client.SyncProgressChanged += OnSyncProgressChanged; + await LoadPosts(); + _ = StartBackgroundSync(); + } + + private async Task LoadPosts() + { + List loadedPosts = showRead ? await client.GetReadPosts(): await client.GetUnreadPosts(); + posts = loadedPosts.AsQueryable(); + } + + private async Task StartBackgroundSync() + { + if (client.IsOffline) return; + + try + { + isSyncing = true; + await client.SyncAsync(); + await LoadPosts(); + } + catch (Exception) + { + // Ignore background sync errors + } + finally + { + isSyncing = false; + StateHasChanged(); + } + } + + private async Task OpenUrlInNewWindow(string? url) + { + await urlLauncher.OpenUrlAsync(url); + } + + private async Task CreateNoteForPost(string postId) + { + var newNote = new Note { PostId = postId }; + + IDialogReference dialog = await DialogService.ShowDialogAsync(newNote, new DialogParameters(){ + Title = "Add a note", + PreventDismissOnOverlayClick = true, + PreventScroll = true, + PrimaryAction = "Save", + SecondaryAction = "Cancel" + }); + + var result = await dialog.Result; + if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) + { + if (dialogResult.Action == "Save" && dialogResult.Note != null) + { + await client.CreateNote(dialogResult.Note); + toastService.ShowSuccess("Note created successfully!"); + await LoadPosts(); + } + } + } + + private void EditNote(string postId) + { + Navigation.NavigateTo($"posteditor/{postId}"); + } + + private async Task EditNoteForPost(string noteId) + { + try + { + var existingNote = await client.GetNote(noteId); + if (existingNote == null) + { + toastService.ShowError("Note not found."); + return; + } + + IDialogReference dialog = await DialogService.ShowDialogAsync(existingNote, new DialogParameters(){ + Title = "Edit note", + PreventDismissOnOverlayClick = true, + PreventScroll = true, + }); + + var result = await dialog.Result; + if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) + { + if (dialogResult.Action == "Delete" && dialogResult.Note != null) + { + var deleteResult = await client.DeleteNote(dialogResult.Note.RowKey); + if (deleteResult) + { + toastService.ShowSuccess("Note deleted successfully!"); + await LoadPosts(); + } + else + { + toastService.ShowError("Failed to delete note. Please try again."); + } + } + else if (dialogResult.Action == "Save" && dialogResult.Note != null) + { + var updateResult = await client.UpdateNote(dialogResult.Note); + if (updateResult) + { + toastService.ShowSuccess("Note updated successfully!"); + await LoadPosts(); + } + else + { + toastService.ShowError("Failed to update note. Please try again."); + } + } + } + } + catch (Exception) + { + toastService.ShowError("An error occurred. Please try again."); + } + } + + private async Task AddNewPost() + { + if (!string.IsNullOrEmpty(newPostUrl)) + { + var result = await client.ExtractPostDetailsAndSave(newPostUrl); + if (result != null) + { + await LoadPosts(); + newPostUrl = string.Empty; + toastService.ShowSuccess("Post added successfully!"); + } + else + { + toastService.ShowError("Failed to add post. Please try again."); + } + } + } + + private async Task DeletePost(string postId) + { + var result = await client.DeletePost(postId); + if (result) + { + await LoadPosts(); + toastService.ShowSuccess("Post deleted successfully!"); + } + else + { + toastService.ShowError("Failed to delete post. Please try again."); + } + } + + private async Task OnShowReadChanged(bool value) + { + showRead = value; + await LoadPosts(); + } + + private void HandleTitleFilter(ChangeEventArgs args) + { + if (args.Value is string value) + { + titleFilter = value; + } + } + + private void HandleClearTitleFilter() + { + if (string.IsNullOrWhiteSpace(titleFilter)) + { + titleFilter = string.Empty; + } + } + + private bool isSyncing = false; + + private async Task SyncNow() + { + if (isSyncing || client.IsSyncing) return; + isSyncing = true; + try + { + toastService.ShowInfo("Synchronization started..."); + await client.SyncAsync(); + toastService.ShowSuccess("Synchronization completed successfully!"); + await LoadPosts(); + } + catch (Exception ex) + { + toastService.ShowError($"Synchronization failed: {ex.Message}"); + } + finally + { + isSyncing = false; + StateHasChanged(); + } + } + + private void ToggleShowPublishedDate() + { + showPublishedDate = !showPublishedDate; + } + + private void ReadPost(string postId) => Navigation.NavigateTo($"postreader/{postId}"); + + private void OnSyncProgressChanged(object? sender, SyncProgressEventArgs e) + { + InvokeAsync(async () => + { + syncProgressCurrent = e.Current; + syncProgressTotal = e.Total; + syncProgressStatus = e.Status; + StateHasChanged(); + + if (e.IsComplete) + { + isSyncing = false; + await LoadPosts(); + StateHasChanged(); + + await Task.Delay(2500); + if (!client.IsSyncing && syncProgressStatus == e.Status) + { + syncProgressStatus = string.Empty; + syncProgressCurrent = 0; + syncProgressTotal = 0; + StateHasChanged(); + } + } + }); + } + + public void Dispose() + { + client.SyncProgressChanged -= OnSyncProgressChanged; + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor b/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor new file mode 100644 index 0000000..7d836ad --- /dev/null +++ b/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor @@ -0,0 +1,162 @@ +@page "/quickadd" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@using NoteBookmark.Domain +@inject IDataService client +@inject NavigationManager Navigation +@inject IJSRuntime JsRuntime +@layout MinimalLayout + +Quick Add - NoteBookmark + +
+ + Quick Add Post + + @if (isLoading) + { + + + Extracting and saving post details... + + } + else if (isSuccess && post != null) + { + + Post saved successfully! + + + + +
+ Title + @(string.IsNullOrWhiteSpace(post.Title) ? "Untitled" : post.Title) +
+ +
+ Author + @(string.IsNullOrWhiteSpace(post.Author) ? "Unknown" : post.Author) +
+ +
+ URL +
+ @post.Url +
+
+
+
+ + + Close Window + Edit + + } + else + { + @if (!string.IsNullOrEmpty(errorMessage)) + { + + @errorMessage + + } + + + + + Close Window + Add + + + } +
+
+ +@code { + [SupplyParameterFromQuery(Name = "url")] + public string? Url { get; set; } + + [SupplyParameterFromQuery(Name = "addUrl")] + public string? AddUrl { get; set; } + + private Post? post; + private bool isLoading = false; + private bool isSuccess = false; + private string? errorMessage; + private string? manualUrl; + private string? _processedUrl; + + protected override async Task OnParametersSetAsync() + { + var targetUrl = !string.IsNullOrWhiteSpace(Url) ? Url : AddUrl; + if (!string.IsNullOrWhiteSpace(targetUrl) && targetUrl != _processedUrl) + { + _processedUrl = targetUrl; + await ProcessUrlAsync(targetUrl); + } + } + + private async Task ProcessUrlAsync(string targetUrl) + { + if (client.IsOffline) + { + errorMessage = "Cannot extract posts while offline."; + isLoading = false; + return; + } + + isLoading = true; + errorMessage = null; + isSuccess = false; + + try + { + post = await client.ExtractPostDetailsAndSave(targetUrl); + if (post != null) + { + isSuccess = true; + } + else + { + errorMessage = "Failed to extract and save post details. Please check the URL and try again."; + } + } + catch (Exception ex) + { + errorMessage = $"An error occurred: {ex.Message}"; + } + finally + { + isLoading = false; + } + } + + private async Task ProcessManualUrl() + { + if (!string.IsNullOrWhiteSpace(manualUrl)) + { + await ProcessUrlAsync(manualUrl); + } + } + + private async Task CloseWindow() + { + try + { + await JsRuntime.InvokeVoidAsync("window.close"); + } + catch + { + // Prerender or context without window.close support + } + } + + private void NavigateToEdit() + { + var rowKey = post?.RowKey ?? post?.Id; + if (!string.IsNullOrEmpty(rowKey)) + { + Navigation.NavigateTo($"posteditorlight/{rowKey}"); + } + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor b/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor index 5b65bab..e319833 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor @@ -101,7 +101,29 @@ Save - + + + + + Browser Bookmarklet + + Drag the button below to your browser's bookmarks bar to quickly save articles from any webpage directly into NoteBookmark: + + + + + + + Add to NoteBookmark + + + + Tip: If dragging is not supported, right-click the button, copy the link, and paste it as the URL of a new browser bookmark. + +
} @@ -154,4 +176,7 @@ var cnt = Convert.ToInt32(settings!.ReadingNotesCounter) + 1; settings.ReadingNotesCounter = (cnt).ToString(); } + + private string BookmarkletCode => + $"javascript:(function(){{var u=encodeURIComponent(window.location.href);window.open('{Navigation.BaseUri}quickadd?url='+u,'nb_quickadd','width=480,height=440,menubar=no,toolbar=no');}})();"; } diff --git a/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor b/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor index 6587d7d..ad47fc3 100644 --- a/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor +++ b/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor @@ -50,7 +50,7 @@ if (postURL != null) { var result = await client.ExtractPostDetailsAndSave(postURL); - if (result) + if (result != null) { Suggestions!.Remove(Suggestions.First(x => x.Url == postURL)); StateHasChanged(); diff --git a/src/NoteBookmark.SharedUI/IDataService.cs b/src/NoteBookmark.SharedUI/IDataService.cs index b5a0887..f95c1b3 100644 --- a/src/NoteBookmark.SharedUI/IDataService.cs +++ b/src/NoteBookmark.SharedUI/IDataService.cs @@ -1,32 +1,32 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using NoteBookmark.Domain; - -namespace NoteBookmark.SharedUI; - -public interface IDataService -{ - Task> GetUnreadPosts(); - Task> GetReadPosts(); - Task> GetSummaries(); - Task CreateNote(Note note); - Task GetNote(string noteId); - Task UpdateNote(Note note); - Task DeleteNote(string noteId); - Task CreateReadingNotes(); - Task GetReadingNotes(string number); - Task SaveReadingNotes(ReadingNotes readingNotes); - Task GetPost(string id); - Task SavePost(Post post); - Task GetSettings(); - Task SaveSettings(Settings settings); - Task ExtractPostDetailsAndSave(string url); - Task DeletePost(string id); - Task SaveReadingNotesMarkdown(string markdown, string number); - Task GetPostHtmlAsync(string postId); - Task SyncAsync(); - event System.EventHandler? SyncProgressChanged; - bool IsSyncing { get; } - bool IsOffline { get; } - bool CanSync { get; } -} +using System.Collections.Generic; +using System.Threading.Tasks; +using NoteBookmark.Domain; + +namespace NoteBookmark.SharedUI; + +public interface IDataService +{ + Task> GetUnreadPosts(); + Task> GetReadPosts(); + Task> GetSummaries(); + Task CreateNote(Note note); + Task GetNote(string noteId); + Task UpdateNote(Note note); + Task DeleteNote(string noteId); + Task CreateReadingNotes(); + Task GetReadingNotes(string number); + Task SaveReadingNotes(ReadingNotes readingNotes); + Task GetPost(string id); + Task SavePost(Post post); + Task GetSettings(); + Task SaveSettings(Settings settings); + Task ExtractPostDetailsAndSave(string url); + Task DeletePost(string id); + Task SaveReadingNotesMarkdown(string markdown, string number); + Task GetPostHtmlAsync(string postId); + Task SyncAsync(); + event System.EventHandler? SyncProgressChanged; + bool IsSyncing { get; } + bool IsOffline { get; } + bool CanSync { get; } +} diff --git a/src/NoteBookmark.SharedUI/PostNoteClient.cs b/src/NoteBookmark.SharedUI/PostNoteClient.cs index b0245a8..8b8acd2 100644 --- a/src/NoteBookmark.SharedUI/PostNoteClient.cs +++ b/src/NoteBookmark.SharedUI/PostNoteClient.cs @@ -158,11 +158,15 @@ public async Task SaveSettings(Settings settings) return response.IsSuccessStatusCode; } - public async Task ExtractPostDetailsAndSave(string url) + public async Task ExtractPostDetailsAndSave(string url) { var requestBody = new { url = url }; var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails", requestBody); - return response.IsSuccessStatusCode; + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(); + } + return null; } public async Task DeletePost(string id) From 73a2d764d8382654191e469271311a52da2241cd Mon Sep 17 00:00:00 2001 From: Frank Boucher <2404846+fboucher@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:44:01 -0400 Subject: [PATCH 24/25] Speed up MAUI app sync (#208) (#209) * Speed up MAUI sync: delta pull, batched saves, off-UI-thread (#208) - Pull only posts modified since the last sync; detect server deletions with a new lightweight GET api/posts/ids endpoint (falls back to the full list on first sync or on servers without the endpoint). - Save pulled posts in one SQLite transaction and remove deleted posts in one batch. - Fetch full details for unread posts with at most 5 concurrent requests. - Run sync on the thread pool and throttle progress events to ~250 ms. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Kq68qA7Jv7puAF3Kc6GSFY * Restore CRLF line endings in LocalDataService and PostNoteClient Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Kq68qA7Jv7puAF3Kc6GSFY --------- Co-authored-by: Claude --- .../Endpoints/DeltaApiTests.cs | 39 ++++ src/NoteBookmark.Api/DataStorageService.cs | 10 + src/NoteBookmark.Api/IDataStorageService.cs | 2 + src/NoteBookmark.Api/PostEndpoints.cs | 8 + .../LocalDataServiceTests.cs | 40 ++++ .../SyncServiceTests.cs | 194 ++++++++++++++-- .../Data/ILocalDataService.cs | 2 + .../Data/ISyncApiClient.cs | 2 + .../Data/LocalDataService.cs | 16 ++ .../Data/SyncApiClient.cs | 1 + src/NoteBookmark.MauiApp/Data/SyncService.cs | 208 ++++++++++-------- src/NoteBookmark.SharedUI/PostNoteClient.cs | 11 + 12 files changed, 428 insertions(+), 105 deletions(-) diff --git a/src/NoteBookmark.Api.Tests/Endpoints/DeltaApiTests.cs b/src/NoteBookmark.Api.Tests/Endpoints/DeltaApiTests.cs index ca597ec..7c794a8 100644 --- a/src/NoteBookmark.Api.Tests/Endpoints/DeltaApiTests.cs +++ b/src/NoteBookmark.Api.Tests/Endpoints/DeltaApiTests.cs @@ -290,6 +290,45 @@ private async Task CreateAndSaveTestPost(string rowKey) return post; } + // ── Post ids ───────────────────────────────────────────────────────────── + + [Fact] + public async Task GetPostIds_ReturnsIdsOfReadAndUnreadPosts() + { + // Arrange + var unreadPost = CreateTestPost("ids-unread-post-1"); + var readPost = CreateTestPost("ids-read-post-1"); + readPost.is_read = true; + await _client.PostAsJsonAsync("/api/posts/", unreadPost); + await _client.PostAsJsonAsync("/api/posts/", readPost); + + // Act + var response = await _client.GetAsync("/api/posts/ids"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var ids = await response.Content.ReadFromJsonAsync>(); + ids.Should().NotBeNull(); + ids.Should().Contain(unreadPost.RowKey); + ids.Should().Contain(readPost.RowKey); + } + + [Fact] + public async Task GetPostIds_DoesNotReturnDeletedPosts() + { + // Arrange + var post = CreateTestPost("ids-deleted-post-1"); + await _client.PostAsJsonAsync("/api/posts/", post); + await _client.DeleteAsync($"/api/posts/{post.RowKey}"); + + // Act + var ids = await _client.GetFromJsonAsync>("/api/posts/ids"); + + // Assert + ids.Should().NotBeNull(); + ids.Should().NotContain(post.RowKey); + } + private static Post CreateTestPost(string rowKey) { return new Post diff --git a/src/NoteBookmark.Api/DataStorageService.cs b/src/NoteBookmark.Api/DataStorageService.cs index 45abbb4..6bfc50f 100644 --- a/src/NoteBookmark.Api/DataStorageService.cs +++ b/src/NoteBookmark.Api/DataStorageService.cs @@ -123,6 +123,16 @@ orderby post.Timestamp return lstPosts; } + public List GetPostIds() + { + var tblPosts = GetPostTable(); + // Only project the key columns so the query stays small even with thousands of posts. + // Clients identify posts by Id, falling back to RowKey, so return the same value. + return tblPosts.Query(select: new[] { "RowKey", "id", "Id" }) + .Select(p => p.Id ?? p.RowKey) + .ToList(); + } + public Post? GetPost(string rowKey) { var tblPost = GetPostTable(); diff --git a/src/NoteBookmark.Api/IDataStorageService.cs b/src/NoteBookmark.Api/IDataStorageService.cs index 85f9473..fbc8704 100644 --- a/src/NoteBookmark.Api/IDataStorageService.cs +++ b/src/NoteBookmark.Api/IDataStorageService.cs @@ -7,6 +7,8 @@ namespace NoteBookmark.Api; public interface IDataStorageService { public List GetFilteredPosts(string filter); + + public List GetPostIds(); public Post? GetPost(string rowKey); diff --git a/src/NoteBookmark.Api/PostEndpoints.cs b/src/NoteBookmark.Api/PostEndpoints.cs index 66a8a7e..ec85fd2 100644 --- a/src/NoteBookmark.Api/PostEndpoints.cs +++ b/src/NoteBookmark.Api/PostEndpoints.cs @@ -19,6 +19,8 @@ public static void MapPostEndpoints(this IEndpointRouteBuilder app) .WithDescription("Get all unread posts"); endpoints.MapGet("/read", GetReadPosts) .WithDescription("Get all read posts"); + endpoints.MapGet("/ids", GetPostIds) + .WithDescription("Get the ids of all existing posts (read and unread)"); endpoints.MapGet("/{id}", Get) .WithDescription("Get a post by id"); endpoints.MapGet("/{postId}/html", GetPostHtml) @@ -57,6 +59,12 @@ static List GetReadPosts(TableServiceClient tblClient, BlobServiceClient return posts; } + static List GetPostIds(TableServiceClient tblClient, BlobServiceClient blobClient) + { + var dataStorageService = new DataStorageService(tblClient, blobClient); + return dataStorageService.GetPostIds(); + } + static async Task>> GetPostHtml( string postId, BlobServiceClient blobClient) { diff --git a/src/NoteBookmark.MauiApp.Tests/LocalDataServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/LocalDataServiceTests.cs index c3311d0..92b3830 100644 --- a/src/NoteBookmark.MauiApp.Tests/LocalDataServiceTests.cs +++ b/src/NoteBookmark.MauiApp.Tests/LocalDataServiceTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using NoteBookmark.Domain; @@ -161,4 +162,43 @@ public async Task SaveSettings_ShouldStoreAndRetrieveFontSize() retrieved.Should().NotBeNull(); retrieved!.FontSize.Should().Be("large"); } + + [Fact] + public async Task RemovePostsAsync_ShouldPermanentlyRemoveOnlyGivenPosts() + { + await _sut.SavePostsAsync(new[] + { + new Post { Id = "p1", PartitionKey = "pk", RowKey = "p1", Title = "One", DateModified = DateTime.UtcNow }, + new Post { Id = "p2", PartitionKey = "pk", RowKey = "p2", Title = "Two", DateModified = DateTime.UtcNow }, + new Post { Id = "p3", PartitionKey = "pk", RowKey = "p3", Title = "Three", DateModified = DateTime.UtcNow } + }); + await _sut.SavePostAsync(new Post { Id = "p2", PartitionKey = "pk", RowKey = "p2", Title = "Two edited" }, isPendingSync: true); + + await _sut.RemovePostsAsync(new[] { "p1", "p2", "missing" }); + + var remaining = await _sut.GetPostsAsync(); + remaining.Select(p => p.Id).Should().BeEquivalentTo(new[] { "p3" }); + (await _sut.GetPendingSyncPostsAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task RemovePostsAsync_WithNoIds_ShouldDoNothing() + { + await _sut.SavePostAsync(new Post { Id = "p1", PartitionKey = "pk", RowKey = "p1", Title = "One" }); + + await _sut.RemovePostsAsync(Array.Empty()); + + (await _sut.GetPostsAsync()).Should().HaveCount(1); + } + + [Fact] + public async Task SavePostsAsync_ShouldPreservePendingSyncFlag_ForLocallyEditedPosts() + { + await _sut.SavePostAsync(new Post { Id = "p1", PartitionKey = "pk", RowKey = "p1", Title = "Local edit" }, isPendingSync: true); + + await _sut.SavePostsAsync(new[] { new Post { Id = "p1", PartitionKey = "pk", RowKey = "p1", Title = "From server" } }); + + var pending = await _sut.GetPendingSyncPostsAsync(); + pending.Should().ContainSingle(p => p.Id == "p1"); + } } diff --git a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs index 4204ab5..b1fe4f2 100644 --- a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs +++ b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Moq; @@ -123,11 +125,11 @@ public async Task PullPhase_RemoteNewer_ShouldOverwriteLocal() .ReturnsAsync(new List { remotePostL }); _apiClientMock.Setup(c => c.GetPost("post1")).ReturnsAsync(remotePost); _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); - _localDataServiceMock.Setup(c => c.GetPostAsync("post1")).ReturnsAsync(localPost); + _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List { localPost }); await _sut.SyncAsync(); - _localDataServiceMock.Verify(c => c.SavePostAsync(It.Is(p => p.Title == "Remote"), false), Times.Once); + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.Is>(ps => ps.Single().Title == "Remote")), Times.Once); } [Fact] @@ -156,11 +158,12 @@ public async Task PullPhase_LocalNewer_ShouldKeepLocal() _apiClientMock.Setup(c => c.GetPostsModifiedAfter(It.IsAny())) .ReturnsAsync(new List { remotePostL }); _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); - _localDataServiceMock.Setup(c => c.GetPostAsync("post1")).ReturnsAsync(localPost); + _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List { localPost }); await _sut.SyncAsync(); - _localDataServiceMock.Verify(c => c.SavePostAsync(It.IsAny(), false), Times.Never); + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.IsAny>()), Times.Never); + _localDataServiceMock.Verify(c => c.SavePostAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -190,11 +193,10 @@ public async Task PullPhase_NoLocal_ShouldSaveRemote() .ReturnsAsync(new List { remotePostL }); _apiClientMock.Setup(c => c.GetPost("post1")).ReturnsAsync(remotePost); _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); - _localDataServiceMock.Setup(c => c.GetPostAsync("post1")).ReturnsAsync((Post?)null); await _sut.SyncAsync(); - _localDataServiceMock.Verify(c => c.SavePostAsync(It.Is(p => p.Title == "Remote"), false), Times.Once); + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.Is>(ps => ps.Single().Title == "Remote")), Times.Once); } [Fact] @@ -266,8 +268,7 @@ public async Task PullPhase_ShouldDeleteLocalPosts_WhenDeletedOnServer() await _sut.SyncAsync(); - _localDataServiceMock.Verify(c => c.DeletePostAsync("post1", false), Times.Once); - _localDataServiceMock.Verify(c => c.MarkSyncedAsync("post1", true), Times.Once); + _localDataServiceMock.Verify(c => c.RemovePostsAsync(It.Is>(ids => ids.SequenceEqual(new[] { "post1" }))), Times.Once); } [Fact] @@ -296,14 +297,13 @@ public async Task PullPhase_ShouldAddNewPosts_WhenAddedOnServer() _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List()); _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)) .ReturnsAsync(new List { remotePostL }); - _localDataServiceMock.Setup(c => c.GetPostAsync("post1")).ReturnsAsync((Post?)null); _apiClientMock.Setup(c => c.GetPost("post1")).ReturnsAsync(remotePost); _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); await _sut.SyncAsync(); _apiClientMock.Verify(c => c.GetPost("post1"), Times.Once); - _localDataServiceMock.Verify(c => c.SavePostAsync(remotePost, false), Times.Once); + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.Is>(ps => ps.Single() == remotePost)), Times.Once); } [Fact] @@ -467,7 +467,7 @@ public async Task SyncAsync_ShouldRaiseSyncProgressChanged_WhenDownloadingPostHt progressEvents.Should().NotBeEmpty(); progressEvents.Should().Contain(e => e.Status == "Cleaning..."); - progressEvents.Should().Contain(e => e.Status == "Downloading 1 of 2 posts..." && e.Current == 1 && e.Total == 2); + progressEvents.Should().Contain(e => e.Status == "Downloading 0 of 2 posts..." && e.Current == 0 && e.Total == 2); progressEvents.Should().Contain(e => e.Status == "Downloading 2 of 2 posts..." && e.Current == 2 && e.Total == 2); progressEvents.Last().Status.Should().Be("Synchronization complete!"); progressEvents.Last().IsComplete.Should().BeTrue(); @@ -495,7 +495,7 @@ public async Task PullPhase_ReadPosts_ShouldNotCallGetPost_AndShouldSaveDirectly // GetPost should NOT be called for read posts _apiClientMock.Verify(c => c.GetPost("read1"), Times.Never); - _localDataServiceMock.Verify(c => c.SavePostAsync(It.Is(p => p.Id == "read1" && p.is_read == true), false), Times.Once); + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.Is>(ps => ps.Single().Id == "read1" && ps.Single().is_read == true)), Times.Once); } [Fact] @@ -520,7 +520,7 @@ public async Task PullPhase_UnreadPost_WhenGetPostFails_ShouldFallbackToBasicPos await _sut.SyncAsync(); // Should fall back and save basic post without throwing - _localDataServiceMock.Verify(c => c.SavePostAsync(It.Is(p => p.Id == "unread1" && p.Title == "Unread Post"), false), Times.Once); + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.Is>(ps => ps.Single().Id == "unread1" && ps.Single().Title == "Unread Post")), Times.Once); } [Fact] @@ -540,7 +540,6 @@ public async Task PullPhase_ShouldReportProgress_WhenPullingPosts() await _sut.SyncAsync(); progressEvents.Should().Contain(e => e.Status == "Pulling 0 of 2 posts..." && e.Current == 0 && e.Total == 2); - progressEvents.Should().Contain(e => e.Status == "Pulling 1 of 2 posts..." && e.Current == 1 && e.Total == 2); progressEvents.Should().Contain(e => e.Status == "Pulling 2 of 2 posts..." && e.Current == 2 && e.Total == 2); } @@ -581,5 +580,170 @@ public async Task IsSyncing_ShouldReflectActiveSyncTask() _sut.IsSyncing.Should().BeFalse(); } -} + // ── Issue #208: delta pull, deletion detection, batched save ───────────── + + private static PostL RemotePostL(string id, bool isRead = true, DateTime? modified = null) => new() + { + Id = id, + RowKey = id, + PartitionKey = "pk", + Title = $"Title {id}", + is_read = isRead, + DateModified = modified ?? DateTime.UtcNow + }; + + private static Post LocalPost(string id, DateTime? modified = null) => new() + { + Id = id, + RowKey = id, + PartitionKey = "pk", + Title = $"Title {id}", + is_read = true, + DateModified = modified ?? DateTime.UtcNow.AddDays(-1) + }; + + private void SetupEmptyPushAndNotes() + { + _localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + } + + [Fact] + public async Task PullPhase_WithLastSync_ShouldOnlyPullDelta_AndUsePostIdsForDeletions() + { + var lastSync = DateTime.UtcNow.AddHours(-1); + SyncService.SetInMemoryPreference("LastSyncTimestamp", lastSync.ToString("O")); + SetupEmptyPushAndNotes(); + + _localDataServiceMock.Setup(c => c.GetPostsAsync()) + .ReturnsAsync(new List { LocalPost("p1"), LocalPost("p2"), LocalPost("p3") }); + _apiClientMock.Setup(c => c.GetPostIds()).ReturnsAsync(new List { "p1", "p3", "new" }); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(It.Is(d => d > DateTime.MinValue))) + .ReturnsAsync(new List { RemotePostL("new") }); + + await _sut.SyncAsync(); + + // The full post list is never requested once we have a last sync time. + _apiClientMock.Verify(c => c.GetPostsModifiedAfter(DateTime.MinValue), Times.Never); + _apiClientMock.Verify(c => c.GetPostsModifiedAfter(It.Is(d => Math.Abs((d - lastSync).TotalSeconds) < 1)), Times.Once); + _localDataServiceMock.Verify(c => c.RemovePostsAsync(It.Is>(ids => ids.SequenceEqual(new[] { "p2" }))), Times.Once); + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.Is>(ps => ps.Select(p => p.Id).SequenceEqual(new[] { "new" }))), Times.Once); + _localDataServiceMock.Verify(c => c.SavePostAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PullPhase_WithLastSync_WhenPostIdsEndpointUnavailable_ShouldFallBackToFullListForDeletions() + { + SyncService.SetInMemoryPreference("LastSyncTimestamp", DateTime.UtcNow.AddHours(-1).ToString("O")); + SetupEmptyPushAndNotes(); + + _localDataServiceMock.Setup(c => c.GetPostsAsync()) + .ReturnsAsync(new List { LocalPost("p1"), LocalPost("p2") }); + _apiClientMock.Setup(c => c.GetPostIds()).ReturnsAsync((List?)null); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(It.Is(d => d > DateTime.MinValue))) + .ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)) + .ReturnsAsync(new List { RemotePostL("p1", modified: DateTime.UtcNow.AddDays(-2)) }); + + await _sut.SyncAsync(); + + _localDataServiceMock.Verify(c => c.RemovePostsAsync(It.Is>(ids => ids.SequenceEqual(new[] { "p2" }))), Times.Once); + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.IsAny>()), Times.Never); + } + + [Fact] + public async Task PullPhase_FirstSync_ShouldUseFullListForDeletions_WithoutCallingPostIds() + { + SetupEmptyPushAndNotes(); + _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List { LocalPost("gone") }); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)) + .ReturnsAsync(new List { RemotePostL("p1") }); + + await _sut.SyncAsync(); + + _apiClientMock.Verify(c => c.GetPostIds(), Times.Never); + _localDataServiceMock.Verify(c => c.RemovePostsAsync(It.Is>(ids => ids.SequenceEqual(new[] { "gone" }))), Times.Once); + } + + [Fact] + public async Task PullPhase_NoDeletedPosts_ShouldNotCallRemovePosts() + { + SetupEmptyPushAndNotes(); + _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List { LocalPost("p1") }); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)) + .ReturnsAsync(new List { RemotePostL("p1", modified: DateTime.UtcNow.AddDays(-2)) }); + + await _sut.SyncAsync(); + + _localDataServiceMock.Verify(c => c.RemovePostsAsync(It.IsAny>()), Times.Never); + } + + [Fact] + public async Task PullPhase_ManyPosts_ShouldSaveInOneBatch() + { + SetupEmptyPushAndNotes(); + var remotePosts = Enumerable.Range(0, 1500).Select(i => RemotePostL($"p{i}")).ToList(); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)).ReturnsAsync(remotePosts); + + await _sut.SyncAsync(); + + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.Is>(ps => ps.Count() == 1500)), Times.Once); + _localDataServiceMock.Verify(c => c.SavePostAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PullPhase_DuplicateRowsForSamePost_ShouldSaveItOnce() + { + // The API joins posts with notes, so a post with two notes comes back twice. + SetupEmptyPushAndNotes(); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)) + .ReturnsAsync(new List { RemotePostL("p1"), RemotePostL("p1") }); + + await _sut.SyncAsync(); + + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.Is>(ps => ps.Count() == 1)), Times.Once); + } + + [Fact] + public async Task PullPhase_ManyPosts_ShouldThrottleProgressEvents() + { + SetupEmptyPushAndNotes(); + var remotePosts = Enumerable.Range(0, 500).Select(i => RemotePostL($"p{i}")).ToList(); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)).ReturnsAsync(remotePosts); + + var progressEvents = new List(); + _sut.SyncProgressChanged += (sender, args) => { lock (progressEvents) progressEvents.Add(args); }; + + await _sut.SyncAsync(); + + var pullingEvents = progressEvents.Where(e => e.Status.StartsWith("Pulling ") && e.Total == 500).ToList(); + pullingEvents.Should().HaveCountLessThan(50); + pullingEvents.Should().Contain(e => e.Current == 0); + pullingEvents.Should().Contain(e => e.Current == 500 && e.Status == "Pulling 500 of 500 posts..."); + } + + [Fact] + public async Task PullPhase_UnreadPosts_ShouldFetchFullPostsWithLimitedConcurrency() + { + SetupEmptyPushAndNotes(); + var remotePosts = Enumerable.Range(0, 20).Select(i => RemotePostL($"u{i}", isRead: false)).ToList(); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)).ReturnsAsync(remotePosts); + + int inFlight = 0, maxInFlight = 0; + _apiClientMock.Setup(c => c.GetPost(It.IsAny())).Returns(async (string id) => + { + var now = Interlocked.Increment(ref inFlight); + lock (_apiClientMock) maxInFlight = Math.Max(maxInFlight, now); + await Task.Delay(20); + Interlocked.Decrement(ref inFlight); + return new Post { Id = id, RowKey = id, PartitionKey = "pk", Title = $"Full {id}", is_read = false }; + }); + + await _sut.SyncAsync(); + + maxInFlight.Should().BeGreaterThan(1).And.BeLessThanOrEqualTo(5); + _localDataServiceMock.Verify(c => c.SavePostsAsync(It.Is>(ps => + ps.Count() == 20 && ps.All(p => p.Title!.StartsWith("Full ")))), Times.Once); + } +} diff --git a/src/NoteBookmark.MauiApp/Data/ILocalDataService.cs b/src/NoteBookmark.MauiApp/Data/ILocalDataService.cs index a20da8b..9689add 100644 --- a/src/NoteBookmark.MauiApp/Data/ILocalDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/ILocalDataService.cs @@ -15,6 +15,8 @@ public interface ILocalDataService Task SaveNoteAsync(Note note, bool isPendingSync = false); Task DeleteNoteAsync(string rowKey, bool isPendingSync = false); Task DeletePostAsync(string rowKey, bool isPendingSync = false); + /// Permanently removes posts that no longer exist on the server, in a single transaction. + Task RemovePostsAsync(IEnumerable ids); Task> GetSummariesAsync(); Task SaveSummariesAsync(IEnumerable summaries); Task GetSettingsAsync(); diff --git a/src/NoteBookmark.MauiApp/Data/ISyncApiClient.cs b/src/NoteBookmark.MauiApp/Data/ISyncApiClient.cs index c71807a..52da789 100644 --- a/src/NoteBookmark.MauiApp/Data/ISyncApiClient.cs +++ b/src/NoteBookmark.MauiApp/Data/ISyncApiClient.cs @@ -9,6 +9,8 @@ namespace NoteBookmark.MauiApp.Data; public interface ISyncApiClient { Task> GetPostsModifiedAfter(DateTime modifiedAfter); + /// Ids of all posts on the server, or null if the server does not support it. + Task?> GetPostIds(); Task> GetNotesModifiedAfter(DateTime modifiedAfter); Task GetPost(string id); Task GetNote(string rowKey); diff --git a/src/NoteBookmark.MauiApp/Data/LocalDataService.cs b/src/NoteBookmark.MauiApp/Data/LocalDataService.cs index a9da479..4c3bd46 100644 --- a/src/NoteBookmark.MauiApp/Data/LocalDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/LocalDataService.cs @@ -170,6 +170,22 @@ public async Task DeletePostAsync(string id, bool isPendingSync = false) } } + public async Task RemovePostsAsync(IEnumerable ids) + { + await InitAsync(); + var idList = ids.ToList(); + if (idList.Count == 0) + return; + + await _database.RunInTransactionAsync(conn => + { + foreach (var id in idList) + { + conn.Delete(id); + } + }); + } + public async Task> GetSummariesAsync() { await InitAsync(); diff --git a/src/NoteBookmark.MauiApp/Data/SyncApiClient.cs b/src/NoteBookmark.MauiApp/Data/SyncApiClient.cs index 61a76ef..da32823 100644 --- a/src/NoteBookmark.MauiApp/Data/SyncApiClient.cs +++ b/src/NoteBookmark.MauiApp/Data/SyncApiClient.cs @@ -10,6 +10,7 @@ namespace NoteBookmark.MauiApp.Data; public class SyncApiClient(PostNoteClient client) : ISyncApiClient { public Task> GetPostsModifiedAfter(DateTime modifiedAfter) => client.GetPostsModifiedAfter(modifiedAfter); + public Task?> GetPostIds() => client.GetPostIds(); public Task> GetNotesModifiedAfter(DateTime modifiedAfter) => client.GetNotesModifiedAfter(modifiedAfter); public Task GetPost(string id) => client.GetPost(id); public Task GetNote(string rowKey) => client.GetNote(rowKey); diff --git a/src/NoteBookmark.MauiApp/Data/SyncService.cs b/src/NoteBookmark.MauiApp/Data/SyncService.cs index a82698b..ba0a877 100644 --- a/src/NoteBookmark.MauiApp/Data/SyncService.cs +++ b/src/NoteBookmark.MauiApp/Data/SyncService.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using NoteBookmark.Domain; @@ -27,6 +28,8 @@ public class SyncService( ILocalHtmlStorageService localHtmlStorageService) : ISyncService { private const string LastSyncTimestampKey = "LastSyncTimestamp"; + private const int MaxConcurrentRequests = 5; + private static readonly TimeSpan ProgressReportInterval = TimeSpan.FromMilliseconds(250); private readonly object _syncLock = new(); private Task? _currentSyncTask; @@ -53,7 +56,8 @@ public Task SyncAsync() return _currentSyncTask; } - _currentSyncTask = DoSyncAsync(); + // Run on the thread pool so callers on the UI thread (e.g. Blazor pages) never block on sync work. + _currentSyncTask = Task.Run(DoSyncAsync); return _currentSyncTask; } } @@ -209,108 +213,48 @@ private async Task PushAsync(DateTime? lastSync) private async Task PullAsync(DateTime? lastSync) { - // 1. Get all remote posts - var allRemotePosts = await apiClient.GetPostsModifiedAfter(DateTime.MinValue) ?? new List(); - var remotePostIds = allRemotePosts.Select(p => p.Id ?? p.RowKey).ToHashSet(); + // 1. Only fetch posts modified since the last sync. On the first sync this is every post. + var changedRemotePosts = await apiClient.GetPostsModifiedAfter(lastSync ?? DateTime.MinValue) ?? new List(); // 2. Any post that was deleted on the online database while offline should be deleted locally. + var remotePostIds = await GetRemotePostIdsAsync(lastSync, changedRemotePosts); var localPosts = await localDataService.GetPostsAsync() ?? new List(); var localPostMap = localPosts.ToDictionary(p => p.Id ?? p.RowKey); - foreach (var localPost in localPosts) + var deletedIds = localPostMap.Keys.Where(id => !remotePostIds.Contains(id)).ToList(); + if (deletedIds.Count > 0) { - var id = localPost.Id ?? localPost.RowKey; - if (!remotePostIds.Contains(id)) + await localDataService.RemovePostsAsync(deletedIds); + foreach (var id in deletedIds) { - await localDataService.DeletePostAsync(id, isPendingSync: false); - await localDataService.MarkSyncedAsync(id, isPost: true); localPostMap.Remove(id); } } - // 3. Pull new/modified posts - var postsToPull = new List(); - foreach (var remotePostL in allRemotePosts) - { - var id = remotePostL.Id ?? remotePostL.RowKey; - if (!localPostMap.TryGetValue(id, out var lp)) - { - lp = await localDataService.GetPostAsync(id); - } - - if (lp is null || remotePostL.DateModified > lp.DateModified) - { - postsToPull.Add(remotePostL); - } - } + // 3. Pull new/modified posts. The API returns one row per post/note pair, so de-duplicate by id. + var postsToPull = changedRemotePosts + .DistinctBy(p => p.Id ?? p.RowKey) + .Where(remote => !localPostMap.TryGetValue(remote.Id ?? remote.RowKey, out var lp) || remote.DateModified > lp.DateModified) + .ToList(); if (postsToPull.Count > 0) { - SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, postsToPull.Count, $"Pulling 0 of {postsToPull.Count} posts...")); - - for (int i = 0; i < postsToPull.Count; i++) - { - var remotePostL = postsToPull[i]; - var id = remotePostL.Id ?? remotePostL.RowKey; - - Post postToSave; - if (remotePostL.is_read == true) - { - postToSave = new Post - { - Id = id, - RowKey = remotePostL.RowKey, - PartitionKey = remotePostL.PartitionKey, - Title = remotePostL.Title, - Url = remotePostL.Url, - Date_published = remotePostL.Date_published, - Excerpt = remotePostL.Excerpt, - is_read = remotePostL.is_read, - DateModified = remotePostL.DateModified - }; - } - else + int total = postsToPull.Count; + var progress = new ProgressThrottle(this, total, current => $"Pulling {current} of {total} posts..."); + progress.Report(0, force: true); + + var postsToSave = new Post[total]; + await Parallel.ForEachAsync( + Enumerable.Range(0, total), + new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentRequests }, + async (i, _) => { - try - { - var fullPost = await apiClient.GetPost(id); - postToSave = fullPost ?? new Post - { - Id = id, - RowKey = remotePostL.RowKey, - PartitionKey = remotePostL.PartitionKey, - Title = remotePostL.Title, - Url = remotePostL.Url, - Date_published = remotePostL.Date_published, - Excerpt = remotePostL.Excerpt, - is_read = remotePostL.is_read, - DateModified = remotePostL.DateModified - }; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to retrieve full post for {PostId}, saving summary metadata", id); - postToSave = new Post - { - Id = id, - RowKey = remotePostL.RowKey, - PartitionKey = remotePostL.PartitionKey, - Title = remotePostL.Title, - Url = remotePostL.Url, - Date_published = remotePostL.Date_published, - Excerpt = remotePostL.Excerpt, - is_read = remotePostL.is_read, - DateModified = remotePostL.DateModified - }; - } - } + postsToSave[i] = await BuildPostToSaveAsync(postsToPull[i]); + progress.Increment(); + }); - await localDataService.SavePostAsync(postToSave, isPendingSync: false); - localPostMap[id] = postToSave; - - int current = i + 1; - SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(current, postsToPull.Count, $"Pulling {current} of {postsToPull.Count} posts...")); - } + await localDataService.SavePostsAsync(postsToSave); + progress.Report(total, force: true); } // 4. Pull notes modified since lastSync @@ -334,6 +278,90 @@ private async Task PullAsync(DateTime? lastSync) } } + private async Task> GetRemotePostIdsAsync(DateTime? lastSync, List changedRemotePosts) + { + if (lastSync is null) + { + // First sync: the delta already holds every post. + return changedRemotePosts.Select(p => p.Id ?? p.RowKey).ToHashSet(); + } + + var ids = await apiClient.GetPostIds(); + if (ids is not null) + { + return ids.ToHashSet(); + } + + // Older servers don't expose the ids endpoint; fall back to the full post list. + logger.LogInformation("Post ids endpoint unavailable, falling back to the full post list to detect deletions."); + var allRemotePosts = await apiClient.GetPostsModifiedAfter(DateTime.MinValue) ?? new List(); + return allRemotePosts.Select(p => p.Id ?? p.RowKey).ToHashSet(); + } + + private async Task BuildPostToSaveAsync(PostL remotePostL) + { + var id = remotePostL.Id ?? remotePostL.RowKey; + + // Read posts only need the summary metadata; unread posts get the full details for offline reading. + if (remotePostL.is_read != true) + { + try + { + var fullPost = await apiClient.GetPost(id); + if (fullPost is not null) + { + return fullPost; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to retrieve full post for {PostId}, saving summary metadata", id); + } + } + + return new Post + { + Id = id, + RowKey = remotePostL.RowKey, + PartitionKey = remotePostL.PartitionKey, + Title = remotePostL.Title, + Url = remotePostL.Url, + Date_published = remotePostL.Date_published, + Excerpt = remotePostL.Excerpt, + is_read = remotePostL.is_read, + DateModified = remotePostL.DateModified + }; + } + + /// + /// Limits how often progress events fire so the UI isn't re-rendered for every single post. + /// + private sealed class ProgressThrottle(SyncService owner, int total, Func formatStatus) + { + private readonly object _lock = new(); + private readonly System.Diagnostics.Stopwatch _sinceLastReport = System.Diagnostics.Stopwatch.StartNew(); + private int _current; + + public void Increment() + { + var current = Interlocked.Increment(ref _current); + Report(current, force: current == total); + } + + public void Report(int current, bool force = false) + { + lock (_lock) + { + if (!force && _sinceLastReport.Elapsed < ProgressReportInterval) + { + return; + } + _sinceLastReport.Restart(); + } + owner.SyncProgressChanged?.Invoke(owner, new SyncProgressEventArgs(current, total, formatStatus(current))); + } + } + private async Task SyncHtmlAsync() { var posts = await localDataService.GetPostsAsync(); @@ -357,7 +385,8 @@ private async Task SyncHtmlAsync() if (total > 0) { - SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, total, $"Downloading 0 of {total} posts...")); + var progress = new ProgressThrottle(this, total, current => $"Downloading {current} of {total} posts..."); + progress.Report(0, force: true); for (int i = 0; i < unreadToDownload.Count; i++) { @@ -377,8 +406,7 @@ private async Task SyncHtmlAsync() logger.LogWarning(ex, "Failed to download HTML for post {PostId}", id); } - int current = i + 1; - SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(current, total, $"Downloading {current} of {total} posts...")); + progress.Increment(); } } } diff --git a/src/NoteBookmark.SharedUI/PostNoteClient.cs b/src/NoteBookmark.SharedUI/PostNoteClient.cs index 8b8acd2..1debee1 100644 --- a/src/NoteBookmark.SharedUI/PostNoteClient.cs +++ b/src/NoteBookmark.SharedUI/PostNoteClient.cs @@ -198,6 +198,17 @@ public async Task> GetPostsModifiedAfter(DateTime modifiedAfter) return unread.Concat(read).ToList(); } + /// + /// Returns the ids of every post on the server, or null when the server does not expose the endpoint. + /// + public async Task?> GetPostIds() + { + var response = await httpClient.GetAsync("api/posts/ids"); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync>(); + } + public async Task> GetNotesModifiedAfter(DateTime modifiedAfter) { try From a00be97eb0fb89d579a50dd846240e155a2e3fa8 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Wed, 30 Sep 2026 11:49:23 -0400 Subject: [PATCH 25/25] chore: bump application version to 1.4.4 Co-Authored-By: Claude Opus 5.5 --- Directory.Build.props | 2 +- src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj | 2 +- src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index b82672e..dde27fd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.4.3 + 1.4.4 net10.0 enable enable diff --git a/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj b/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj index ce78b0a..b3931cd 100644 --- a/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj +++ b/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj @@ -1,7 +1,7 @@ - 1.4.3 + 1.4.4 diff --git a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj index b4140b5..53cfa87 100644 --- a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj +++ b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj @@ -44,9 +44,9 @@ c5m.notebookmark.mauiapp - 1.4.3 - 8 - 1.4.3 + 1.4.4 + 9 + 1.4.4 None