From 3ee59749dd5751a0bce80e854e8d4640a55b5639 Mon Sep 17 00:00:00 2001 From: Frank Boucher <2404846+fboucher@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:45:56 -0400 Subject: [PATCH 1/4] v1.4.3 - Enhances MAUI sync, local storage, and reader (#202) * fix(#173): use Id ?? RowKey for checking and opening offline post HTML (#179) * feat(#176): add sync progress bar and status updates for downloading offline post text (#180) * should have done that long ago * fix: resolve Android MAUI app crash when opening URLs (issue #177) (#181) * feat: add up/down reordering for categories and notes in summary editor (issue #92) (#183) Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> * feat: add text size slider and fix text wrapping on offline reader page (issue #178) (#182) * bumb version * feat: implement async background post content extraction to save HTML in blob storage (#185) Co-authored-by: fboucher-os * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache (#186) * feat: implement async background post content extraction to save HTML in blob storage * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache * feat: configure protected Azure Function Parser API and key propagation (#187) * feat: configure protected Azure Function Parser API and key propagation * bump: version to 1.4.0 --------- Co-authored-by: fboucher-os * update missing env variable * fix * chore: bump version to 1.4.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(#190): update actions/cache to v5 for Node.js 24 compatibility in OpenCode triage workflow (#191) * Update version to 1.4.2, add sync progress tracking, and reader page controls (#192) * chore: bump version to 1.4.2 * feat: show detailed sync progress messages and progress bar on Posts page * feat: add back button and text size slider at both top and bottom of reader page * ci: include version in android apk filename (#193) Co-authored-by: fboucher-os * Fix document sync stuck and improve sync progress and offline fallback (fixes #196) (#199) * fix: constrain reader text size slider and add step buttons (fixes #195) (#198) * feat: Add markdown saving for reading notes and bump version to 1.4.3 * feat: add MAUI storage tab (#200) * feat: add MAUI storage tab for offline posts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: refine MAUI storage controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: highlight storage actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: bump application version to 1.4.3 (#201) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: fboucher-os Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 2 +- .../Tests/PostReaderTests.cs | 116 ++++++++- .../Tests/PostsTests.cs | 30 +++ .../NoteBookmark.BlazorApp.csproj | 6 +- .../SyncProgressEventArgs.cs | 4 +- .../LocalHtmlStorageServiceTests.cs | 41 ++++ .../NoteBookmark.MauiApp.Tests.csproj | 2 + .../StorageServiceTests.cs | 69 ++++++ .../SyncServiceTests.cs | 110 +++++++++ .../Components/Layout/NavMenu.razor | 1 + .../Components/Pages/Storage.razor | 232 ++++++++++++++++++ .../Data/OfflineDataService.cs | 50 +++- .../Data/StorageService.cs | 66 +++++ src/NoteBookmark.MauiApp/Data/SyncService.cs | 125 ++++++++-- src/NoteBookmark.MauiApp/MauiProgram.cs | 1 + .../NoteBookmark.MauiApp.csproj | 6 +- .../Components/Pages/PostReader.razor | 61 ++++- .../Components/Pages/Posts.razor | 30 ++- src/NoteBookmark.SharedUI/IDataService.cs | 1 + src/NoteBookmark.SharedUI/PostNoteClient.cs | 1 + 20 files changed, 907 insertions(+), 47 deletions(-) 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/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 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.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.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.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/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.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/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/OfflineDataService.cs b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs index 1136a47..5834142 100644 --- a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs @@ -173,14 +173,22 @@ 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) { - 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 +251,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 +309,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/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/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.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(); 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 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() { 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 2e1e2d5f7231fafc3aead01a56b2ef9261e83a11 Mon Sep 17 00:00:00 2001 From: Frank Boucher <2404846+fboucher@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:46:11 -0400 Subject: [PATCH 2/4] Fixes CI timeout and rate limits in triage workflow (#206) * fix(#173): use Id ?? RowKey for checking and opening offline post HTML (#179) * feat(#176): add sync progress bar and status updates for downloading offline post text (#180) * should have done that long ago * fix: resolve Android MAUI app crash when opening URLs (issue #177) (#181) * feat: add up/down reordering for categories and notes in summary editor (issue #92) (#183) Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> * feat: add text size slider and fix text wrapping on offline reader page (issue #178) (#182) * bumb version * feat: implement async background post content extraction to save HTML in blob storage (#185) Co-authored-by: fboucher-os * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache (#186) * feat: implement async background post content extraction to save HTML in blob storage * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache * feat: configure protected Azure Function Parser API and key propagation (#187) * feat: configure protected Azure Function Parser API and key propagation * bump: version to 1.4.0 --------- Co-authored-by: fboucher-os * update missing env variable * fix * chore: bump version to 1.4.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(#190): update actions/cache to v5 for Node.js 24 compatibility in OpenCode triage workflow (#191) * Update version to 1.4.2, add sync progress tracking, and reader page controls (#192) * chore: bump version to 1.4.2 * feat: show detailed sync progress messages and progress bar on Posts page * feat: add back button and text size slider at both top and bottom of reader page * ci: include version in android apk filename (#193) Co-authored-by: fboucher-os * Fix document sync stuck and improve sync progress and offline fallback (fixes #196) (#199) * fix: constrain reader text size slider and add step buttons (fixes #195) (#198) * feat: Add markdown saving for reading notes and bump version to 1.4.3 * feat: add MAUI storage tab (#200) * feat: add MAUI storage tab for offline posts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: refine MAUI storage controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: highlight storage actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: bump application version to 1.4.3 (#201) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ci): fix timeout and rate limit failures in OpenCode Daily Triage workflow (#204) (#205) * v1.4.3 - Enhances MAUI sync, local storage, and reader (#202) * fix(#173): use Id ?? RowKey for checking and opening offline post HTML (#179) * feat(#176): add sync progress bar and status updates for downloading offline post text (#180) * should have done that long ago * fix: resolve Android MAUI app crash when opening URLs (issue #177) (#181) * feat: add up/down reordering for categories and notes in summary editor (issue #92) (#183) Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> * feat: add text size slider and fix text wrapping on offline reader page (issue #178) (#182) * bumb version * feat: implement async background post content extraction to save HTML in blob storage (#185) Co-authored-by: fboucher-os * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache (#186) * feat: implement async background post content extraction to save HTML in blob storage * feat: improve sync html download, always display read post button, and remove ILocalHtmlCache * feat: configure protected Azure Function Parser API and key propagation (#187) * feat: configure protected Azure Function Parser API and key propagation * bump: version to 1.4.0 --------- Co-authored-by: fboucher-os * update missing env variable * fix * chore: bump version to 1.4.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(#190): update actions/cache to v5 for Node.js 24 compatibility in OpenCode triage workflow (#191) * Update version to 1.4.2, add sync progress tracking, and reader page controls (#192) * chore: bump version to 1.4.2 * feat: show detailed sync progress messages and progress bar on Posts page * feat: add back button and text size slider at both top and bottom of reader page * ci: include version in android apk filename (#193) Co-authored-by: fboucher-os * Fix document sync stuck and improve sync progress and offline fallback (fixes #196) (#199) * fix: constrain reader text size slider and add step buttons (fixes #195) (#198) * feat: Add markdown saving for reading notes and bump version to 1.4.3 * feat: add MAUI storage tab (#200) * feat: add MAUI storage tab for offline posts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: refine MAUI storage controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: highlight storage actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: bump application version to 1.4.3 (#201) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: fboucher-os Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ci): prevent timeout and rate limits in opencode daily triage (#204) --------- Co-authored-by: Frank Boucher <2404846+fboucher@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: fboucher-os Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/opencode-triage.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/opencode-triage.yml b/.github/workflows/opencode-triage.yml index 72c2308..3fcb2fc 100644 --- a/.github/workflows/opencode-triage.yml +++ b/.github/workflows/opencode-triage.yml @@ -8,6 +8,7 @@ on: jobs: triage: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: id-token: write contents: write @@ -20,7 +21,7 @@ jobs: - name: Get opencode version id: version run: | - VERSION=$(curl -sf https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4) + VERSION=$(curl -sf -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4) echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT - name: Cache opencode @@ -47,9 +48,9 @@ jobs: PROMPT: | You are a triage agent. Triaging open GitHub issues in this repo. - 1. Use `gh issue list --state open --json number,title,body,labels,createdAt` to list open issues. - 2. Skip any issue that already has a state label (ready-for-agent, ready-for-human, go:needs-research, go:no, wontfix). - 3. For each unlabeled issue, read it with `gh issue view `. + 1. Use `gh issue list --state open --limit 5 --json number,title,body,labels,createdAt` to list open issues. + 2. Skip any issue that already has a state label (ready-for-agent, ready-for-human, go:needs-research, go:no, wontfix, in-progress). + 3. For each unlabeled issue (max 5 per run), read it with `gh issue view `. 4. Classify as `type:bug` or `type:feature`. 5. Assign a state: - `go:needs-research` — needs more info or investigation @@ -68,5 +69,6 @@ jobs: Rules: - Never re-triage an issue that already has a state label. - Never modify closed issues. + - Process at most 5 issues per daily run to avoid rate limits. - When in doubt, use `go:needs-research`. - Keep briefs behavioral (what, not how). No file paths or line numbers. From 0fa86487c91d636bf55541148f94250d576edd18 Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sun, 20 Sep 2026 09:16:12 -0400 Subject: [PATCH 3/4] feat(ui): add browser bookmarklet support for saving URLs (#203) --- .../Tests/PostsTests.cs | 444 ++++++----- .../Components/Pages/Posts.razor | 711 +++++++++--------- .../Components/Pages/Settings.razor | 27 +- 3 files changed, 648 insertions(+), 534 deletions(-) diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs index c4e5ec9..de2bd18 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs @@ -1,196 +1,248 @@ -using Bunit; -using Bunit.TestDoubles; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.FluentUI.AspNetCore.Components; -using Moq; -using NoteBookmark.BlazorApp.Tests.Helpers; -using NoteBookmark.Domain; -using NoteBookmark.SharedUI; -using NoteBookmark.SharedUI.Components.Pages; - -namespace NoteBookmark.BlazorApp.Tests.Tests; - -/// -/// Tests for the Posts page in NoteBookmark.SharedUI. -/// Covers the show/hide published date toggle, title filter, and read/unread switching. -/// -public sealed class PostsTests : BunitContext -{ - private readonly Mock _dataServiceMock; - - private static List SamplePosts() => - [ - new PostL { PartitionKey = "p", RowKey = "1", Title = "First Post", Url = "https://example.com/1", Date_published = "2025-01-15T00:00:00", is_read = false }, - new PostL { PartitionKey = "p", RowKey = "2", Title = "Second Post", Url = "https://example.com/2", Date_published = "2025-06-20T00:00:00", is_read = false }, - ]; - - public PostsTests() - { - this.AddFluentUI(); - this.AddAuthorization().SetAuthorized("testuser"); - - _dataServiceMock = new Mock(); - _dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync(SamplePosts()); - _dataServiceMock.Setup(s => s.GetReadPosts()).ReturnsAsync([]); - _dataServiceMock.Setup(s => s.SyncAsync()).Returns(Task.CompletedTask); - _dataServiceMock.SetupGet(s => s.IsOffline).Returns(false); - _dataServiceMock.SetupGet(s => s.CanSync).Returns(false); - - Services.AddSingleton(_dataServiceMock.Object); - Services.AddSingleton(new Mock().Object); - Services.AddSingleton(new Mock().Object); - } - - [Fact] - public void Posts_RendersWithoutThrowing() - { - var cut = Render(); - - cut.Markup.Should().NotBeNullOrEmpty(); - } - - [Fact] - public void Posts_RendersPostTitles() - { - var cut = Render(); - - cut.Markup.Should().Contain("First Post"); - cut.Markup.Should().Contain("Second Post"); - } - - [Fact] - public void Posts_PublishedDateColumn_HiddenByDefault() - { - var cut = Render(); - - // The Published column header text should not appear as a grid header; - // "Show Published Date" (the checkbox label) still contains "Published" as a substring, - // so we look for the exact header cell pattern instead. - cut.Markup.Should().NotMatchRegex(@"col-title-text[^>]*>Published<"); - } - - [Fact] - public void Posts_PublishedDateColumn_VisibleAfterToggle() - { - var cut = Render(); - - // Find and click the "Show Published Date" checkbox - var checkbox = cut.Find("fluent-checkbox"); - checkbox.Click(); - - cut.Markup.Should().Contain("Published"); - } - - [Fact] - public void Posts_ShowPublishedDateCheckbox_IsRendered() - { - var cut = Render(); - - cut.Markup.Should().Contain("Show Published Date"); - } - - [Fact] - public void Posts_TitleFilter_RendersFilterButton() - { - var cut = Render(); - - // The Title column renders a filter button; the options panel (with the search input) - // only opens after the button is clicked, so we verify the button is present. - cut.Markup.Should().Contain("Filter this column"); - } - - [Fact] - public void Posts_LoadsUnreadPostsByDefault() - { - Render(); - - _dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce); - } - - [Fact] - public void Posts_RendersAddButton() - { - var cut = Render(); - - // The URL input and add button are present - cut.Markup.Should().Contain("Enter URL"); - } - - [Fact] - public void Posts_RendersEmptyState_WhenNoPostsReturned() - { - _dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync([]); - - var cut = Render(); - - cut.Markup.Should().Contain("Nothing to see here"); - } - - [Fact] - public void Posts_ReadPostButton_IsAlwaysRendered() - { - var cut = Render(); - - cut.Markup.Should().Contain("Read post"); - } - - [Fact] - public void Posts_DisplaysSyncProgress_WhenSyncProgressChangedFired() - { - var cut = Render(); - - cut.InvokeAsync(() => - { - _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(1, 6, "Downloading 1 of 6 posts...")); - }); - - cut.Markup.Should().Contain("Downloading 1 of 6 posts..."); - var progress = cut.FindComponent(); - progress.Instance.Value.Should().Be(1); - progress.Instance.Max.Should().Be(6); - } - - [Fact] - public void Posts_DisplaysCleaningStatus_WhenSyncProgressChangedFired() - { - var cut = Render(); - - cut.InvokeAsync(() => - { - _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Cleaning...")); - }); - - cut.Markup.Should().Contain("Cleaning..."); - } - - [Fact] - public void Posts_SyncProgressChanged_WhenIsComplete_ReloadsPosts() - { - var cut = Render(); - - _dataServiceMock.Invocations.Clear(); - - cut.InvokeAsync(() => - { - _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Synchronization complete!", isComplete: true)); - }); - - _dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce); - } - - [Fact] - public void Posts_SyncButton_DisabledAndLoadingReflectsIsSyncing() - { - _dataServiceMock.SetupGet(s => s.CanSync).Returns(true); - _dataServiceMock.SetupGet(s => s.IsSyncing).Returns(true); - - var cut = Render(); - - var buttons = cut.FindComponents(); - var syncButton = buttons.FirstOrDefault(b => b.Instance.Title == "Sync posts and comments"); - syncButton.Should().NotBeNull(); - syncButton!.Instance.Disabled.Should().BeTrue(); - syncButton.Instance.Loading.Should().BeTrue(); - } -} - +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Moq; +using NoteBookmark.BlazorApp.Tests.Helpers; +using NoteBookmark.Domain; +using NoteBookmark.SharedUI; +using NoteBookmark.SharedUI.Components.Pages; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +/// +/// Tests for the Posts page in NoteBookmark.SharedUI. +/// Covers the show/hide published date toggle, title filter, and read/unread switching. +/// +public sealed class PostsTests : BunitContext +{ + private readonly Mock _dataServiceMock; + + private static List SamplePosts() => + [ + new PostL { PartitionKey = "p", RowKey = "1", Title = "First Post", Url = "https://example.com/1", Date_published = "2025-01-15T00:00:00", is_read = false }, + new PostL { PartitionKey = "p", RowKey = "2", Title = "Second Post", Url = "https://example.com/2", Date_published = "2025-06-20T00:00:00", is_read = false }, + ]; + + public PostsTests() + { + this.AddFluentUI(); + this.AddAuthorization().SetAuthorized("testuser"); + + _dataServiceMock = new Mock(); + _dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync(SamplePosts()); + _dataServiceMock.Setup(s => s.GetReadPosts()).ReturnsAsync([]); + _dataServiceMock.Setup(s => s.SyncAsync()).Returns(Task.CompletedTask); + _dataServiceMock.SetupGet(s => s.IsOffline).Returns(false); + _dataServiceMock.SetupGet(s => s.CanSync).Returns(false); + + Services.AddSingleton(_dataServiceMock.Object); + Services.AddSingleton(new Mock().Object); + Services.AddSingleton(new Mock().Object); + } + + [Fact] + public void Posts_RendersWithoutThrowing() + { + var cut = Render(); + + cut.Markup.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Posts_RendersPostTitles() + { + var cut = Render(); + + cut.Markup.Should().Contain("First Post"); + cut.Markup.Should().Contain("Second Post"); + } + + [Fact] + public void Posts_PublishedDateColumn_HiddenByDefault() + { + var cut = Render(); + + // The Published column header text should not appear as a grid header; + // "Show Published Date" (the checkbox label) still contains "Published" as a substring, + // so we look for the exact header cell pattern instead. + cut.Markup.Should().NotMatchRegex(@"col-title-text[^>]*>Published<"); + } + + [Fact] + public void Posts_PublishedDateColumn_VisibleAfterToggle() + { + var cut = Render(); + + // Find and click the "Show Published Date" checkbox + var checkbox = cut.Find("fluent-checkbox"); + checkbox.Click(); + + cut.Markup.Should().Contain("Published"); + } + + [Fact] + public void Posts_ShowPublishedDateCheckbox_IsRendered() + { + var cut = Render(); + + cut.Markup.Should().Contain("Show Published Date"); + } + + [Fact] + public void Posts_TitleFilter_RendersFilterButton() + { + var cut = Render(); + + // The Title column renders a filter button; the options panel (with the search input) + // only opens after the button is clicked, so we verify the button is present. + cut.Markup.Should().Contain("Filter this column"); + } + + [Fact] + public void Posts_LoadsUnreadPostsByDefault() + { + Render(); + + _dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce); + } + + [Fact] + public void Posts_RendersAddButton() + { + var cut = Render(); + + // The URL input and add button are present + cut.Markup.Should().Contain("Enter URL"); + } + + [Fact] + public void Posts_RendersEmptyState_WhenNoPostsReturned() + { + _dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync([]); + + var cut = Render(); + + cut.Markup.Should().Contain("Nothing to see here"); + } + + [Fact] + public void Posts_ReadPostButton_IsAlwaysRendered() + { + var cut = Render(); + + cut.Markup.Should().Contain("Read post"); + } + + [Fact] + public void Posts_DisplaysSyncProgress_WhenSyncProgressChangedFired() + { + var cut = Render(); + + cut.InvokeAsync(() => + { + _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(1, 6, "Downloading 1 of 6 posts...")); + }); + + cut.Markup.Should().Contain("Downloading 1 of 6 posts..."); + var progress = cut.FindComponent(); + progress.Instance.Value.Should().Be(1); + progress.Instance.Max.Should().Be(6); + } + + [Fact] + public void Posts_DisplaysCleaningStatus_WhenSyncProgressChangedFired() + { + var cut = Render(); + + cut.InvokeAsync(() => + { + _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Cleaning...")); + }); + + cut.Markup.Should().Contain("Cleaning..."); + } + + [Fact] + public void Posts_SyncProgressChanged_WhenIsComplete_ReloadsPosts() + { + var cut = Render(); + + _dataServiceMock.Invocations.Clear(); + + cut.InvokeAsync(() => + { + _dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Synchronization complete!", isComplete: true)); + }); + + _dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce); + } + + [Fact] + public void Posts_SyncButton_DisabledAndLoadingReflectsIsSyncing() + { + _dataServiceMock.SetupGet(s => s.CanSync).Returns(true); + _dataServiceMock.SetupGet(s => s.IsSyncing).Returns(true); + + var cut = Render(); + + var buttons = cut.FindComponents(); + var syncButton = buttons.FirstOrDefault(b => b.Instance.Title == "Sync posts and comments"); + syncButton.Should().NotBeNull(); + syncButton!.Instance.Disabled.Should().BeTrue(); + syncButton.Instance.Loading.Should().BeTrue(); + } + + [Fact] + public void Posts_WithAddUrlParameter_AutomaticallyExtractsAndSavesPost() + { + const string testUrl = "https://example.com/bookmarklet-article"; + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)).ReturnsAsync(true); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/posts?addUrl={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(testUrl), Times.Once); + } + + [Fact] + public void Posts_WithPopupParameter_RendersBookmarkletMessageBar() + { + var nav = Services.GetRequiredService(); + nav.NavigateTo("http://localhost/posts?popup=true"); + + var cut = Render(); + + cut.Markup.Should().Contain("Opened via Bookmarklet"); + cut.Markup.Should().Contain("Close Window"); + } + + [Fact] + public void Posts_WithoutPopupParameter_DoesNotRenderBookmarkletMessageBar() + { + var nav = Services.GetRequiredService(); + nav.NavigateTo("http://localhost/posts"); + + var cut = Render(); + + cut.Markup.Should().NotContain("Opened via Bookmarklet"); + } + + [Fact] + public void Posts_WhenOffline_DoesNotAutoAddUrl() + { + const string testUrl = "https://example.com/offline-article"; + _dataServiceMock.SetupGet(s => s.IsOffline).Returns(true); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/posts?addUrl={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(It.IsAny()), Times.Never); + } +} + diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index bcf310b..3e6a639 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -1,337 +1,374 @@ -@page "/posts" -@attribute [Authorize] -@using Microsoft.AspNetCore.Authorization -@using NoteBookmark.Domain -@using Microsoft.FluentUI.AspNetCore.Components -@inject IDataService client -@inject IJSRuntime jsRuntime -@inject IToastService toastService -@inject IDialogService DialogService -@inject NavigationManager Navigation -@inject IUrlLauncher urlLauncher -@implements IDisposable - -Posts - -

Posts

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

Posts

+ + + + + + @if (client.CanSync) + { + Sync + } + + @if (isSyncing || client.IsSyncing || !string.IsNullOrEmpty(syncProgressStatus)) + { + + + @if (!string.IsNullOrEmpty(syncProgressStatus)) + { + @syncProgressStatus + } + + } + + Read Only + UnRead Only + + + + + + + + + @if (String.IsNullOrEmpty(context!.NoteId)) + { + + } + else + { + + } + + + + + + + +@if(showPublishedDate) +{ + +} + + + + + +   Nothing to see here. Carry on! + + + + + + + +@code { + [SupplyParameterFromQuery(Name = "addUrl")] + public string? AddUrl { get; set; } + + [SupplyParameterFromQuery(Name = "popup")] + public bool? Popup { get; set; } + + private string? _processedAddUrl; + private IQueryable? posts; + IQueryable? filteredUrlList => posts?.Where(x => x.Title!.Contains(titleFilter, StringComparison.CurrentCultureIgnoreCase)); + private GridSort defSort = GridSort.ByDescending(c => c.Date_published); + private string newPostUrl = string.Empty; + private bool showRead = false; + private PaginationState pagination = new PaginationState { ItemsPerPage = 20 }; + private string titleFilter = string.Empty; + private bool showPublishedDate = false; + private int syncProgressCurrent = 0; + private int syncProgressTotal = 0; + private string syncProgressStatus = string.Empty; + + protected override async Task OnInitializedAsync() + { + client.SyncProgressChanged += OnSyncProgressChanged; + await LoadPosts(); + _ = StartBackgroundSync(); + } + + protected override async Task OnParametersSetAsync() + { + if (!string.IsNullOrWhiteSpace(AddUrl) && AddUrl != _processedAddUrl && !client.IsOffline) + { + _processedAddUrl = AddUrl; + newPostUrl = AddUrl; + await AddNewPost(); + } + } + + private async Task LoadPosts() + { + List loadedPosts = showRead ? await client.GetReadPosts(): await client.GetUnreadPosts(); + posts = loadedPosts.AsQueryable(); + } + + private async Task StartBackgroundSync() + { + if (client.IsOffline) return; + + try + { + isSyncing = true; + await client.SyncAsync(); + await LoadPosts(); + } + catch (Exception) + { + // Ignore background sync errors + } + finally + { + isSyncing = false; + StateHasChanged(); + } + } + + private async Task OpenUrlInNewWindow(string? url) + { + await urlLauncher.OpenUrlAsync(url); + } + + private async Task CreateNoteForPost(string postId) + { + var newNote = new Note { PostId = postId }; + + IDialogReference dialog = await DialogService.ShowDialogAsync(newNote, new DialogParameters(){ + Title = "Add a note", + PreventDismissOnOverlayClick = true, + PreventScroll = true, + PrimaryAction = "Save", + SecondaryAction = "Cancel" + }); + + var result = await dialog.Result; + if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) + { + if (dialogResult.Action == "Save" && dialogResult.Note != null) + { + await client.CreateNote(dialogResult.Note); + toastService.ShowSuccess("Note created successfully!"); + await LoadPosts(); + } + } + } + + private void EditNote(string postId) + { + Navigation.NavigateTo($"posteditor/{postId}"); + } + + private async Task EditNoteForPost(string noteId) + { + try + { + var existingNote = await client.GetNote(noteId); + if (existingNote == null) + { + toastService.ShowError("Note not found."); + return; + } + + IDialogReference dialog = await DialogService.ShowDialogAsync(existingNote, new DialogParameters(){ + Title = "Edit note", + PreventDismissOnOverlayClick = true, + PreventScroll = true, + }); + + var result = await dialog.Result; + if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) + { + if (dialogResult.Action == "Delete" && dialogResult.Note != null) + { + var deleteResult = await client.DeleteNote(dialogResult.Note.RowKey); + if (deleteResult) + { + toastService.ShowSuccess("Note deleted successfully!"); + await LoadPosts(); + } + else + { + toastService.ShowError("Failed to delete note. Please try again."); + } + } + else if (dialogResult.Action == "Save" && dialogResult.Note != null) + { + var updateResult = await client.UpdateNote(dialogResult.Note); + if (updateResult) + { + toastService.ShowSuccess("Note updated successfully!"); + await LoadPosts(); + } + else + { + toastService.ShowError("Failed to update note. Please try again."); + } + } + } + } + catch (Exception) + { + toastService.ShowError("An error occurred. Please try again."); + } + } + + private async Task AddNewPost() + { + if (!string.IsNullOrEmpty(newPostUrl)) + { + var result = await client.ExtractPostDetailsAndSave(newPostUrl); + if (result) + { + await LoadPosts(); + newPostUrl = string.Empty; + toastService.ShowSuccess("Post added successfully!"); + } + else + { + toastService.ShowError("Failed to add post. Please try again."); + } + } + } + + private async Task ClosePopup() + { + try + { + await jsRuntime.InvokeVoidAsync("window.close"); + } + catch + { + // Ignore if script execution / window.close is not allowed + } + } + + private async Task DeletePost(string postId) + { + var result = await client.DeletePost(postId); + if (result) + { + await LoadPosts(); + toastService.ShowSuccess("Post deleted successfully!"); + } + else + { + toastService.ShowError("Failed to delete post. Please try again."); + } + } + + private async Task OnShowReadChanged(bool value) + { + showRead = value; + await LoadPosts(); + } + + private void HandleTitleFilter(ChangeEventArgs args) + { + if (args.Value is string value) + { + titleFilter = value; + } + } + + private void HandleClearTitleFilter() + { + if (string.IsNullOrWhiteSpace(titleFilter)) + { + titleFilter = string.Empty; + } + } + + private bool isSyncing = false; + + private async Task SyncNow() + { + if (isSyncing || client.IsSyncing) return; + isSyncing = true; + try + { + toastService.ShowInfo("Synchronization started..."); + await client.SyncAsync(); + toastService.ShowSuccess("Synchronization completed successfully!"); + await LoadPosts(); + } + catch (Exception ex) + { + toastService.ShowError($"Synchronization failed: {ex.Message}"); + } + finally + { + isSyncing = false; + StateHasChanged(); + } + } + + private void ToggleShowPublishedDate() + { + showPublishedDate = !showPublishedDate; + } + + private void ReadPost(string postId) => Navigation.NavigateTo($"postreader/{postId}"); + + private void OnSyncProgressChanged(object? sender, SyncProgressEventArgs e) + { + InvokeAsync(async () => + { + syncProgressCurrent = e.Current; + syncProgressTotal = e.Total; + syncProgressStatus = e.Status; + StateHasChanged(); + + if (e.IsComplete) + { + isSyncing = false; + await LoadPosts(); + StateHasChanged(); + + await Task.Delay(2500); + if (!client.IsSyncing && syncProgressStatus == e.Status) + { + syncProgressStatus = string.Empty; + syncProgressCurrent = 0; + syncProgressTotal = 0; + StateHasChanged(); + } + } + }); + } + + public void Dispose() + { + client.SyncProgressChanged -= OnSyncProgressChanged; + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor b/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor index 5b65bab..aca0208 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor @@ -101,7 +101,29 @@ Save
- + + + + + Browser Bookmarklet + + Drag the button below to your browser's bookmarks bar to quickly save articles from any webpage directly into NoteBookmark: + + + + + + + Add to NoteBookmark + + + + Tip: If dragging is not supported, right-click the button, copy the link, and paste it as the URL of a new browser bookmark. + + } @@ -154,4 +176,7 @@ var cnt = Convert.ToInt32(settings!.ReadingNotesCounter) + 1; settings.ReadingNotesCounter = (cnt).ToString(); } + + private string BookmarkletCode => + $"javascript:(function(){{var u=encodeURIComponent(window.location.href);window.open('{Navigation.BaseUri}posts?addUrl='+u+'&popup=true','nb_add','width=520,height=650,menubar=no,toolbar=no');}})();"; } From 7037566b7d1cbca61278c0b652638db32c3aeb7c Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sun, 20 Sep 2026 09:29:37 -0400 Subject: [PATCH 4/4] feat(ui): add dedicated quickadd popup and MinimalLayout for bookmarklet (#203) --- .../Tests/PostsTests.cs | 51 -- .../Tests/QuickAddTests.cs | 131 ++++ .../Data/OfflineDataService.cs | 686 +++++++++--------- .../Components/Pages/Posts.razor | 39 +- .../Components/Pages/QuickAdd.razor | 162 +++++ .../Components/Pages/Settings.razor | 2 +- .../Components/Shared/SuggestionList.razor | 2 +- src/NoteBookmark.SharedUI/IDataService.cs | 64 +- src/NoteBookmark.SharedUI/PostNoteClient.cs | 8 +- 9 files changed, 677 insertions(+), 468 deletions(-) create mode 100644 src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs create mode 100644 src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs index de2bd18..2ebc1bc 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs @@ -193,56 +193,5 @@ public void Posts_SyncButton_DisabledAndLoadingReflectsIsSyncing() syncButton!.Instance.Disabled.Should().BeTrue(); syncButton.Instance.Loading.Should().BeTrue(); } - - [Fact] - public void Posts_WithAddUrlParameter_AutomaticallyExtractsAndSavesPost() - { - const string testUrl = "https://example.com/bookmarklet-article"; - _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)).ReturnsAsync(true); - - var nav = Services.GetRequiredService(); - nav.NavigateTo($"http://localhost/posts?addUrl={Uri.EscapeDataString(testUrl)}"); - - var cut = Render(); - - _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(testUrl), Times.Once); - } - - [Fact] - public void Posts_WithPopupParameter_RendersBookmarkletMessageBar() - { - var nav = Services.GetRequiredService(); - nav.NavigateTo("http://localhost/posts?popup=true"); - - var cut = Render(); - - cut.Markup.Should().Contain("Opened via Bookmarklet"); - cut.Markup.Should().Contain("Close Window"); - } - - [Fact] - public void Posts_WithoutPopupParameter_DoesNotRenderBookmarkletMessageBar() - { - var nav = Services.GetRequiredService(); - nav.NavigateTo("http://localhost/posts"); - - var cut = Render(); - - cut.Markup.Should().NotContain("Opened via Bookmarklet"); - } - - [Fact] - public void Posts_WhenOffline_DoesNotAutoAddUrl() - { - const string testUrl = "https://example.com/offline-article"; - _dataServiceMock.SetupGet(s => s.IsOffline).Returns(true); - - var nav = Services.GetRequiredService(); - nav.NavigateTo($"http://localhost/posts?addUrl={Uri.EscapeDataString(testUrl)}"); - - var cut = Render(); - - _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(It.IsAny()), Times.Never); - } } diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs new file mode 100644 index 0000000..f51e4c3 --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs @@ -0,0 +1,131 @@ +using Bunit; +using Bunit.TestDoubles; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Moq; +using NoteBookmark.BlazorApp.Tests.Helpers; +using NoteBookmark.Domain; +using NoteBookmark.SharedUI; +using NoteBookmark.SharedUI.Components.Pages; +using Xunit; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +public sealed class QuickAddTests : BunitContext +{ + private readonly Mock _dataServiceMock; + + public QuickAddTests() + { + this.AddFluentUI(); + this.AddAuthorization().SetAuthorized("testuser"); + + _dataServiceMock = new Mock(); + _dataServiceMock.SetupGet(s => s.IsOffline).Returns(false); + + Services.AddSingleton(_dataServiceMock.Object); + Services.AddSingleton(new Mock().Object); + Services.AddSingleton(new Mock().Object); + } + + [Fact] + public void QuickAdd_WithUrlParameter_ExtractsAndDisplaysPostDetails() + { + const string testUrl = "https://example.com/my-post"; + var samplePost = new Post + { + PartitionKey = "p", + RowKey = "12345", + Title = "Awesome .NET Post", + Author = "Jane Doe", + Url = testUrl + }; + + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)) + .ReturnsAsync(samplePost); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Post saved successfully!"); + cut.Markup.Should().Contain("Awesome .NET Post"); + cut.Markup.Should().Contain("Jane Doe"); + cut.Markup.Should().Contain("Close Window"); + cut.Markup.Should().Contain("Edit"); + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(testUrl), Times.Once); + } + + [Fact] + public void QuickAdd_WhenEditClicked_NavigatesToPostEditorLight() + { + const string testUrl = "https://example.com/edit-post"; + var samplePost = new Post + { + PartitionKey = "p", + RowKey = "post-row-key-999", + Title = "Post to Edit", + Author = "Author Name", + Url = testUrl + }; + + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)) + .ReturnsAsync(samplePost); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + var editButton = cut.FindAll("fluent-button").FirstOrDefault(e => e.TextContent.Contains("Edit")); + editButton.Should().NotBeNull(); + editButton!.Click(); + + nav.Uri.Should().EndWith("posteditorlight/post-row-key-999"); + } + + [Fact] + public void QuickAdd_WhenOffline_DisplaysErrorMessage() + { + const string testUrl = "https://example.com/offline"; + _dataServiceMock.SetupGet(s => s.IsOffline).Returns(true); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Cannot extract posts while offline."); + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(It.IsAny()), Times.Never); + } + + [Fact] + public void QuickAdd_WhenExtractionFails_DisplaysErrorMessage() + { + const string testUrl = "https://example.com/fail"; + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)) + .ReturnsAsync((Post?)null); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Failed to extract and save post details"); + } + + [Fact] + public void QuickAdd_WithoutUrlParameter_DisplaysManualInput() + { + var nav = Services.GetRequiredService(); + nav.NavigateTo("http://localhost/quickadd"); + + var cut = Render(); + + cut.Markup.Should().Contain("Enter or paste URL"); + cut.Markup.Should().Contain("Close Window"); + } +} diff --git a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs index 5834142..c14d03f 100644 --- a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs @@ -1,343 +1,343 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Maui.Networking; -using NoteBookmark.Domain; -using NoteBookmark.SharedUI; - -namespace NoteBookmark.MauiApp.Data; - -public class OfflineDataService(PostNoteClient apiClient, ILocalDataService localDataService, IConnectivity connectivity, ISyncService syncService, ILocalHtmlStorageService localHtmlStorageService) : IDataService -{ - private bool IsOnline => connectivity.NetworkAccess == NetworkAccess.Internet; - - public async Task> GetUnreadPosts() - { - if (IsOnline) - { - var posts = await apiClient.GetUnreadPosts(); - await MergeLocalNotesIntoRemotePosts(posts); - return posts; - } - else - { - var allPosts = await localDataService.GetPostsAsync(); - var allNotes = await localDataService.GetNotesAsync(); - - return allPosts.Where(p => p.is_read != true).Select(p => { - var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); - return new PostL - { - Id = p.Id, - Title = p.Title, - Date_published = p.Date_published, - Url = p.Url, - Excerpt = p.Excerpt, - is_read = p.is_read, - PartitionKey = p.PartitionKey, - RowKey = p.RowKey, - NoteId = note?.RowKey, - Note = note?.Comment, - DateModified = p.DateModified - }; - }).ToList(); - } - } - - public async Task> GetReadPosts() - { - if (IsOnline) - { - var posts = await apiClient.GetReadPosts(); - await MergeLocalNotesIntoRemotePosts(posts); - return posts; - } - else - { - var allPosts = await localDataService.GetPostsAsync(); - var allNotes = await localDataService.GetNotesAsync(); - - return allPosts.Where(p => p.is_read == true).Select(p => { - var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); - return new PostL - { - Id = p.Id, - Title = p.Title, - Date_published = p.Date_published, - Url = p.Url, - Excerpt = p.Excerpt, - is_read = p.is_read, - PartitionKey = p.PartitionKey, - RowKey = p.RowKey, - NoteId = note?.RowKey, - Note = note?.Comment, - DateModified = p.DateModified - }; - }).ToList(); - } - } - - public async Task> GetSummaries() - { - if (IsOnline) - { - var summaries = await apiClient.GetSummaries(); - await localDataService.SaveSummariesAsync(summaries); - return summaries; - } - else - { - return await localDataService.GetSummariesAsync(); - } - } - - public async Task CreateNote(Note note) - { - note.DateModified = DateTime.UtcNow; - if (IsOnline) - { - await apiClient.CreateNote(note); - await localDataService.SaveNoteAsync(note); - var post = await localDataService.GetPostAsync(note.PostId!); - if (post != null) - { - post.is_read = true; - await localDataService.SavePostAsync(post, isPendingSync: false); - } - } - else - { - var settings = await localDataService.GetSettingsAsync(); - note.PartitionKey = settings?.ReadingNotesCounter ?? note.PartitionKey; - note.CreatedOffline = true; - await localDataService.SaveNoteAsync(note, isPendingSync: true); - var post = await localDataService.GetPostAsync(note.PostId!); - if (post != null) - { - post.is_read = true; - post.DateModified = DateTime.UtcNow; - await localDataService.SavePostAsync(post, isPendingSync: true); - } - } - } - - public async Task GetNote(string noteId) - { - if (IsOnline) - { - var note = await apiClient.GetNote(noteId); - if (note != null) await localDataService.SaveNoteAsync(note); - return note; - } - else - { - return await localDataService.GetNoteAsync(noteId); - } - } - - public async Task UpdateNote(Note note) - { - note.DateModified = DateTime.UtcNow; - if (IsOnline) - { - var success = await apiClient.UpdateNote(note); - if (success) await localDataService.SaveNoteAsync(note); - return success; - } - else - { - await localDataService.SaveNoteAsync(note, isPendingSync: true); - return true; - } - } - - public async Task DeleteNote(string noteId) - { - if (IsOnline) - { - var success = await apiClient.DeleteNote(noteId); - if (success) - { - await localDataService.DeleteNoteAsync(noteId); - } - return success; - } - else - { - await localDataService.DeleteNoteAsync(noteId, isPendingSync: true); - return true; - } - } - - public Task CreateReadingNotes() => apiClient.CreateReadingNotes(); - public Task GetReadingNotes(string number) => apiClient.GetReadingNotes(number); - public Task SaveReadingNotes(ReadingNotes readingNotes) => apiClient.SaveReadingNotes(readingNotes); - public Task SaveReadingNotesMarkdown(string markdown, string number) => apiClient.SaveReadingNotesMarkdown(markdown, number); - - public async Task GetPost(string id) - { - if (IsOnline) - { - try - { - var post = await apiClient.GetPost(id); - if (post != null) await localDataService.SavePostAsync(post); - return post; - } - catch - { - return await localDataService.GetPostAsync(id); - } - } - else - { - return await localDataService.GetPostAsync(id); - } - } - - public async Task SavePost(Post post) - { - post.DateModified = DateTime.UtcNow; - if (IsOnline) - { - var success = await apiClient.SavePost(post); - if (success) await localDataService.SavePostAsync(post); - return success; - } - else - { - await localDataService.SavePostAsync(post, isPendingSync: true); - return true; - } - } - - public async Task GetSettings() - { - if (IsOnline) - { - var settings = await apiClient.GetSettings(); - if (settings != null) await localDataService.SaveSettingsAsync(settings); - return settings; - } - else - { - return await localDataService.GetSettingsAsync(); - } - } - - public async Task SaveSettings(Settings settings) - { - if (IsOnline) - { - var success = await apiClient.SaveSettings(settings); - if (success) await localDataService.SaveSettingsAsync(settings); - return success; - } - else - { - // For now, offline settings saves don't sync. - await localDataService.SaveSettingsAsync(settings); - return true; - } - } - - public async Task ExtractPostDetailsAndSave(string url) - { - if (IsOnline) - { - return await apiClient.ExtractPostDetailsAndSave(url); - } - return false; // Can't extract offline - } - - public async Task GetPostHtmlAsync(string postId) - { - var localHtml = await localHtmlStorageService.GetPostHtmlAsync(postId); - if (!string.IsNullOrEmpty(localHtml)) - { - return localHtml; - } - - if (IsOnline) - { - try - { - var remoteHtml = await apiClient.GetPostHtmlAsync(postId); - if (!string.IsNullOrEmpty(remoteHtml)) - { - await localHtmlStorageService.SavePostHtmlAsync(postId, remoteHtml); - return remoteHtml; - } - } - catch - { - // Fall back to null if remote fetch fails - } - } - - return null; - } - - public async Task DeletePost(string id) - { - if (IsOnline) - { - var success = await apiClient.DeletePost(id); - if (success) - { - var post = await localDataService.GetPostAsync(id); - if (post != null) - { - post.is_read = true; // Simulating what API does - post.DateModified = DateTime.UtcNow; - await localDataService.SavePostAsync(post); - } - } - return success; - } - else - { - var post = await localDataService.GetPostAsync(id); - if (post != null) - { - post.is_read = true; - post.DateModified = DateTime.UtcNow; - await localDataService.SavePostAsync(post, isPendingSync: true); - } - return true; - } - } - - public Task SyncAsync() => syncService.SyncAsync(); - public event EventHandler? SyncProgressChanged - { - add => syncService.SyncProgressChanged += value; - remove => syncService.SyncProgressChanged -= value; - } - public bool IsSyncing => syncService.IsSyncing; - public bool IsOffline => connectivity.NetworkAccess != NetworkAccess.Internet; - public bool CanSync => true; - - private async Task MergeLocalNotesIntoRemotePosts(List remotePosts) - { - if (remotePosts == null || !remotePosts.Any()) return; - - var localNotes = await localDataService.GetNotesAsync(); - if (localNotes == null || !localNotes.Any()) return; - - var localNotesByPostId = localNotes - .GroupBy(n => n.PostId!) - .ToDictionary(g => g.Key, g => g.First()); - - foreach (var post in remotePosts) - { - var id = post.Id ?? post.RowKey; - if (localNotesByPostId.TryGetValue(id, out var localNote)) - { - post.Note = localNote.Comment; - post.NoteId = localNote.RowKey; - } - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Maui.Networking; +using NoteBookmark.Domain; +using NoteBookmark.SharedUI; + +namespace NoteBookmark.MauiApp.Data; + +public class OfflineDataService(PostNoteClient apiClient, ILocalDataService localDataService, IConnectivity connectivity, ISyncService syncService, ILocalHtmlStorageService localHtmlStorageService) : IDataService +{ + private bool IsOnline => connectivity.NetworkAccess == NetworkAccess.Internet; + + public async Task> GetUnreadPosts() + { + if (IsOnline) + { + var posts = await apiClient.GetUnreadPosts(); + await MergeLocalNotesIntoRemotePosts(posts); + return posts; + } + else + { + var allPosts = await localDataService.GetPostsAsync(); + var allNotes = await localDataService.GetNotesAsync(); + + return allPosts.Where(p => p.is_read != true).Select(p => { + var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); + return new PostL + { + Id = p.Id, + Title = p.Title, + Date_published = p.Date_published, + Url = p.Url, + Excerpt = p.Excerpt, + is_read = p.is_read, + PartitionKey = p.PartitionKey, + RowKey = p.RowKey, + NoteId = note?.RowKey, + Note = note?.Comment, + DateModified = p.DateModified + }; + }).ToList(); + } + } + + public async Task> GetReadPosts() + { + if (IsOnline) + { + var posts = await apiClient.GetReadPosts(); + await MergeLocalNotesIntoRemotePosts(posts); + return posts; + } + else + { + var allPosts = await localDataService.GetPostsAsync(); + var allNotes = await localDataService.GetNotesAsync(); + + return allPosts.Where(p => p.is_read == true).Select(p => { + var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); + return new PostL + { + Id = p.Id, + Title = p.Title, + Date_published = p.Date_published, + Url = p.Url, + Excerpt = p.Excerpt, + is_read = p.is_read, + PartitionKey = p.PartitionKey, + RowKey = p.RowKey, + NoteId = note?.RowKey, + Note = note?.Comment, + DateModified = p.DateModified + }; + }).ToList(); + } + } + + public async Task> GetSummaries() + { + if (IsOnline) + { + var summaries = await apiClient.GetSummaries(); + await localDataService.SaveSummariesAsync(summaries); + return summaries; + } + else + { + return await localDataService.GetSummariesAsync(); + } + } + + public async Task CreateNote(Note note) + { + note.DateModified = DateTime.UtcNow; + if (IsOnline) + { + await apiClient.CreateNote(note); + await localDataService.SaveNoteAsync(note); + var post = await localDataService.GetPostAsync(note.PostId!); + if (post != null) + { + post.is_read = true; + await localDataService.SavePostAsync(post, isPendingSync: false); + } + } + else + { + var settings = await localDataService.GetSettingsAsync(); + note.PartitionKey = settings?.ReadingNotesCounter ?? note.PartitionKey; + note.CreatedOffline = true; + await localDataService.SaveNoteAsync(note, isPendingSync: true); + var post = await localDataService.GetPostAsync(note.PostId!); + if (post != null) + { + post.is_read = true; + post.DateModified = DateTime.UtcNow; + await localDataService.SavePostAsync(post, isPendingSync: true); + } + } + } + + public async Task GetNote(string noteId) + { + if (IsOnline) + { + var note = await apiClient.GetNote(noteId); + if (note != null) await localDataService.SaveNoteAsync(note); + return note; + } + else + { + return await localDataService.GetNoteAsync(noteId); + } + } + + public async Task UpdateNote(Note note) + { + note.DateModified = DateTime.UtcNow; + if (IsOnline) + { + var success = await apiClient.UpdateNote(note); + if (success) await localDataService.SaveNoteAsync(note); + return success; + } + else + { + await localDataService.SaveNoteAsync(note, isPendingSync: true); + return true; + } + } + + public async Task DeleteNote(string noteId) + { + if (IsOnline) + { + var success = await apiClient.DeleteNote(noteId); + if (success) + { + await localDataService.DeleteNoteAsync(noteId); + } + return success; + } + else + { + await localDataService.DeleteNoteAsync(noteId, isPendingSync: true); + return true; + } + } + + public Task CreateReadingNotes() => apiClient.CreateReadingNotes(); + public Task GetReadingNotes(string number) => apiClient.GetReadingNotes(number); + public Task SaveReadingNotes(ReadingNotes readingNotes) => apiClient.SaveReadingNotes(readingNotes); + public Task SaveReadingNotesMarkdown(string markdown, string number) => apiClient.SaveReadingNotesMarkdown(markdown, number); + + public async Task GetPost(string id) + { + if (IsOnline) + { + try + { + var post = await apiClient.GetPost(id); + if (post != null) await localDataService.SavePostAsync(post); + return post; + } + catch + { + return await localDataService.GetPostAsync(id); + } + } + else + { + return await localDataService.GetPostAsync(id); + } + } + + public async Task SavePost(Post post) + { + post.DateModified = DateTime.UtcNow; + if (IsOnline) + { + var success = await apiClient.SavePost(post); + if (success) await localDataService.SavePostAsync(post); + return success; + } + else + { + await localDataService.SavePostAsync(post, isPendingSync: true); + return true; + } + } + + public async Task GetSettings() + { + if (IsOnline) + { + var settings = await apiClient.GetSettings(); + if (settings != null) await localDataService.SaveSettingsAsync(settings); + return settings; + } + else + { + return await localDataService.GetSettingsAsync(); + } + } + + public async Task SaveSettings(Settings settings) + { + if (IsOnline) + { + var success = await apiClient.SaveSettings(settings); + if (success) await localDataService.SaveSettingsAsync(settings); + return success; + } + else + { + // For now, offline settings saves don't sync. + await localDataService.SaveSettingsAsync(settings); + return true; + } + } + + public async Task ExtractPostDetailsAndSave(string url) + { + if (IsOnline) + { + return await apiClient.ExtractPostDetailsAndSave(url); + } + return null; // Can't extract offline + } + + public async Task GetPostHtmlAsync(string postId) + { + var localHtml = await localHtmlStorageService.GetPostHtmlAsync(postId); + if (!string.IsNullOrEmpty(localHtml)) + { + return localHtml; + } + + if (IsOnline) + { + try + { + var remoteHtml = await apiClient.GetPostHtmlAsync(postId); + if (!string.IsNullOrEmpty(remoteHtml)) + { + await localHtmlStorageService.SavePostHtmlAsync(postId, remoteHtml); + return remoteHtml; + } + } + catch + { + // Fall back to null if remote fetch fails + } + } + + return null; + } + + public async Task DeletePost(string id) + { + if (IsOnline) + { + var success = await apiClient.DeletePost(id); + if (success) + { + var post = await localDataService.GetPostAsync(id); + if (post != null) + { + post.is_read = true; // Simulating what API does + post.DateModified = DateTime.UtcNow; + await localDataService.SavePostAsync(post); + } + } + return success; + } + else + { + var post = await localDataService.GetPostAsync(id); + if (post != null) + { + post.is_read = true; + post.DateModified = DateTime.UtcNow; + await localDataService.SavePostAsync(post, isPendingSync: true); + } + return true; + } + } + + public Task SyncAsync() => syncService.SyncAsync(); + public event EventHandler? SyncProgressChanged + { + add => syncService.SyncProgressChanged += value; + remove => syncService.SyncProgressChanged -= value; + } + public bool IsSyncing => syncService.IsSyncing; + public bool IsOffline => connectivity.NetworkAccess != NetworkAccess.Internet; + public bool CanSync => true; + + private async Task MergeLocalNotesIntoRemotePosts(List remotePosts) + { + if (remotePosts == null || !remotePosts.Any()) return; + + var localNotes = await localDataService.GetNotesAsync(); + if (localNotes == null || !localNotes.Any()) return; + + var localNotesByPostId = localNotes + .GroupBy(n => n.PostId!) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var post in remotePosts) + { + var id = post.Id ?? post.RowKey; + if (localNotesByPostId.TryGetValue(id, out var localNote)) + { + post.Note = localNote.Comment; + post.NoteId = localNote.RowKey; + } + } + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index 3e6a639..69ef426 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -13,14 +13,6 @@ Posts -@if (Popup == true) -{ - - Opened via Bookmarklet. - Close Window - -} -

Posts

@@ -92,13 +84,6 @@ @code { - [SupplyParameterFromQuery(Name = "addUrl")] - public string? AddUrl { get; set; } - - [SupplyParameterFromQuery(Name = "popup")] - public bool? Popup { get; set; } - - private string? _processedAddUrl; private IQueryable? posts; IQueryable? filteredUrlList => posts?.Where(x => x.Title!.Contains(titleFilter, StringComparison.CurrentCultureIgnoreCase)); private GridSort defSort = GridSort.ByDescending(c => c.Date_published); @@ -118,16 +103,6 @@ _ = StartBackgroundSync(); } - protected override async Task OnParametersSetAsync() - { - if (!string.IsNullOrWhiteSpace(AddUrl) && AddUrl != _processedAddUrl && !client.IsOffline) - { - _processedAddUrl = AddUrl; - newPostUrl = AddUrl; - await AddNewPost(); - } - } - private async Task LoadPosts() { List loadedPosts = showRead ? await client.GetReadPosts(): await client.GetUnreadPosts(); @@ -248,7 +223,7 @@ if (!string.IsNullOrEmpty(newPostUrl)) { var result = await client.ExtractPostDetailsAndSave(newPostUrl); - if (result) + if (result != null) { await LoadPosts(); newPostUrl = string.Empty; @@ -261,18 +236,6 @@ } } - private async Task ClosePopup() - { - try - { - await jsRuntime.InvokeVoidAsync("window.close"); - } - catch - { - // Ignore if script execution / window.close is not allowed - } - } - private async Task DeletePost(string postId) { var result = await client.DeletePost(postId); diff --git a/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor b/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor new file mode 100644 index 0000000..7d836ad --- /dev/null +++ b/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor @@ -0,0 +1,162 @@ +@page "/quickadd" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@using NoteBookmark.Domain +@inject IDataService client +@inject NavigationManager Navigation +@inject IJSRuntime JsRuntime +@layout MinimalLayout + +Quick Add - NoteBookmark + +
+ + Quick Add Post + + @if (isLoading) + { + + + Extracting and saving post details... + + } + else if (isSuccess && post != null) + { + + Post saved successfully! + + + + +
+ Title + @(string.IsNullOrWhiteSpace(post.Title) ? "Untitled" : post.Title) +
+ +
+ Author + @(string.IsNullOrWhiteSpace(post.Author) ? "Unknown" : post.Author) +
+ +
+ URL +
+ @post.Url +
+
+
+
+ + + Close Window + Edit + + } + else + { + @if (!string.IsNullOrEmpty(errorMessage)) + { + + @errorMessage + + } + + + + + Close Window + Add + + + } +
+
+ +@code { + [SupplyParameterFromQuery(Name = "url")] + public string? Url { get; set; } + + [SupplyParameterFromQuery(Name = "addUrl")] + public string? AddUrl { get; set; } + + private Post? post; + private bool isLoading = false; + private bool isSuccess = false; + private string? errorMessage; + private string? manualUrl; + private string? _processedUrl; + + protected override async Task OnParametersSetAsync() + { + var targetUrl = !string.IsNullOrWhiteSpace(Url) ? Url : AddUrl; + if (!string.IsNullOrWhiteSpace(targetUrl) && targetUrl != _processedUrl) + { + _processedUrl = targetUrl; + await ProcessUrlAsync(targetUrl); + } + } + + private async Task ProcessUrlAsync(string targetUrl) + { + if (client.IsOffline) + { + errorMessage = "Cannot extract posts while offline."; + isLoading = false; + return; + } + + isLoading = true; + errorMessage = null; + isSuccess = false; + + try + { + post = await client.ExtractPostDetailsAndSave(targetUrl); + if (post != null) + { + isSuccess = true; + } + else + { + errorMessage = "Failed to extract and save post details. Please check the URL and try again."; + } + } + catch (Exception ex) + { + errorMessage = $"An error occurred: {ex.Message}"; + } + finally + { + isLoading = false; + } + } + + private async Task ProcessManualUrl() + { + if (!string.IsNullOrWhiteSpace(manualUrl)) + { + await ProcessUrlAsync(manualUrl); + } + } + + private async Task CloseWindow() + { + try + { + await JsRuntime.InvokeVoidAsync("window.close"); + } + catch + { + // Prerender or context without window.close support + } + } + + private void NavigateToEdit() + { + var rowKey = post?.RowKey ?? post?.Id; + if (!string.IsNullOrEmpty(rowKey)) + { + Navigation.NavigateTo($"posteditorlight/{rowKey}"); + } + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor b/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor index aca0208..e319833 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor @@ -178,5 +178,5 @@ } private string BookmarkletCode => - $"javascript:(function(){{var u=encodeURIComponent(window.location.href);window.open('{Navigation.BaseUri}posts?addUrl='+u+'&popup=true','nb_add','width=520,height=650,menubar=no,toolbar=no');}})();"; + $"javascript:(function(){{var u=encodeURIComponent(window.location.href);window.open('{Navigation.BaseUri}quickadd?url='+u,'nb_quickadd','width=480,height=440,menubar=no,toolbar=no');}})();"; } diff --git a/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor b/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor index 6587d7d..ad47fc3 100644 --- a/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor +++ b/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor @@ -50,7 +50,7 @@ if (postURL != null) { var result = await client.ExtractPostDetailsAndSave(postURL); - if (result) + if (result != null) { Suggestions!.Remove(Suggestions.First(x => x.Url == postURL)); StateHasChanged(); diff --git a/src/NoteBookmark.SharedUI/IDataService.cs b/src/NoteBookmark.SharedUI/IDataService.cs index b5a0887..f95c1b3 100644 --- a/src/NoteBookmark.SharedUI/IDataService.cs +++ b/src/NoteBookmark.SharedUI/IDataService.cs @@ -1,32 +1,32 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using NoteBookmark.Domain; - -namespace NoteBookmark.SharedUI; - -public interface IDataService -{ - Task> GetUnreadPosts(); - Task> GetReadPosts(); - Task> GetSummaries(); - Task CreateNote(Note note); - Task GetNote(string noteId); - Task UpdateNote(Note note); - Task DeleteNote(string noteId); - Task CreateReadingNotes(); - Task GetReadingNotes(string number); - Task SaveReadingNotes(ReadingNotes readingNotes); - Task GetPost(string id); - Task SavePost(Post post); - Task GetSettings(); - Task SaveSettings(Settings settings); - Task ExtractPostDetailsAndSave(string url); - Task DeletePost(string id); - Task SaveReadingNotesMarkdown(string markdown, string number); - Task GetPostHtmlAsync(string postId); - Task SyncAsync(); - event System.EventHandler? SyncProgressChanged; - bool IsSyncing { get; } - bool IsOffline { get; } - bool CanSync { get; } -} +using System.Collections.Generic; +using System.Threading.Tasks; +using NoteBookmark.Domain; + +namespace NoteBookmark.SharedUI; + +public interface IDataService +{ + Task> GetUnreadPosts(); + Task> GetReadPosts(); + Task> GetSummaries(); + Task CreateNote(Note note); + Task GetNote(string noteId); + Task UpdateNote(Note note); + Task DeleteNote(string noteId); + Task CreateReadingNotes(); + Task GetReadingNotes(string number); + Task SaveReadingNotes(ReadingNotes readingNotes); + Task GetPost(string id); + Task SavePost(Post post); + Task GetSettings(); + Task SaveSettings(Settings settings); + Task ExtractPostDetailsAndSave(string url); + Task DeletePost(string id); + Task SaveReadingNotesMarkdown(string markdown, string number); + Task GetPostHtmlAsync(string postId); + Task SyncAsync(); + event System.EventHandler? SyncProgressChanged; + bool IsSyncing { get; } + bool IsOffline { get; } + bool CanSync { get; } +} diff --git a/src/NoteBookmark.SharedUI/PostNoteClient.cs b/src/NoteBookmark.SharedUI/PostNoteClient.cs index b0245a8..8b8acd2 100644 --- a/src/NoteBookmark.SharedUI/PostNoteClient.cs +++ b/src/NoteBookmark.SharedUI/PostNoteClient.cs @@ -158,11 +158,15 @@ public async Task SaveSettings(Settings settings) return response.IsSuccessStatusCode; } - public async Task ExtractPostDetailsAndSave(string url) + public async Task ExtractPostDetailsAndSave(string url) { var requestBody = new { url = url }; var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails", requestBody); - return response.IsSuccessStatusCode; + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(); + } + return null; } public async Task DeletePost(string id)