Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
34548d0
fix(ci): fix timeout and rate limit failures in OpenCode Daily Triage…
fboucher-os Sep 20, 2026
39e20c8
feat(ui): add dedicated quickadd popup and MinimalLayout for bookmark…
fboucher-os Sep 20, 2026
73a2d76
Speed up MAUI app sync (#208) (#209)
fboucher Sep 30, 2026
a00be97
chore: bump application version to 1.4.4
fboucher-os Sep 30, 2026
98441ef
Merge branch 'main' into v-next
fboucher-os Sep 30, 2026
0af4ab2
Fix bookmarklet saving the same post twice (#211) (#212)
fboucher Oct 4, 2026
d4f75a8
Add offline news and updates to home page (#214)
fboucher-os Oct 24, 2026
3e59141
Merge branch 'main' into v-next
claude Oct 25, 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
3 changes: 3 additions & 0 deletions docs/news.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# News & Updates

Welcome to NoteBookmark! Check back here for product updates.
30 changes: 30 additions & 0 deletions src/NoteBookmark.Api.Tests/Endpoints/PostEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,36 @@ public async Task ExtractPostDetails_WithValidUrl_ReturnsOkWithPost()
post.Title.Should().NotBeNullOrEmpty();
}

[Fact]
public async Task ExtractPostDetails_CalledTwiceForSameUrl_StoresSinglePostAndReturnsExisting()
{
// Arrange
var extractRequest = new
{
url = "https://example.com/blog/duplicate-check-" + Guid.NewGuid(),
tags = "test",
category = "Test"
};

// Act
var firstResponse = await _client.PostAsJsonAsync("/api/posts/extractPostDetails", extractRequest);
var secondResponse = await _client.PostAsJsonAsync("/api/posts/extractPostDetails", extractRequest);

// Assert
firstResponse.StatusCode.Should().Be(HttpStatusCode.OK);
secondResponse.StatusCode.Should().Be(HttpStatusCode.OK);

var firstPost = await firstResponse.Content.ReadFromJsonAsync<Post>();
var secondPost = await secondResponse.Content.ReadFromJsonAsync<Post>();
firstPost.Should().NotBeNull();
secondPost.Should().NotBeNull();
secondPost!.RowKey.Should().Be(firstPost!.RowKey);

var unreadPosts = await _client.GetFromJsonAsync<List<PostL>>("/api/posts/");
unreadPosts.Should().NotBeNull();
unreadPosts!.Count(p => p.Url == extractRequest.url).Should().Be(1);
}

[Fact]
public async Task ExtractPostDetails_WithInvalidUrl_ReturnsBadRequest()
{
Expand Down
28 changes: 28 additions & 0 deletions src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
public DataStorageServiceTests(AzureStorageTestFixture fixture)
{
_fixture = fixture;
_dataStorageService = new DataStorageService(_fixture.TableServiceClient, _fixture.BlobServiceClient);

Check warning on line 21 in src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Possible null reference argument for parameter 'blobClient' in 'DataStorageService.DataStorageService(TableServiceClient tblClient, BlobServiceClient blobClient)'.

Check warning on line 21 in src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Possible null reference argument for parameter 'tblClient' in 'DataStorageService.DataStorageService(TableServiceClient tblClient, BlobServiceClient blobClient)'.
}

[Fact]
Expand All @@ -26,7 +26,7 @@
{
// Arrange
var testPost = CreateTestPost();
var table = _fixture.TableServiceClient.GetTableClient("Posts");

Check warning on line 29 in src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Dereference of a possibly null reference.
table.CreateIfNotExists();
table.AddEntity(testPost);

Expand All @@ -53,6 +53,34 @@
result.Should().BeNull();
}

[Fact]
public void GetPostByUrl_WhenPostExists_ReturnsPost()
{
// Arrange
var testPost = CreateTestPost();
testPost.Url = "https://example.com/by-url-" + Guid.NewGuid();
var table = _fixture.TableServiceClient!.GetTableClient("Posts");
table.CreateIfNotExists();
table.AddEntity(testPost);

// Act
var result = _dataStorageService.GetPostByUrl(testPost.Url);

// Assert
result.Should().NotBeNull();
result!.RowKey.Should().Be(testPost.RowKey);
}

[Fact]
public void GetPostByUrl_WhenNoPostHasUrl_ReturnsNull()
{
// Act
var result = _dataStorageService.GetPostByUrl("https://example.com/missing-" + Guid.NewGuid());

// Assert
result.Should().BeNull();
}

[Fact]
public void SavePost_WhenNewPost_AddsPostSuccessfully()
{
Expand Down Expand Up @@ -282,7 +310,7 @@
var summary1 = CreateTestSummary("summary1");
var summary2 = CreateTestSummary("summary2");

var table = _fixture.TableServiceClient.GetTableClient("Summary");

Check warning on line 313 in src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Dereference of a possibly null reference.
table.CreateIfNotExists();
table.AddEntity(summary1);
table.AddEntity(summary2);
Expand Down
6 changes: 6 additions & 0 deletions src/NoteBookmark.Api/DataStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ public List<string> GetPostIds()
return post;
}

public Post? GetPostByUrl(string url)
{
var tblPost = GetPostTable();
return tblPost.Query<Post>(p => p.Url == url).FirstOrDefault();
}

public bool SavePost(Post post)
{
var tblPost = GetPostTable();
Expand Down
2 changes: 2 additions & 0 deletions src/NoteBookmark.Api/IDataStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public interface IDataStorageService

public Post? GetPost(string rowKey);

public Post? GetPostByUrl(string url);

public bool SavePost(Post post);

public List<Summary> GetSummaries();
Expand Down
9 changes: 9 additions & 0 deletions src/NoteBookmark.Api/PostEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ static async Task<Results<Ok<Post>, BadRequest>> ExtractPostDetails(
try
{
var decodeUrl = System.Net.WebUtility.UrlDecode(request.url);

// The same URL can be submitted more than once (e.g. a prerendered page followed
// by its interactive render), so return the existing post instead of adding a duplicate.
var existingPost = dataStorageService.GetPostByUrl(decodeUrl);
if (existingPost != null)
{
return TypedResults.Ok(existingPost);
}

var post = await ExtractPostDetailsFromUrl(decodeUrl);
if (post != null)
{
Expand Down
53 changes: 53 additions & 0 deletions src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ namespace NoteBookmark.BlazorApp.Tests.Tests;

public sealed class QuickAddTests : BunitContext
{
private static readonly RendererInfo Prerendering = new("Static", isInteractive: false);
private static readonly RendererInfo Interactive = new("Server", isInteractive: true);

private readonly Mock<IDataService> _dataServiceMock;

public QuickAddTests()
Expand All @@ -28,6 +31,8 @@ public QuickAddTests()
Services.AddSingleton(_dataServiceMock.Object);
Services.AddSingleton(new Mock<IToastService>().Object);
Services.AddSingleton(new Mock<IDialogService>().Object);

SetRendererInfo(Interactive);
}

[Fact]
Expand Down Expand Up @@ -128,4 +133,52 @@ public void QuickAdd_WithoutUrlParameter_DisplaysManualInput()
cut.Markup.Should().Contain("Enter or paste URL");
cut.Markup.Should().Contain("Close Window");
}

[Fact]
public void QuickAdd_WhenPrerenderedThenRenderedInteractively_SavesPostOnlyOnce()
{
const string testUrl = "https://example.com/bookmarklet-post";
var samplePost = new Post
{
PartitionKey = "p",
RowKey = "bookmarklet-row-key",
Title = "Bookmarklet Post",
Author = "Jane Doe",
Url = testUrl
};

_dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl))
.ReturnsAsync(samplePost);

var nav = Services.GetRequiredService<NavigationManager>();
nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}");

// A page visit under InteractiveServer creates one instance for the prerender
// and a second, fresh instance once the circuit is interactive.
SetRendererInfo(Prerendering);
Render<QuickAdd>();

SetRendererInfo(Interactive);
var cut = Render<QuickAdd>();

cut.Markup.Should().Contain("Post saved successfully!");
cut.Markup.Should().Contain("Bookmarklet Post");
_dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(testUrl), Times.Once);
}

[Fact]
public void QuickAdd_WhenPrerendering_ShowsProgressWithoutSaving()
{
const string testUrl = "https://example.com/prerender";
SetRendererInfo(Prerendering);

var nav = Services.GetRequiredService<NavigationManager>();
nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}");

var cut = Render<QuickAdd>();

cut.Markup.Should().Contain("Extracting and saving post details...");
cut.Markup.Should().NotContain("Enter or paste URL");
_dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(It.IsAny<string>()), Times.Never);
}
}
185 changes: 185 additions & 0 deletions src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NoteBookmark.MauiApp.Data;

