diff --git a/.github/workflows/build-android-apk.yml b/.github/workflows/build-android-apk.yml index 9943b68..3c0d842 100644 --- a/.github/workflows/build-android-apk.yml +++ b/.github/workflows/build-android-apk.yml @@ -48,6 +48,33 @@ jobs: ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + - name: Rename APK with version + run: | + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + VERSION="${{ github.ref_name }}" + else + VERSION_NUM=$(grep -oPm1 "(?<=)[^<]+" src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj || true) + if [ -z "$VERSION_NUM" ]; then + VERSION_NUM=$(grep -oPm1 "(?<=)[^<]+" Directory.Build.props) + fi + VERSION="v${VERSION_NUM#v}" + fi + echo "Using version: $VERSION" + + PUBLISH_DIR="src/NoteBookmark.MauiApp/bin/Release/net10.0-android/publish" + for apk in "$PUBLISH_DIR"/*.apk; do + if [ -f "$apk" ]; then + filename=$(basename "$apk") + if [[ "$filename" == *"-Signed.apk" ]]; then + new_filename="${filename%-Signed.apk}-${VERSION}-Signed.apk" + else + new_filename="${filename%.apk}-${VERSION}.apk" + fi + echo "Renaming $apk to $PUBLISH_DIR/$new_filename" + mv "$apk" "$PUBLISH_DIR/$new_filename" + fi + done + - name: Upload APK artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/opencode-triage.yml b/.github/workflows/opencode-triage.yml index c532108..72c2308 100644 --- a/.github/workflows/opencode-triage.yml +++ b/.github/workflows/opencode-triage.yml @@ -1,52 +1,72 @@ -name: OpenCode Daily Triage - -on: - schedule: - - cron: "0 6 * * *" # 1:00 AM EST (6:00 AM UTC) daily - workflow_dispatch: - -jobs: - triage: - runs-on: ubuntu-latest - permissions: - id-token: write - contents: write - issues: write - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - - uses: anomalyco/opencode/github@latest - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - model: opencode/big-pickle - use_github_token: true - 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 `. - 4. Classify as `type:bug` or `type:feature`. - 5. Assign a state: - - `go:needs-research` — needs more info or investigation - - `ready-for-agent` — well-specified, agent-implementable - - `ready-for-human` — needs human judgment or design decisions - - `wontfix` — out of scope or not actionable (close it) - 6. Apply labels with `gh issue edit --add-label`. - 7. Post a triage comment with `gh issue comment `. Every comment MUST start with: - > *This was generated by AI during triage.* - - For `go:needs-research`: post "Triage Notes" with what you established and specific questions for the reporter. - For `ready-for-agent`: post an "Agent Brief" with category, summary, current/desired behavior, key interfaces, acceptance criteria, and out-of-scope. - For `ready-for-human`: same as agent brief but note why it can't be delegated. - For `wontfix`: post a polite explanation and close with `gh issue close --reason not-planned`. - - Rules: - - Never re-triage an issue that already has a state label. - - Never modify closed issues. - - When in doubt, use `go:needs-research`. - - Keep briefs behavioral (what, not how). No file paths or line numbers. +name: OpenCode Daily Triage + +on: + schedule: + - cron: "0 6 * * *" # 1:00 AM EST (6:00 AM UTC) daily + workflow_dispatch: + +jobs: + triage: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + issues: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - 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) + echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT + + - name: Cache opencode + id: cache + uses: actions/cache@v5 + with: + path: ~/.opencode/bin + key: opencode-${{ runner.os }}-${{ runner.arch }}-${{ steps.version.outputs.version }} + + - name: Install opencode + if: steps.cache.outputs.cache-hit != 'true' + run: curl -fsSL https://opencode.ai/install | bash + + - name: Add opencode to PATH + run: echo "$HOME/.opencode/bin" >> $GITHUB_PATH + + - name: Run opencode + run: opencode github run + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MODEL: opencode/big-pickle + USE_GITHUB_TOKEN: "true" + 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 `. + 4. Classify as `type:bug` or `type:feature`. + 5. Assign a state: + - `go:needs-research` — needs more info or investigation + - `ready-for-agent` — well-specified, agent-implementable + - `ready-for-human` — needs human judgment or design decisions + - `wontfix` — out of scope or not actionable (close it) + 6. Apply labels with `gh issue edit --add-label`. + 7. Post a triage comment with `gh issue comment `. Every comment MUST start with: + > *This was generated by AI during triage.* + + For `go:needs-research`: post "Triage Notes" with what you established and specific questions for the reporter. + For `ready-for-agent`: post an "Agent Brief" with category, summary, current/desired behavior, key interfaces, acceptance criteria, and out-of-scope. + For `ready-for-human`: same as agent brief but note why it can't be delegated. + For `wontfix`: post a polite explanation and close with `gh issue close --reason not-planned`. + + Rules: + - Never re-triage an issue that already has a state label. + - Never modify closed issues. + - 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 e3276bc..24fd94d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.4.1 + 1.4.2 net10.0 enable enable diff --git a/docs/maui-android-install.md b/docs/maui-android-install.md index 101cefd..8aa9ebf 100644 --- a/docs/maui-android-install.md +++ b/docs/maui-android-install.md @@ -9,7 +9,7 @@ This guide walks you through installing (often called "sideloading") the NoteBoo 2. Tap on the **Actions** tab at the top. 3. Select the latest successful **Build Android APK** run. 4. Scroll down to the **Artifacts** section and download the **`notebookmark-android-apk`** ZIP file. -5. Extract the ZIP file to retrieve the **`.apk`** installer file (e.g., `NoteBookmark.MauiApp.apk`). +5. Extract the ZIP file to retrieve the **`.apk`** installer file (e.g., `c5m.notebookmark.mauiapp-v1.4.2-Signed.apk`). --- diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs index b480ab3..382a6eb 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_RendersTitleAndContentAndSlider() + public void PostReader_RendersTitleAndContentAndSlidersAndBackButtonsAtTopAndBottom() { var cut = Render(ps => ps.Add(p => p.PostId, "p1")); @@ -44,23 +44,43 @@ public void PostReader_RendersTitleAndContentAndSlider() cut.Markup.Should().Contain("reader-content"); cut.Markup.Should().Contain("Text size:"); - var slider = cut.FindComponent>(); - slider.Instance.Min.Should().Be(8); - slider.Instance.Max.Should().Be(56); + 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); + + var backButtons = cut.FindComponents() + .Where(b => b.Instance.Title == "Back to posts") + .ToList(); + backButtons.Should().HaveCount(2); } [Fact] - public void PostReader_SliderValueChange_UpdatesContentFontSize() + public void PostReader_TopSliderValueChange_UpdatesContentFontSize() { var cut = Render(ps => ps.Add(p => p.PostId, "p1")); var contentDivBefore = cut.Find("div.reader-content"); contentDivBefore.GetAttribute("style").Should().Contain("font-size: 16px;"); - var slider = cut.FindComponent>(); - cut.InvokeAsync(() => slider.Instance.ValueChanged.InvokeAsync(24)); + var sliders = cut.FindComponents>(); + cut.InvokeAsync(() => sliders[0].Instance.ValueChanged.InvokeAsync(24)); var contentDivAfter = cut.Find("div.reader-content"); contentDivAfter.GetAttribute("style").Should().Contain("font-size: 24px;"); } + + [Fact] + public void PostReader_BottomSliderValueChange_UpdatesContentFontSize() + { + var cut = Render(ps => ps.Add(p => p.PostId, "p1")); + + var sliders = cut.FindComponents>(); + cut.InvokeAsync(() => sliders[1].Instance.ValueChanged.InvokeAsync(20)); + + var contentDivAfter = cut.Find("div.reader-content"); + contentDivAfter.GetAttribute("style").Should().Contain("font-size: 20px;"); + } } diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs index 70ee8ea..6c3c6db 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs @@ -133,5 +133,34 @@ public void Posts_ReadPostButton_IsAlwaysRendered() 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..."); + } } diff --git a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs index 6cc0536..1ad1706 100644 --- a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs +++ b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs @@ -466,7 +466,9 @@ public async Task SyncAsync_ShouldRaiseSyncProgressChanged_WhenDownloadingPostHt await _sut.SyncAsync(); progressEvents.Should().NotBeEmpty(); - progressEvents.Should().Contain(e => e.Status.Contains("Downloading offline text")); + progressEvents.Should().Contain(e => e.Status == "Cleaning..."); + progressEvents.Should().Contain(e => e.Status == "Downloading 1 of 2 posts..." && e.Current == 1 && e.Total == 2); + progressEvents.Should().Contain(e => e.Status == "Downloading 2 of 2 posts..." && e.Current == 2 && e.Total == 2); progressEvents.Last().Status.Should().Be("Synchronization complete!"); } } diff --git a/src/NoteBookmark.MauiApp/Data/SyncService.cs b/src/NoteBookmark.MauiApp/Data/SyncService.cs index 4f52f3d..495b749 100644 --- a/src/NoteBookmark.MauiApp/Data/SyncService.cs +++ b/src/NoteBookmark.MauiApp/Data/SyncService.cs @@ -243,6 +243,8 @@ private async Task SyncHtmlAsync() var postMap = posts.ToDictionary(p => p.Id ?? p.RowKey); var cachedIds = localHtmlStorageService.GetCachedPostIds().ToHashSet(); + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Cleaning...")); + // Prune cached HTML for posts that are read or no longer exist foreach (var cachedId in cachedIds) { @@ -258,7 +260,7 @@ private async Task SyncHtmlAsync() if (total > 0) { - SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, total, $"Downloading offline text (0/{total})...")); + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, total, $"Downloading 0 of {total} posts...")); for (int i = 0; i < unreadToDownload.Count; i++) { @@ -279,7 +281,7 @@ private async Task SyncHtmlAsync() } int current = i + 1; - SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(current, total, $"Downloading offline text ({current}/{total})...")); + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(current, total, $"Downloading {current} of {total} posts...")); } } } diff --git a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj index fce28f8..680d90d 100644 --- a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj +++ b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj @@ -44,9 +44,9 @@ c5m.notebookmark.mauiapp - 1.3.3 - 6 - 1.3.3 + 1.4.2 + 7 + 1.4.2 None diff --git a/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor index 57af22a..ff1005f 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/PostReader.razor @@ -9,10 +9,16 @@ @(post?.Title ?? "Reading...") - - Back + + + Back + + Text size: + @(textSize)px + + @if (isLoading) @@ -51,6 +57,10 @@ + Back + Text size: @(textSize)px diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index 3308e1d..0dbebea 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -115,14 +115,22 @@ try { + isSyncing = true; await client.SyncAsync(); await LoadPosts(); - StateHasChanged(); } catch (Exception) { // Ignore background sync errors } + finally + { + isSyncing = false; + syncProgressStatus = string.Empty; + syncProgressCurrent = 0; + syncProgressTotal = 0; + StateHasChanged(); + } } private async Task OpenUrlInNewWindow(string? url) @@ -287,6 +295,10 @@ finally { isSyncing = false; + syncProgressStatus = string.Empty; + syncProgressCurrent = 0; + syncProgressTotal = 0; + StateHasChanged(); } }