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