namespace NoteBookmark.MauiApp.Tests;

public sealed class NewsFeedServiceTests : IDisposable
{
private readonly string _appDataDirectory = Path.Combine(Path.GetTempPath(), $"NoteBookmark-News-{Guid.NewGuid():N}");

[Fact]
public async Task RefreshAsync_CachesMarkdownAndReferencedImagesForOfflineRendering()
{
var handler = new StubHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/docs/news.md", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("# Updates\n\n![Logo](assets/logo.png)\n\n[Unsafe](javascript:alert(1))", Encoding.UTF8, "text/plain")
};
}

if (request.RequestUri.AbsolutePath.EndsWith("/docs/assets/logo.png", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
};
}

return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var service = CreateService(handler);

await service.RefreshAsync();
var html = await service.GetHtmlAsync();

html.Should().Contain("<h1>Updates</h1>");
html.Should().Contain("src=\"data:image/png;base64,AQID\"");
html.Should().Contain("href=\"#\"");
File.Exists(Path.Combine(_appDataDirectory, "docs", "news.md")).Should().BeTrue();
File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "logo.png")).Should().BeTrue();
}

[Fact]
public async Task GetHtmlAsync_WhenNoFeedIsCached_LoadsRemoteFeed()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("# Online feed", Encoding.UTF8, "text/plain")
});
var service = CreateService(handler);

