Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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);
}
}
1 change: 1 addition & 0 deletions src/NoteBookmark.MauiApp/Components/Layout/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<FluentNavLink Href="summaries" Icon="@(new Icons.Regular.Size20.BookNumber())" IconColor="Color.Accent" OnClick="CloseMenu">Summaries</FluentNavLink>
<FluentNavLink Href="summaryeditor" Icon="@(new Icons.Regular.Size20.ArrowAutofitHeightIn())" IconColor="Color.Accent" OnClick="CloseMenu">Generate Summary</FluentNavLink>
<FluentNavLink Href="search" Icon="@(new Icons.Regular.Size20.SearchSparkle())" IconColor="Color.Accent" OnClick="CloseMenu">Search</FluentNavLink>
<FluentNavLink Href="storage" Icon="@(new Icons.Regular.Size20.Folder())" IconColor="Color.Accent" OnClick="CloseMenu">Storage</FluentNavLink>
<FluentNavLink Href="appsettings" Icon="@(new Icons.Regular.Size20.Settings())" IconColor="Color.Accent" OnClick="CloseMenu">App Settings</FluentNavLink>
<FluentNavLink Href="settings" Icon="@(new Icons.Regular.Size20.Server())" IconColor="Color.Accent" OnClick="CloseMenu">Server Settings</FluentNavLink>
</FluentNavMenu>
Expand Down
232 changes: 232 additions & 0 deletions src/NoteBookmark.MauiApp/Components/Pages/Storage.razor
Original file line number Diff line number Diff line change
@@ -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

<PageTitle>Storage</PageTitle>

<h1>Storage</h1>

<FluentStack Orientation="Orientation.Vertical" Style="gap: 1rem;">
<FluentStack Orientation="Orientation.Horizontal">
<FluentButton OnClick="SyncNow"
IconStart="@(new Icons.Regular.Size20.ArrowSync())"
Loading="@isSyncing"
Disabled="@(isSyncing || DataService.IsSyncing || DataService.IsOffline)"
Title="Sync posts and local files">Sync</FluentButton>
</FluentStack>

@if (isSyncing || DataService.IsSyncing || !string.IsNullOrEmpty(syncProgressStatus))
{
<FluentStack Orientation="Orientation.Vertical" Style="gap: 0.25rem;">
<FluentProgress Min="0"
Max="@(syncProgressTotal > 0 ? syncProgressTotal : 100)"
Value="@(syncProgressTotal > 0 ? syncProgressCurrent : null)" />
@if (!string.IsNullOrEmpty(syncProgressStatus))
{
<span style="font-size: 0.85rem;">@syncProgressStatus</span>
}
</FluentStack>
}

@if (isLoading)
{
<p><em>Loading unread posts...</em></p>
}
else if (posts.Count == 0)
{
<p>No unread posts.</p>
}
else
{
<FluentDataGrid Id="storagegrid" Items="@PostItems" RowSize="DataGridRowSize.Medium">
<TemplateColumn Title="Actions" Width="180px">
@{
var postId = context!.Id ?? context.RowKey;
}
<FluentButton OnClick="@(() => DownloadPost(postId))"
Disabled="@IsDownloadDisabled(postId)"
Appearance="@GetDownloadAppearance(postId)"
Loading="@IsDownloading(postId)"
IconEnd="@(new Icons.Regular.Size20.ArrowDownload())"
Title="Download post" />
<FluentButton OnClick="@(() => DeletePost(postId))"
Disabled="@IsDeleteDisabled(postId)"
Appearance="@GetDeleteAppearance(postId)"
IconEnd="@(new Icons.Regular.Size20.Delete())"
Title="Delete local file" />
</TemplateColumn>
<PropertyColumn Title="Title" Property="@(post => post!.Title)" Sortable="true" />
<TemplateColumn Title="Local file" Width="190px">
@{
var postId = context!.Id ?? context.RowKey;
}
@if (IsDownloading(postId))
{
<FluentProgress />
}
else
{
<span>@(IsCached(postId) ? "Available offline" : "Not downloaded")</span>
}
</TemplateColumn>
</FluentDataGrid>
}
</FluentStack>

@code {
private readonly List<PostL> posts = [];
private readonly HashSet<string> cachedPostIds = [];
private readonly HashSet<string> downloadingPostIds = [];
private IQueryable<PostL> 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;
}
}
Loading
Loading