Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a970e5a
fix(#173): use Id ?? RowKey for checking and opening offline post HTM…
fboucher-os Aug 8, 2026
a5d25a2
feat(#176): add sync progress bar and status updates for downloading …
fboucher-os Aug 8, 2026
2c97bd1
should have done that long ago
fboucher-os Aug 11, 2026
2677eb1
fix: resolve Android MAUI app crash when opening URLs (issue #177) (#…
fboucher-os Aug 13, 2026
1acc7d6
feat: add up/down reordering for categories and notes in summary edit…
fboucher-os Aug 15, 2026
007a4d4
feat: add text size slider and fix text wrapping on offline reader pa…
fboucher-os Aug 15, 2026
5077f44
bumb version
fboucher-os Aug 15, 2026
ff8ecd1
feat: implement async background post content extraction to save HTML…
fboucher Aug 15, 2026
aaa3880
feat: improve sync html download, always display read post button, an…
fboucher-os Aug 15, 2026
369139c
feat: configure protected Azure Function Parser API and key propagati…
fboucher Aug 15, 2026
5a65b09
Merge branch 'main' into feature/resolve-pr-188-conflicts
fboucher-os Aug 15, 2026
c8afa57
update missing env variable
fboucher-os Aug 16, 2026
7726e97
fix
fboucher-os Aug 16, 2026
6018801
chore: bump version to 1.4.1
fboucher-os Aug 16, 2026
1b28a01
Merge branch 'main' into v-next
fboucher Aug 16, 2026
be83109
fix(#190): update actions/cache to v5 for Node.js 24 compatibility in…
fboucher-os Sep 5, 2026
ae568c8
Update version to 1.4.2, add sync progress tracking, and reader page …
fboucher-os Sep 5, 2026
322848d
ci: include version in android apk filename (#193)
fboucher Sep 5, 2026
6c944fa
Merge branch 'main' into v-next
fboucher Sep 5, 2026
305d527
Fix document sync stuck and improve sync progress and offline fallbac…
fboucher-os Sep 19, 2026
d51dd46
fix: constrain reader text size slider and add step buttons (fixes #1…
fboucher-os Sep 19, 2026
239cbc9
feat: Add markdown saving for reading notes and bump version to 1.4.3
fboucher-os Sep 19, 2026
47f9e59
feat: add MAUI storage tab (#200)
fboucher-os Sep 19, 2026
9d76219
chore: bump application version to 1.4.3 (#201)
fboucher-os Sep 19, 2026
bce656e
Merge branch 'main' into v-next
fboucher Sep 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.4.2</Version>
<Version>1.4.3</Version>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
116 changes: 111 additions & 5 deletions src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public PostReaderTests()
}

[Fact]
public void PostReader_RendersTitleAndContentAndSlidersAndBackButtonsAtTopAndBottom()
public void PostReader_RendersTitleAndContentAndSlidersAndButtonsAtTopAndBottom()
{
var cut = Render<PostReader>(ps => ps.Add(p => p.PostId, "p1"));

Expand All @@ -46,15 +46,25 @@ public void PostReader_RendersTitleAndContentAndSlidersAndBackButtonsAtTopAndBot

var sliders = cut.FindComponents<FluentSlider<int>>();
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<FluentButton>()
.Where(b => b.Instance.Title == "Back to posts")
.ToList();
backButtons.Should().HaveCount(2);

var decreaseButtons = cut.FindComponents<FluentButton>()
.Where(b => b.Instance.Title == "Decrease text size")
.ToList();
decreaseButtons.Should().HaveCount(2);

var increaseButtons = cut.FindComponents<FluentButton>()
.Where(b => b.Instance.Title == "Increase text size")
.ToList();
increaseButtons.Should().HaveCount(2);
}

[Fact]
Expand Down Expand Up @@ -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<PostReader>(ps => ps.Add(p => p.PostId, "p1"));

var sliders = cut.FindComponents<FluentSlider<int>>();
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<PostReader>(ps => ps.Add(p => p.PostId, "p1"));

var decreaseButton = cut.FindComponents<FluentButton>()
.First(b => b.Instance.Title == "Decrease text size");
var increaseButton = cut.FindComponents<FluentButton>()
.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<PostReader>(ps => ps.Add(p => p.PostId, "p1"));

var decreaseButtons = cut.FindComponents<FluentButton>()
.Where(b => b.Instance.Title == "Decrease text size")
.ToList();
var increaseButtons = cut.FindComponents<FluentButton>()
.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<PostReader>(ps => ps.Add(p => p.PostId, "p1"));

var sliders = cut.FindComponents<FluentSlider<int>>();

// Set to minimum (12)
cut.InvokeAsync(() => sliders[0].Instance.ValueChanged.InvokeAsync(12));

var decreaseButtonsAtMin = cut.FindComponents<FluentButton>()
.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<FluentButton>()
.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;");
}
}
30 changes: 30 additions & 0 deletions src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,5 +162,35 @@ public void Posts_DisplaysCleaningStatus_WhenSyncProgressChangedFired()

cut.Markup.Should().Contain("Cleaning...");
}

[Fact]
public void Posts_SyncProgressChanged_WhenIsComplete_ReloadsPosts()
{
var cut = Render<Posts>();

_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<Posts>();

var buttons = cut.FindComponents<FluentButton>();
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();
}
}

6 changes: 5 additions & 1 deletion src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">


<PropertyGroup>
<Version>1.4.3</Version>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Azure.Data.Tables" />
<PackageReference Include="Azure.Data.Tables" />
Expand Down
4 changes: 3 additions & 1 deletion src/NoteBookmark.Domain/SyncProgressEventArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
41 changes: 41 additions & 0 deletions src/NoteBookmark.MauiApp.Tests/LocalHtmlStorageServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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", "<p>Hello</p>");

service.IsPostHtmlCached("post-1").Should().BeTrue();
(await service.GetPostHtmlAsync("post-1")).Should().Be("<p>Hello</p>");
service.GetCachedPostIds().Should().ContainSingle("post-1");
}

[Fact]
public async Task RemovePostHtml_ShouldRemoveCachedContent()
{
var service = new LocalHtmlStorageService(_directory);
await service.SavePostHtmlAsync("post-1", "<p>Hello</p>");

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
<Compile Include="..\NoteBookmark.MauiApp\Data\LocalDataService.cs" Link="Data\LocalDataService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\ISyncApiClient.cs" Link="Data\ISyncApiClient.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\ILocalHtmlStorageService.cs" Link="Data\ILocalHtmlStorageService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\LocalHtmlStorageService.cs" Link="Data\LocalHtmlStorageService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\StorageService.cs" Link="Data\StorageService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\SyncService.cs" Link="Data\SyncService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\SyncApiClient.cs" Link="Data\SyncApiClient.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\MauiUrlLauncher.cs" Link="Data\MauiUrlLauncher.cs" />
Expand Down
69 changes: 69 additions & 0 deletions src/NoteBookmark.MauiApp.Tests/StorageServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<IDataService> _dataService = new();
private readonly Mock<ILocalHtmlStorageService> _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("<p>Post</p>");

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", "<p>Post</p>"), 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<string>()), Times.Never);
}

[Fact]
public async Task DownloadPostAsync_ShouldShareAnInProgressDownload()
{
var gate = new TaskCompletionSource<string?>();
_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("<p>Post</p>");

(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);
}
}
Loading
Loading