(await service.GetHtmlAsync()).Should().Contain("<h1>Online feed</h1>");
File.Exists(Path.Combine(_appDataDirectory, "docs", "news.md")).Should().BeTrue();
}

[Fact]
public async Task RefreshAsync_WhenRemoteRefreshFails_KeepsCachedFeed()
{
var requestCount = 0;
var handler = new StubHttpMessageHandler(_ =>
{
requestCount++;
if (requestCount == 1)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("# Existing feed", Encoding.UTF8, "text/plain")
};
}

throw new HttpRequestException("Network unavailable.");
});
var service = CreateService(handler);
await service.RefreshAsync();

var act = () => service.RefreshAsync();
await act.Should().ThrowAsync<HttpRequestException>();
(await service.GetHtmlAsync()).Should().Contain("<h1>Existing feed</h1>");
}

[Fact]
public async Task GetHtmlAsync_WhenNoFeedIsCached_ReturnsFriendlyFallback()
{
var service = CreateService(new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)));

(await service.GetHtmlAsync()).Should().Contain("Welcome to NoteBookmark");
}

[Fact]
public async Task RefreshAsync_CachesReferenceStyleImagesAndPathsWithParentheses()
{
var service = CreateService(CreateFeedHandler(
"# Updates\n\n![Logo][logo]\n\n![Chart](assets/chart(1).png)\n\n[logo]: assets/logo.png",
"/docs/assets/logo.png",
"/docs/assets/chart%281%29.png"));

await service.RefreshAsync();
var html = await service.GetHtmlAsync();

html.Should().NotContain("assets/");
html.Should().Contain("src=\"data:image/png;base64,AQID\" alt=\"Logo\"");
html.Should().Contain("src=\"data:image/png;base64,AQID\" alt=\"Chart\"");
File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "logo.png")).Should().BeTrue();
File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "chart(1).png")).Should().BeTrue();
}

[Fact]
public async Task RefreshAsync_IgnoresImageSyntaxInsideCodeBlocks()
{
var service = CreateService(CreateFeedHandler(
"# Updates\n\n```markdown\n![Example](assets/missing.png)\n```\n\n`![Inline](assets/also-missing.png)`"));

await service.RefreshAsync();
var html = await service.GetHtmlAsync();

html.Should().Contain("![Example](assets/missing.png)");
Directory.Exists(Path.Combine(_appDataDirectory, "docs", "assets")).Should().BeFalse();
}

[Fact]
public async Task RefreshAsync_RaisesFeedUpdatedOnlyAfterSuccessfulRefresh()
{
var fail = false;
var handler = new StubHttpMessageHandler(_ => fail
? throw new HttpRequestException("Network unavailable.")
: new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("# Feed", Encoding.UTF8, "text/plain") });
var service = CreateService(handler);
var updates = 0;
service.FeedUpdated += (_, _) => updates++;

await service.RefreshAsync();
fail = true;
var act = () => service.RefreshAsync();

await act.Should().ThrowAsync<HttpRequestException>();
updates.Should().Be(1);
}

public void Dispose()
{
if (Directory.Exists(_appDataDirectory))
{
Directory.Delete(_appDataDirectory, recursive: true);
}
}

private NewsFeedService CreateService(HttpMessageHandler handler)
{
var logger = new Mock<ILogger<NewsFeedService>>();
return new NewsFeedService(new HttpClient(handler), logger.Object, _appDataDirectory);
}

private static StubHttpMessageHandler CreateFeedHandler(string markdown, params string[] assetPaths) =>
new(request =>
{
var path = request.RequestUri!.AbsolutePath;
if (path.EndsWith("/docs/news.md", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(markdown, Encoding.UTF8, "text/plain")
};
}

return Array.Exists(assetPaths, assetPath => path.EndsWith(assetPath, StringComparison.Ordinal))
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[] { 1, 2, 3 }) }
: new HttpResponseMessage(HttpStatusCode.NotFound);
});

private sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(responseFactory(request));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
<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\INewsFeedService.cs" Link="Data\INewsFeedService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\NewsFeedService.cs" Link="Data\NewsFeedService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\SyncApiClient.cs" Link="Data\SyncApiClient.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\MauiUrlLauncher.cs" Link="Data\MauiUrlLauncher.cs" />
</ItemGroup>
Expand Down
Loading
Loading