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. 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; }