diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs index c4e5ec9..2ebc1bc 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs @@ -1,196 +1,197 @@ -using Bunit; -using Bunit.TestDoubles; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.FluentUI.AspNetCore.Components; -using Moq; -using NoteBookmark.BlazorApp.Tests.Helpers; -using NoteBookmark.Domain; -using NoteBookmark.SharedUI; -using NoteBookmark.SharedUI.Components.Pages; - -namespace NoteBookmark.BlazorApp.Tests.Tests; - -/// -/// Tests for the Posts page in NoteBookmark.SharedUI. -/// Covers the show/hide published date toggle, title filter, and read/unread switching. -/// -public sealed class PostsTests : BunitContext -{ - private readonly Mock _dataServiceMock; - - private static List SamplePosts() => - [ - new PostL { PartitionKey = "p", RowKey = "1", Title = "First Post", Url = "https://example.com/1", Date_published = "2025-01-15T00:00:00", is_read = false }, - new PostL { PartitionKey = "p", RowKey = "2", Title = "Second Post", Url = "https://example.com/2", Date_published = "2025-06-20T00:00:00", is_read = false }, - ]; - - public PostsTests() - { - this.AddFluentUI(); - this.AddAuthorization().SetAuthorized("testuser"); - - _dataServiceMock = new Mock(); - _dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync(SamplePosts()); - _dataServiceMock.Setup(s => s.GetReadPosts()).ReturnsAsync([]); - _dataServiceMock.Setup(s => s.SyncAsync()).Returns(Task.CompletedTask); - _dataServiceMock.SetupGet(s => s.IsOffline).Returns(false); - _dataServiceMock.SetupGet(s => s.CanSync).Returns(false); - - Services.AddSingleton(_dataServiceMock.Object); - Services.AddSingleton(new Mock().Object); - Services.AddSingleton(new Mock().Object); - } - - [Fact] - public void Posts_RendersWithoutThrowing() - { - var cut = Render(); - - cut.Markup.Should().NotBeNullOrEmpty(); - } - - [Fact] - public void Posts_RendersPostTitles() - { - var cut = Render(); - - cut.Markup.Should().Contain("First Post"); - cut.Markup.Should().Contain("Second Post"); - } - - [Fact] - public void Posts_PublishedDateColumn_HiddenByDefault() - { - var cut = Render(); - - // The Published column header text should not appear as a grid header; - // "Show Published Date" (the checkbox label) still contains "Published" as a substring, - // so we look for the exact header cell pattern instead. - cut.Markup.Should().NotMatchRegex(@"col-title-text[^>]*>Published<"); - } - - [Fact] - public void Posts_PublishedDateColumn_VisibleAfterToggle() - { - var cut = Render(); - - // Find and click the "Show Published Date" checkbox - var checkbox = cut.Find("fluent-checkbox"); - checkbox.Click(); - - cut.Markup.Should().Contain("Published"); - } - - [Fact] - public void Posts_ShowPublishedDateCheckbox_IsRendered() - { - var cut = Render(); - - cut.Markup.Should().Contain("Show Published Date"); - } - - [Fact] - public void Posts_TitleFilter_RendersFilterButton() - { - var cut = Render(); - - // The Title column renders a filter button; the options panel (with the search input) - // only opens after the button is clicked, so we verify the button is present. - cut.Markup.Should().Contain("Filter this column"); - } - - [Fact] - public void Posts_LoadsUnreadPostsByDefault() - { - Render(); - - _dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce); - } - - [Fact] - public void Posts_RendersAddButton() - { - var cut = Render(); - - // The URL input and add button are present - cut.Markup.Should().Contain("Enter URL"); - } - - [Fact] - public void Posts_RendersEmptyState_WhenNoPostsReturned() - { - _dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync([]); - - var cut = Render(); - - cut.Markup.Should().Contain("Nothing to see here"); - } - - [Fact] - public void Posts_ReadPostButton_IsAlwaysRendered() - { - var cut = Render(); - - 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..."); - } - - [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(); - } -} - +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Moq; +using NoteBookmark.BlazorApp.Tests.Helpers; +using NoteBookmark.Domain; +using NoteBookmark.SharedUI; +using NoteBookmark.SharedUI.Components.Pages; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +/// +/// Tests for the Posts page in NoteBookmark.SharedUI. +/// Covers the show/hide published date toggle, title filter, and read/unread switching. +/// +public sealed class PostsTests : BunitContext +{ + private readonly Mock _dataServiceMock; + + private static List SamplePosts() => + [ + new PostL { PartitionKey = "p", RowKey = "1", Title = "First Post", Url = "https://example.com/1", Date_published = "2025-01-15T00:00:00", is_read = false }, + new PostL { PartitionKey = "p", RowKey = "2", Title = "Second Post", Url = "https://example.com/2", Date_published = "2025-06-20T00:00:00", is_read = false }, + ]; + + public PostsTests() + { + this.AddFluentUI(); + this.AddAuthorization().SetAuthorized("testuser"); + + _dataServiceMock = new Mock(); + _dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync(SamplePosts()); + _dataServiceMock.Setup(s => s.GetReadPosts()).ReturnsAsync([]); + _dataServiceMock.Setup(s => s.SyncAsync()).Returns(Task.CompletedTask); + _dataServiceMock.SetupGet(s => s.IsOffline).Returns(false); + _dataServiceMock.SetupGet(s => s.CanSync).Returns(false); + + Services.AddSingleton(_dataServiceMock.Object); + Services.AddSingleton(new Mock().Object); + Services.AddSingleton(new Mock().Object); + } + + [Fact] + public void Posts_RendersWithoutThrowing() + { + var cut = Render(); + + cut.Markup.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Posts_RendersPostTitles() + { + var cut = Render(); + + cut.Markup.Should().Contain("First Post"); + cut.Markup.Should().Contain("Second Post"); + } + + [Fact] + public void Posts_PublishedDateColumn_HiddenByDefault() + { + var cut = Render(); + + // The Published column header text should not appear as a grid header; + // "Show Published Date" (the checkbox label) still contains "Published" as a substring, + // so we look for the exact header cell pattern instead. + cut.Markup.Should().NotMatchRegex(@"col-title-text[^>]*>Published<"); + } + + [Fact] + public void Posts_PublishedDateColumn_VisibleAfterToggle() + { + var cut = Render(); + + // Find and click the "Show Published Date" checkbox + var checkbox = cut.Find("fluent-checkbox"); + checkbox.Click(); + + cut.Markup.Should().Contain("Published"); + } + + [Fact] + public void Posts_ShowPublishedDateCheckbox_IsRendered() + { + var cut = Render(); + + cut.Markup.Should().Contain("Show Published Date"); + } + + [Fact] + public void Posts_TitleFilter_RendersFilterButton() + { + var cut = Render(); + + // The Title column renders a filter button; the options panel (with the search input) + // only opens after the button is clicked, so we verify the button is present. + cut.Markup.Should().Contain("Filter this column"); + } + + [Fact] + public void Posts_LoadsUnreadPostsByDefault() + { + Render(); + + _dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce); + } + + [Fact] + public void Posts_RendersAddButton() + { + var cut = Render(); + + // The URL input and add button are present + cut.Markup.Should().Contain("Enter URL"); + } + + [Fact] + public void Posts_RendersEmptyState_WhenNoPostsReturned() + { + _dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync([]); + + var cut = Render(); + + cut.Markup.Should().Contain("Nothing to see here"); + } + + [Fact] + public void Posts_ReadPostButton_IsAlwaysRendered() + { + var cut = Render(); + + 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..."); + } + + [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.Tests/Tests/QuickAddTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs new file mode 100644 index 0000000..f51e4c3 --- /dev/null +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs @@ -0,0 +1,131 @@ +using Bunit; +using Bunit.TestDoubles; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Moq; +using NoteBookmark.BlazorApp.Tests.Helpers; +using NoteBookmark.Domain; +using NoteBookmark.SharedUI; +using NoteBookmark.SharedUI.Components.Pages; +using Xunit; + +namespace NoteBookmark.BlazorApp.Tests.Tests; + +public sealed class QuickAddTests : BunitContext +{ + private readonly Mock _dataServiceMock; + + public QuickAddTests() + { + this.AddFluentUI(); + this.AddAuthorization().SetAuthorized("testuser"); + + _dataServiceMock = new Mock(); + _dataServiceMock.SetupGet(s => s.IsOffline).Returns(false); + + Services.AddSingleton(_dataServiceMock.Object); + Services.AddSingleton(new Mock().Object); + Services.AddSingleton(new Mock().Object); + } + + [Fact] + public void QuickAdd_WithUrlParameter_ExtractsAndDisplaysPostDetails() + { + const string testUrl = "https://example.com/my-post"; + var samplePost = new Post + { + PartitionKey = "p", + RowKey = "12345", + Title = "Awesome .NET Post", + Author = "Jane Doe", + Url = testUrl + }; + + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)) + .ReturnsAsync(samplePost); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Post saved successfully!"); + cut.Markup.Should().Contain("Awesome .NET Post"); + cut.Markup.Should().Contain("Jane Doe"); + cut.Markup.Should().Contain("Close Window"); + cut.Markup.Should().Contain("Edit"); + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(testUrl), Times.Once); + } + + [Fact] + public void QuickAdd_WhenEditClicked_NavigatesToPostEditorLight() + { + const string testUrl = "https://example.com/edit-post"; + var samplePost = new Post + { + PartitionKey = "p", + RowKey = "post-row-key-999", + Title = "Post to Edit", + Author = "Author Name", + Url = testUrl + }; + + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)) + .ReturnsAsync(samplePost); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + var editButton = cut.FindAll("fluent-button").FirstOrDefault(e => e.TextContent.Contains("Edit")); + editButton.Should().NotBeNull(); + editButton!.Click(); + + nav.Uri.Should().EndWith("posteditorlight/post-row-key-999"); + } + + [Fact] + public void QuickAdd_WhenOffline_DisplaysErrorMessage() + { + const string testUrl = "https://example.com/offline"; + _dataServiceMock.SetupGet(s => s.IsOffline).Returns(true); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Cannot extract posts while offline."); + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(It.IsAny()), Times.Never); + } + + [Fact] + public void QuickAdd_WhenExtractionFails_DisplaysErrorMessage() + { + const string testUrl = "https://example.com/fail"; + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)) + .ReturnsAsync((Post?)null); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Failed to extract and save post details"); + } + + [Fact] + public void QuickAdd_WithoutUrlParameter_DisplaysManualInput() + { + var nav = Services.GetRequiredService(); + nav.NavigateTo("http://localhost/quickadd"); + + var cut = Render(); + + cut.Markup.Should().Contain("Enter or paste URL"); + cut.Markup.Should().Contain("Close Window"); + } +} diff --git a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs index 5834142..c14d03f 100644 --- a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs @@ -1,343 +1,343 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Maui.Networking; -using NoteBookmark.Domain; -using NoteBookmark.SharedUI; - -namespace NoteBookmark.MauiApp.Data; - -public class OfflineDataService(PostNoteClient apiClient, ILocalDataService localDataService, IConnectivity connectivity, ISyncService syncService, ILocalHtmlStorageService localHtmlStorageService) : IDataService -{ - private bool IsOnline => connectivity.NetworkAccess == NetworkAccess.Internet; - - public async Task> GetUnreadPosts() - { - if (IsOnline) - { - var posts = await apiClient.GetUnreadPosts(); - await MergeLocalNotesIntoRemotePosts(posts); - return posts; - } - else - { - var allPosts = await localDataService.GetPostsAsync(); - var allNotes = await localDataService.GetNotesAsync(); - - return allPosts.Where(p => p.is_read != true).Select(p => { - var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); - return new PostL - { - Id = p.Id, - Title = p.Title, - Date_published = p.Date_published, - Url = p.Url, - Excerpt = p.Excerpt, - is_read = p.is_read, - PartitionKey = p.PartitionKey, - RowKey = p.RowKey, - NoteId = note?.RowKey, - Note = note?.Comment, - DateModified = p.DateModified - }; - }).ToList(); - } - } - - public async Task> GetReadPosts() - { - if (IsOnline) - { - var posts = await apiClient.GetReadPosts(); - await MergeLocalNotesIntoRemotePosts(posts); - return posts; - } - else - { - var allPosts = await localDataService.GetPostsAsync(); - var allNotes = await localDataService.GetNotesAsync(); - - return allPosts.Where(p => p.is_read == true).Select(p => { - var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); - return new PostL - { - Id = p.Id, - Title = p.Title, - Date_published = p.Date_published, - Url = p.Url, - Excerpt = p.Excerpt, - is_read = p.is_read, - PartitionKey = p.PartitionKey, - RowKey = p.RowKey, - NoteId = note?.RowKey, - Note = note?.Comment, - DateModified = p.DateModified - }; - }).ToList(); - } - } - - public async Task> GetSummaries() - { - if (IsOnline) - { - var summaries = await apiClient.GetSummaries(); - await localDataService.SaveSummariesAsync(summaries); - return summaries; - } - else - { - return await localDataService.GetSummariesAsync(); - } - } - - public async Task CreateNote(Note note) - { - note.DateModified = DateTime.UtcNow; - if (IsOnline) - { - await apiClient.CreateNote(note); - await localDataService.SaveNoteAsync(note); - var post = await localDataService.GetPostAsync(note.PostId!); - if (post != null) - { - post.is_read = true; - await localDataService.SavePostAsync(post, isPendingSync: false); - } - } - else - { - var settings = await localDataService.GetSettingsAsync(); - note.PartitionKey = settings?.ReadingNotesCounter ?? note.PartitionKey; - note.CreatedOffline = true; - await localDataService.SaveNoteAsync(note, isPendingSync: true); - var post = await localDataService.GetPostAsync(note.PostId!); - if (post != null) - { - post.is_read = true; - post.DateModified = DateTime.UtcNow; - await localDataService.SavePostAsync(post, isPendingSync: true); - } - } - } - - public async Task GetNote(string noteId) - { - if (IsOnline) - { - var note = await apiClient.GetNote(noteId); - if (note != null) await localDataService.SaveNoteAsync(note); - return note; - } - else - { - return await localDataService.GetNoteAsync(noteId); - } - } - - public async Task UpdateNote(Note note) - { - note.DateModified = DateTime.UtcNow; - if (IsOnline) - { - var success = await apiClient.UpdateNote(note); - if (success) await localDataService.SaveNoteAsync(note); - return success; - } - else - { - await localDataService.SaveNoteAsync(note, isPendingSync: true); - return true; - } - } - - public async Task DeleteNote(string noteId) - { - if (IsOnline) - { - var success = await apiClient.DeleteNote(noteId); - if (success) - { - await localDataService.DeleteNoteAsync(noteId); - } - return success; - } - else - { - await localDataService.DeleteNoteAsync(noteId, isPendingSync: true); - return true; - } - } - - 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) - { - try - { - var post = await apiClient.GetPost(id); - if (post != null) await localDataService.SavePostAsync(post); - return post; - } - catch - { - return await localDataService.GetPostAsync(id); - } - } - else - { - return await localDataService.GetPostAsync(id); - } - } - - public async Task SavePost(Post post) - { - post.DateModified = DateTime.UtcNow; - if (IsOnline) - { - var success = await apiClient.SavePost(post); - if (success) await localDataService.SavePostAsync(post); - return success; - } - else - { - await localDataService.SavePostAsync(post, isPendingSync: true); - return true; - } - } - - public async Task GetSettings() - { - if (IsOnline) - { - var settings = await apiClient.GetSettings(); - if (settings != null) await localDataService.SaveSettingsAsync(settings); - return settings; - } - else - { - return await localDataService.GetSettingsAsync(); - } - } - - public async Task SaveSettings(Settings settings) - { - if (IsOnline) - { - var success = await apiClient.SaveSettings(settings); - if (success) await localDataService.SaveSettingsAsync(settings); - return success; - } - else - { - // For now, offline settings saves don't sync. - await localDataService.SaveSettingsAsync(settings); - return true; - } - } - - public async Task ExtractPostDetailsAndSave(string url) - { - if (IsOnline) - { - return await apiClient.ExtractPostDetailsAndSave(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) - { - var success = await apiClient.DeletePost(id); - if (success) - { - var post = await localDataService.GetPostAsync(id); - if (post != null) - { - post.is_read = true; // Simulating what API does - post.DateModified = DateTime.UtcNow; - await localDataService.SavePostAsync(post); - } - } - return success; - } - else - { - var post = await localDataService.GetPostAsync(id); - if (post != null) - { - post.is_read = true; - post.DateModified = DateTime.UtcNow; - await localDataService.SavePostAsync(post, isPendingSync: true); - } - return true; - } - } - - 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; - - private async Task MergeLocalNotesIntoRemotePosts(List remotePosts) - { - if (remotePosts == null || !remotePosts.Any()) return; - - var localNotes = await localDataService.GetNotesAsync(); - if (localNotes == null || !localNotes.Any()) return; - - var localNotesByPostId = localNotes - .GroupBy(n => n.PostId!) - .ToDictionary(g => g.Key, g => g.First()); - - foreach (var post in remotePosts) - { - var id = post.Id ?? post.RowKey; - if (localNotesByPostId.TryGetValue(id, out var localNote)) - { - post.Note = localNote.Comment; - post.NoteId = localNote.RowKey; - } - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Maui.Networking; +using NoteBookmark.Domain; +using NoteBookmark.SharedUI; + +namespace NoteBookmark.MauiApp.Data; + +public class OfflineDataService(PostNoteClient apiClient, ILocalDataService localDataService, IConnectivity connectivity, ISyncService syncService, ILocalHtmlStorageService localHtmlStorageService) : IDataService +{ + private bool IsOnline => connectivity.NetworkAccess == NetworkAccess.Internet; + + public async Task> GetUnreadPosts() + { + if (IsOnline) + { + var posts = await apiClient.GetUnreadPosts(); + await MergeLocalNotesIntoRemotePosts(posts); + return posts; + } + else + { + var allPosts = await localDataService.GetPostsAsync(); + var allNotes = await localDataService.GetNotesAsync(); + + return allPosts.Where(p => p.is_read != true).Select(p => { + var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); + return new PostL + { + Id = p.Id, + Title = p.Title, + Date_published = p.Date_published, + Url = p.Url, + Excerpt = p.Excerpt, + is_read = p.is_read, + PartitionKey = p.PartitionKey, + RowKey = p.RowKey, + NoteId = note?.RowKey, + Note = note?.Comment, + DateModified = p.DateModified + }; + }).ToList(); + } + } + + public async Task> GetReadPosts() + { + if (IsOnline) + { + var posts = await apiClient.GetReadPosts(); + await MergeLocalNotesIntoRemotePosts(posts); + return posts; + } + else + { + var allPosts = await localDataService.GetPostsAsync(); + var allNotes = await localDataService.GetNotesAsync(); + + return allPosts.Where(p => p.is_read == true).Select(p => { + var note = allNotes.FirstOrDefault(n => n.PostId == p.RowKey); + return new PostL + { + Id = p.Id, + Title = p.Title, + Date_published = p.Date_published, + Url = p.Url, + Excerpt = p.Excerpt, + is_read = p.is_read, + PartitionKey = p.PartitionKey, + RowKey = p.RowKey, + NoteId = note?.RowKey, + Note = note?.Comment, + DateModified = p.DateModified + }; + }).ToList(); + } + } + + public async Task> GetSummaries() + { + if (IsOnline) + { + var summaries = await apiClient.GetSummaries(); + await localDataService.SaveSummariesAsync(summaries); + return summaries; + } + else + { + return await localDataService.GetSummariesAsync(); + } + } + + public async Task CreateNote(Note note) + { + note.DateModified = DateTime.UtcNow; + if (IsOnline) + { + await apiClient.CreateNote(note); + await localDataService.SaveNoteAsync(note); + var post = await localDataService.GetPostAsync(note.PostId!); + if (post != null) + { + post.is_read = true; + await localDataService.SavePostAsync(post, isPendingSync: false); + } + } + else + { + var settings = await localDataService.GetSettingsAsync(); + note.PartitionKey = settings?.ReadingNotesCounter ?? note.PartitionKey; + note.CreatedOffline = true; + await localDataService.SaveNoteAsync(note, isPendingSync: true); + var post = await localDataService.GetPostAsync(note.PostId!); + if (post != null) + { + post.is_read = true; + post.DateModified = DateTime.UtcNow; + await localDataService.SavePostAsync(post, isPendingSync: true); + } + } + } + + public async Task GetNote(string noteId) + { + if (IsOnline) + { + var note = await apiClient.GetNote(noteId); + if (note != null) await localDataService.SaveNoteAsync(note); + return note; + } + else + { + return await localDataService.GetNoteAsync(noteId); + } + } + + public async Task UpdateNote(Note note) + { + note.DateModified = DateTime.UtcNow; + if (IsOnline) + { + var success = await apiClient.UpdateNote(note); + if (success) await localDataService.SaveNoteAsync(note); + return success; + } + else + { + await localDataService.SaveNoteAsync(note, isPendingSync: true); + return true; + } + } + + public async Task DeleteNote(string noteId) + { + if (IsOnline) + { + var success = await apiClient.DeleteNote(noteId); + if (success) + { + await localDataService.DeleteNoteAsync(noteId); + } + return success; + } + else + { + await localDataService.DeleteNoteAsync(noteId, isPendingSync: true); + return true; + } + } + + 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) + { + try + { + var post = await apiClient.GetPost(id); + if (post != null) await localDataService.SavePostAsync(post); + return post; + } + catch + { + return await localDataService.GetPostAsync(id); + } + } + else + { + return await localDataService.GetPostAsync(id); + } + } + + public async Task SavePost(Post post) + { + post.DateModified = DateTime.UtcNow; + if (IsOnline) + { + var success = await apiClient.SavePost(post); + if (success) await localDataService.SavePostAsync(post); + return success; + } + else + { + await localDataService.SavePostAsync(post, isPendingSync: true); + return true; + } + } + + public async Task GetSettings() + { + if (IsOnline) + { + var settings = await apiClient.GetSettings(); + if (settings != null) await localDataService.SaveSettingsAsync(settings); + return settings; + } + else + { + return await localDataService.GetSettingsAsync(); + } + } + + public async Task SaveSettings(Settings settings) + { + if (IsOnline) + { + var success = await apiClient.SaveSettings(settings); + if (success) await localDataService.SaveSettingsAsync(settings); + return success; + } + else + { + // For now, offline settings saves don't sync. + await localDataService.SaveSettingsAsync(settings); + return true; + } + } + + public async Task ExtractPostDetailsAndSave(string url) + { + if (IsOnline) + { + return await apiClient.ExtractPostDetailsAndSave(url); + } + return null; // 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) + { + var success = await apiClient.DeletePost(id); + if (success) + { + var post = await localDataService.GetPostAsync(id); + if (post != null) + { + post.is_read = true; // Simulating what API does + post.DateModified = DateTime.UtcNow; + await localDataService.SavePostAsync(post); + } + } + return success; + } + else + { + var post = await localDataService.GetPostAsync(id); + if (post != null) + { + post.is_read = true; + post.DateModified = DateTime.UtcNow; + await localDataService.SavePostAsync(post, isPendingSync: true); + } + return true; + } + } + + 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; + + private async Task MergeLocalNotesIntoRemotePosts(List remotePosts) + { + if (remotePosts == null || !remotePosts.Any()) return; + + var localNotes = await localDataService.GetNotesAsync(); + if (localNotes == null || !localNotes.Any()) return; + + var localNotesByPostId = localNotes + .GroupBy(n => n.PostId!) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var post in remotePosts) + { + var id = post.Id ?? post.RowKey; + if (localNotesByPostId.TryGetValue(id, out var localNote)) + { + post.Note = localNote.Comment; + post.NoteId = localNote.RowKey; + } + } + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index bcf310b..69ef426 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -1,337 +1,337 @@ -@page "/posts" -@attribute [Authorize] -@using Microsoft.AspNetCore.Authorization -@using NoteBookmark.Domain -@using Microsoft.FluentUI.AspNetCore.Components -@inject IDataService client -@inject IJSRuntime jsRuntime -@inject IToastService toastService -@inject IDialogService DialogService -@inject NavigationManager Navigation -@inject IUrlLauncher urlLauncher -@implements IDisposable - -Posts - -Posts - - - - - - @if (client.CanSync) - { - Sync - } - - @if (isSyncing || client.IsSyncing || !string.IsNullOrEmpty(syncProgressStatus)) - { - - - @if (!string.IsNullOrEmpty(syncProgressStatus)) - { - @syncProgressStatus - } - - } - - Read Only - UnRead Only - - - - - - - - - @if (String.IsNullOrEmpty(context!.NoteId)) - { - - } - else - { - - } - - - - - - - - - -@if(showPublishedDate) -{ - -} - - - - - - Nothing to see here. Carry on! - - - - - - - -@code { - private IQueryable? posts; - IQueryable? filteredUrlList => posts?.Where(x => x.Title!.Contains(titleFilter, StringComparison.CurrentCultureIgnoreCase)); - private GridSort defSort = GridSort.ByDescending(c => c.Date_published); - private string newPostUrl = string.Empty; - private bool showRead = false; - private PaginationState pagination = new PaginationState { ItemsPerPage = 20 }; - private string titleFilter = string.Empty; - private bool showPublishedDate = false; - private int syncProgressCurrent = 0; - private int syncProgressTotal = 0; - private string syncProgressStatus = string.Empty; - - protected override async Task OnInitializedAsync() - { - client.SyncProgressChanged += OnSyncProgressChanged; - await LoadPosts(); - _ = StartBackgroundSync(); - } - - private async Task LoadPosts() - { - List loadedPosts = showRead ? await client.GetReadPosts(): await client.GetUnreadPosts(); - posts = loadedPosts.AsQueryable(); - } - - private async Task StartBackgroundSync() - { - if (client.IsOffline) return; - - try - { - isSyncing = true; - await client.SyncAsync(); - await LoadPosts(); - } - catch (Exception) - { - // Ignore background sync errors - } - finally - { - isSyncing = false; - StateHasChanged(); - } - } - - private async Task OpenUrlInNewWindow(string? url) - { - await urlLauncher.OpenUrlAsync(url); - } - - private async Task CreateNoteForPost(string postId) - { - var newNote = new Note { PostId = postId }; - - IDialogReference dialog = await DialogService.ShowDialogAsync(newNote, new DialogParameters(){ - Title = "Add a note", - PreventDismissOnOverlayClick = true, - PreventScroll = true, - PrimaryAction = "Save", - SecondaryAction = "Cancel" - }); - - var result = await dialog.Result; - if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) - { - if (dialogResult.Action == "Save" && dialogResult.Note != null) - { - await client.CreateNote(dialogResult.Note); - toastService.ShowSuccess("Note created successfully!"); - await LoadPosts(); - } - } - } - - private void EditNote(string postId) - { - Navigation.NavigateTo($"posteditor/{postId}"); - } - - private async Task EditNoteForPost(string noteId) - { - try - { - var existingNote = await client.GetNote(noteId); - if (existingNote == null) - { - toastService.ShowError("Note not found."); - return; - } - - IDialogReference dialog = await DialogService.ShowDialogAsync(existingNote, new DialogParameters(){ - Title = "Edit note", - PreventDismissOnOverlayClick = true, - PreventScroll = true, - }); - - var result = await dialog.Result; - if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) - { - if (dialogResult.Action == "Delete" && dialogResult.Note != null) - { - var deleteResult = await client.DeleteNote(dialogResult.Note.RowKey); - if (deleteResult) - { - toastService.ShowSuccess("Note deleted successfully!"); - await LoadPosts(); - } - else - { - toastService.ShowError("Failed to delete note. Please try again."); - } - } - else if (dialogResult.Action == "Save" && dialogResult.Note != null) - { - var updateResult = await client.UpdateNote(dialogResult.Note); - if (updateResult) - { - toastService.ShowSuccess("Note updated successfully!"); - await LoadPosts(); - } - else - { - toastService.ShowError("Failed to update note. Please try again."); - } - } - } - } - catch (Exception) - { - toastService.ShowError("An error occurred. Please try again."); - } - } - - private async Task AddNewPost() - { - if (!string.IsNullOrEmpty(newPostUrl)) - { - var result = await client.ExtractPostDetailsAndSave(newPostUrl); - if (result) - { - await LoadPosts(); - newPostUrl = string.Empty; - toastService.ShowSuccess("Post added successfully!"); - } - else - { - toastService.ShowError("Failed to add post. Please try again."); - } - } - } - - private async Task DeletePost(string postId) - { - var result = await client.DeletePost(postId); - if (result) - { - await LoadPosts(); - toastService.ShowSuccess("Post deleted successfully!"); - } - else - { - toastService.ShowError("Failed to delete post. Please try again."); - } - } - - private async Task OnShowReadChanged(bool value) - { - showRead = value; - await LoadPosts(); - } - - private void HandleTitleFilter(ChangeEventArgs args) - { - if (args.Value is string value) - { - titleFilter = value; - } - } - - private void HandleClearTitleFilter() - { - if (string.IsNullOrWhiteSpace(titleFilter)) - { - titleFilter = string.Empty; - } - } - - private bool isSyncing = false; - - private async Task SyncNow() - { - if (isSyncing || client.IsSyncing) return; - isSyncing = true; - try - { - toastService.ShowInfo("Synchronization started..."); - await client.SyncAsync(); - toastService.ShowSuccess("Synchronization completed successfully!"); - await LoadPosts(); - } - catch (Exception ex) - { - toastService.ShowError($"Synchronization failed: {ex.Message}"); - } - finally - { - isSyncing = false; - StateHasChanged(); - } - } - - private void ToggleShowPublishedDate() - { - showPublishedDate = !showPublishedDate; - } - - private void ReadPost(string postId) => Navigation.NavigateTo($"postreader/{postId}"); - - private void OnSyncProgressChanged(object? sender, SyncProgressEventArgs e) - { - 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(); - } - } - }); - } - - public void Dispose() - { - client.SyncProgressChanged -= OnSyncProgressChanged; - } -} +@page "/posts" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@using NoteBookmark.Domain +@using Microsoft.FluentUI.AspNetCore.Components +@inject IDataService client +@inject IJSRuntime jsRuntime +@inject IToastService toastService +@inject IDialogService DialogService +@inject NavigationManager Navigation +@inject IUrlLauncher urlLauncher +@implements IDisposable + +Posts + +Posts + + + + + + @if (client.CanSync) + { + Sync + } + + @if (isSyncing || client.IsSyncing || !string.IsNullOrEmpty(syncProgressStatus)) + { + + + @if (!string.IsNullOrEmpty(syncProgressStatus)) + { + @syncProgressStatus + } + + } + + Read Only + UnRead Only + + + + + + + + + @if (String.IsNullOrEmpty(context!.NoteId)) + { + + } + else + { + + } + + + + + + + + + +@if(showPublishedDate) +{ + +} + + + + + + Nothing to see here. Carry on! + + + + + + + +@code { + private IQueryable? posts; + IQueryable? filteredUrlList => posts?.Where(x => x.Title!.Contains(titleFilter, StringComparison.CurrentCultureIgnoreCase)); + private GridSort defSort = GridSort.ByDescending(c => c.Date_published); + private string newPostUrl = string.Empty; + private bool showRead = false; + private PaginationState pagination = new PaginationState { ItemsPerPage = 20 }; + private string titleFilter = string.Empty; + private bool showPublishedDate = false; + private int syncProgressCurrent = 0; + private int syncProgressTotal = 0; + private string syncProgressStatus = string.Empty; + + protected override async Task OnInitializedAsync() + { + client.SyncProgressChanged += OnSyncProgressChanged; + await LoadPosts(); + _ = StartBackgroundSync(); + } + + private async Task LoadPosts() + { + List loadedPosts = showRead ? await client.GetReadPosts(): await client.GetUnreadPosts(); + posts = loadedPosts.AsQueryable(); + } + + private async Task StartBackgroundSync() + { + if (client.IsOffline) return; + + try + { + isSyncing = true; + await client.SyncAsync(); + await LoadPosts(); + } + catch (Exception) + { + // Ignore background sync errors + } + finally + { + isSyncing = false; + StateHasChanged(); + } + } + + private async Task OpenUrlInNewWindow(string? url) + { + await urlLauncher.OpenUrlAsync(url); + } + + private async Task CreateNoteForPost(string postId) + { + var newNote = new Note { PostId = postId }; + + IDialogReference dialog = await DialogService.ShowDialogAsync(newNote, new DialogParameters(){ + Title = "Add a note", + PreventDismissOnOverlayClick = true, + PreventScroll = true, + PrimaryAction = "Save", + SecondaryAction = "Cancel" + }); + + var result = await dialog.Result; + if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) + { + if (dialogResult.Action == "Save" && dialogResult.Note != null) + { + await client.CreateNote(dialogResult.Note); + toastService.ShowSuccess("Note created successfully!"); + await LoadPosts(); + } + } + } + + private void EditNote(string postId) + { + Navigation.NavigateTo($"posteditor/{postId}"); + } + + private async Task EditNoteForPost(string noteId) + { + try + { + var existingNote = await client.GetNote(noteId); + if (existingNote == null) + { + toastService.ShowError("Note not found."); + return; + } + + IDialogReference dialog = await DialogService.ShowDialogAsync(existingNote, new DialogParameters(){ + Title = "Edit note", + PreventDismissOnOverlayClick = true, + PreventScroll = true, + }); + + var result = await dialog.Result; + if (!result.Cancelled && result.Data is NoteDialogResult dialogResult) + { + if (dialogResult.Action == "Delete" && dialogResult.Note != null) + { + var deleteResult = await client.DeleteNote(dialogResult.Note.RowKey); + if (deleteResult) + { + toastService.ShowSuccess("Note deleted successfully!"); + await LoadPosts(); + } + else + { + toastService.ShowError("Failed to delete note. Please try again."); + } + } + else if (dialogResult.Action == "Save" && dialogResult.Note != null) + { + var updateResult = await client.UpdateNote(dialogResult.Note); + if (updateResult) + { + toastService.ShowSuccess("Note updated successfully!"); + await LoadPosts(); + } + else + { + toastService.ShowError("Failed to update note. Please try again."); + } + } + } + } + catch (Exception) + { + toastService.ShowError("An error occurred. Please try again."); + } + } + + private async Task AddNewPost() + { + if (!string.IsNullOrEmpty(newPostUrl)) + { + var result = await client.ExtractPostDetailsAndSave(newPostUrl); + if (result != null) + { + await LoadPosts(); + newPostUrl = string.Empty; + toastService.ShowSuccess("Post added successfully!"); + } + else + { + toastService.ShowError("Failed to add post. Please try again."); + } + } + } + + private async Task DeletePost(string postId) + { + var result = await client.DeletePost(postId); + if (result) + { + await LoadPosts(); + toastService.ShowSuccess("Post deleted successfully!"); + } + else + { + toastService.ShowError("Failed to delete post. Please try again."); + } + } + + private async Task OnShowReadChanged(bool value) + { + showRead = value; + await LoadPosts(); + } + + private void HandleTitleFilter(ChangeEventArgs args) + { + if (args.Value is string value) + { + titleFilter = value; + } + } + + private void HandleClearTitleFilter() + { + if (string.IsNullOrWhiteSpace(titleFilter)) + { + titleFilter = string.Empty; + } + } + + private bool isSyncing = false; + + private async Task SyncNow() + { + if (isSyncing || client.IsSyncing) return; + isSyncing = true; + try + { + toastService.ShowInfo("Synchronization started..."); + await client.SyncAsync(); + toastService.ShowSuccess("Synchronization completed successfully!"); + await LoadPosts(); + } + catch (Exception ex) + { + toastService.ShowError($"Synchronization failed: {ex.Message}"); + } + finally + { + isSyncing = false; + StateHasChanged(); + } + } + + private void ToggleShowPublishedDate() + { + showPublishedDate = !showPublishedDate; + } + + private void ReadPost(string postId) => Navigation.NavigateTo($"postreader/{postId}"); + + private void OnSyncProgressChanged(object? sender, SyncProgressEventArgs e) + { + 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(); + } + } + }); + } + + public void Dispose() + { + client.SyncProgressChanged -= OnSyncProgressChanged; + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor b/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor new file mode 100644 index 0000000..7d836ad --- /dev/null +++ b/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor @@ -0,0 +1,162 @@ +@page "/quickadd" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@using NoteBookmark.Domain +@inject IDataService client +@inject NavigationManager Navigation +@inject IJSRuntime JsRuntime +@layout MinimalLayout + +Quick Add - NoteBookmark + + + + Quick Add Post + + @if (isLoading) + { + + + Extracting and saving post details... + + } + else if (isSuccess && post != null) + { + + Post saved successfully! + + + + + + Title + @(string.IsNullOrWhiteSpace(post.Title) ? "Untitled" : post.Title) + + + + Author + @(string.IsNullOrWhiteSpace(post.Author) ? "Unknown" : post.Author) + + + + URL + + @post.Url + + + + + + + Close Window + Edit + + } + else + { + @if (!string.IsNullOrEmpty(errorMessage)) + { + + @errorMessage + + } + + + + + Close Window + Add + + + } + + + +@code { + [SupplyParameterFromQuery(Name = "url")] + public string? Url { get; set; } + + [SupplyParameterFromQuery(Name = "addUrl")] + public string? AddUrl { get; set; } + + private Post? post; + private bool isLoading = false; + private bool isSuccess = false; + private string? errorMessage; + private string? manualUrl; + private string? _processedUrl; + + protected override async Task OnParametersSetAsync() + { + var targetUrl = !string.IsNullOrWhiteSpace(Url) ? Url : AddUrl; + if (!string.IsNullOrWhiteSpace(targetUrl) && targetUrl != _processedUrl) + { + _processedUrl = targetUrl; + await ProcessUrlAsync(targetUrl); + } + } + + private async Task ProcessUrlAsync(string targetUrl) + { + if (client.IsOffline) + { + errorMessage = "Cannot extract posts while offline."; + isLoading = false; + return; + } + + isLoading = true; + errorMessage = null; + isSuccess = false; + + try + { + post = await client.ExtractPostDetailsAndSave(targetUrl); + if (post != null) + { + isSuccess = true; + } + else + { + errorMessage = "Failed to extract and save post details. Please check the URL and try again."; + } + } + catch (Exception ex) + { + errorMessage = $"An error occurred: {ex.Message}"; + } + finally + { + isLoading = false; + } + } + + private async Task ProcessManualUrl() + { + if (!string.IsNullOrWhiteSpace(manualUrl)) + { + await ProcessUrlAsync(manualUrl); + } + } + + private async Task CloseWindow() + { + try + { + await JsRuntime.InvokeVoidAsync("window.close"); + } + catch + { + // Prerender or context without window.close support + } + } + + private void NavigateToEdit() + { + var rowKey = post?.RowKey ?? post?.Id; + if (!string.IsNullOrEmpty(rowKey)) + { + Navigation.NavigateTo($"posteditorlight/{rowKey}"); + } + } +} diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor b/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor index 5b65bab..e319833 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor @@ -101,7 +101,29 @@ Save - + + + + + Browser Bookmarklet + + Drag the button below to your browser's bookmarks bar to quickly save articles from any webpage directly into NoteBookmark: + + + + + + + Add to NoteBookmark + + + + Tip: If dragging is not supported, right-click the button, copy the link, and paste it as the URL of a new browser bookmark. + + } @@ -154,4 +176,7 @@ var cnt = Convert.ToInt32(settings!.ReadingNotesCounter) + 1; settings.ReadingNotesCounter = (cnt).ToString(); } + + private string BookmarkletCode => + $"javascript:(function(){{var u=encodeURIComponent(window.location.href);window.open('{Navigation.BaseUri}quickadd?url='+u,'nb_quickadd','width=480,height=440,menubar=no,toolbar=no');}})();"; } diff --git a/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor b/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor index 6587d7d..ad47fc3 100644 --- a/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor +++ b/src/NoteBookmark.SharedUI/Components/Shared/SuggestionList.razor @@ -50,7 +50,7 @@ if (postURL != null) { var result = await client.ExtractPostDetailsAndSave(postURL); - if (result) + if (result != null) { Suggestions!.Remove(Suggestions.First(x => x.Url == postURL)); StateHasChanged(); diff --git a/src/NoteBookmark.SharedUI/IDataService.cs b/src/NoteBookmark.SharedUI/IDataService.cs index b5a0887..f95c1b3 100644 --- a/src/NoteBookmark.SharedUI/IDataService.cs +++ b/src/NoteBookmark.SharedUI/IDataService.cs @@ -1,32 +1,32 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using NoteBookmark.Domain; - -namespace NoteBookmark.SharedUI; - -public interface IDataService -{ - Task> GetUnreadPosts(); - Task> GetReadPosts(); - Task> GetSummaries(); - Task CreateNote(Note note); - Task GetNote(string noteId); - Task UpdateNote(Note note); - Task DeleteNote(string noteId); - Task CreateReadingNotes(); - Task GetReadingNotes(string number); - Task SaveReadingNotes(ReadingNotes readingNotes); - Task GetPost(string id); - Task SavePost(Post post); - Task GetSettings(); - Task SaveSettings(Settings settings); - Task ExtractPostDetailsAndSave(string url); - Task DeletePost(string id); - Task SaveReadingNotesMarkdown(string markdown, string number); - Task GetPostHtmlAsync(string postId); - Task SyncAsync(); - event System.EventHandler? SyncProgressChanged; - bool IsSyncing { get; } - bool IsOffline { get; } - bool CanSync { get; } -} +using System.Collections.Generic; +using System.Threading.Tasks; +using NoteBookmark.Domain; + +namespace NoteBookmark.SharedUI; + +public interface IDataService +{ + Task> GetUnreadPosts(); + Task> GetReadPosts(); + Task> GetSummaries(); + Task CreateNote(Note note); + Task GetNote(string noteId); + Task UpdateNote(Note note); + Task DeleteNote(string noteId); + Task CreateReadingNotes(); + Task GetReadingNotes(string number); + Task SaveReadingNotes(ReadingNotes readingNotes); + Task GetPost(string id); + Task SavePost(Post post); + Task GetSettings(); + Task SaveSettings(Settings settings); + Task ExtractPostDetailsAndSave(string url); + Task DeletePost(string id); + Task SaveReadingNotesMarkdown(string markdown, string number); + 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 b0245a8..8b8acd2 100644 --- a/src/NoteBookmark.SharedUI/PostNoteClient.cs +++ b/src/NoteBookmark.SharedUI/PostNoteClient.cs @@ -158,11 +158,15 @@ public async Task SaveSettings(Settings settings) return response.IsSuccessStatusCode; } - public async Task ExtractPostDetailsAndSave(string url) + public async Task ExtractPostDetailsAndSave(string url) { var requestBody = new { url = url }; var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails", requestBody); - return response.IsSuccessStatusCode; + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(); + } + return null; } public async Task DeletePost(string id)