From a970e5a623909b88649ffac1bea3f0758f0167f0 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 8 Aug 2026 08:34:06 -0400 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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