diff --git a/Directory.Build.props b/Directory.Build.props index a218b45..328c501 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.3.2 + 1.4.0 net10.0 enable enable diff --git a/src/NoteBookmark.AIServices/ResearchService.cs b/src/NoteBookmark.AIServices/ResearchService.cs index 69ac93a..3b4d750 100644 --- a/src/NoteBookmark.AIServices/ResearchService.cs +++ b/src/NoteBookmark.AIServices/ResearchService.cs @@ -151,7 +151,9 @@ private async Task SaveToFile(string prefix, string responseContent) { string datetime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm"); string fileName = $"{prefix}_{datetime}.json"; - string folderPath = "Data"; + // Use the app's sandboxed data directory so this works on Android/iOS as well as desktop + string folderPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Data"); Directory.CreateDirectory(folderPath); string filePath = Path.Combine(folderPath, fileName); await File.WriteAllTextAsync(filePath, responseContent); diff --git a/src/NoteBookmark.Api.Tests/Endpoints/PostExtractionTests.cs b/src/NoteBookmark.Api.Tests/Endpoints/PostExtractionTests.cs new file mode 100644 index 0000000..4fb5e35 --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Endpoints/PostExtractionTests.cs @@ -0,0 +1,74 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NoteBookmark.Api.Tests.Fixtures; +using NoteBookmark.Domain; +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Xunit; +using Azure.Storage.Blobs; + +namespace NoteBookmark.Api.Tests.Endpoints; + +public class PostExtractionTests : IClassFixture +{ + private readonly NoteBookmarkApiTestFactory _factory; + private readonly HttpClient _client; + + public PostExtractionTests(NoteBookmarkApiTestFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + [Fact] + public async Task ExtractPostDetails_TriggersBackgroundWorkerAndSavesHtmlToBlobStorage() + { + // Arrange + var url = "https://example.com/blog/test-post-" + Guid.NewGuid(); + var extractRequest = new + { + url = url, + tags = "test", + category = "Test" + }; + + // Act - Call the API to extract metadata and save the post + var response = await _client.PostAsJsonAsync("/api/posts/extractPostDetails", extractRequest); + + // Assert API response is OK + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var post = await response.Content.ReadFromJsonAsync(); + post.Should().NotBeNull(); + var postId = post!.Id ?? post.RowKey; + postId.Should().NotBeNullOrEmpty(); + + // Since the extraction happens asynchronously in a BackgroundWorker, + // we poll the blob storage for a short time to verify the file was created. + var blobServiceClient = _factory.Services.GetRequiredService(); + var containerClient = blobServiceClient.GetBlobContainerClient("cleanedposts"); + var blobClient = containerClient.GetBlobClient($"{postId}.html"); + + // Wait up to 5 seconds for the background worker to process + bool blobExists = false; + for (int i = 0; i < 25; i++) + { + if (await blobClient.ExistsAsync()) + { + blobExists = true; + break; + } + await Task.Delay(200); + } + + blobExists.Should().BeTrue("HTML content should be processed by the background worker and saved to blob storage"); + + // Verify the content saved matches the fake content + var downloadResult = await blobClient.DownloadContentAsync(); + var content = downloadResult.Value.Content.ToString(); + content.Should().Contain(url); + } +} diff --git a/src/NoteBookmark.Api.Tests/Fixtures/FakePostParserClient.cs b/src/NoteBookmark.Api.Tests/Fixtures/FakePostParserClient.cs new file mode 100644 index 0000000..5dc2e2e --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Fixtures/FakePostParserClient.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace NoteBookmark.Api.Tests.Fixtures; + +public class FakePostParserClient : IPostParserClient +{ + public Task ExtractContentAsync(string url, CancellationToken cancellationToken = default) + { + // Return a mock HTML snippet for testing + return Task.FromResult($"
Extracted HTML content for {url}
"); + } +} diff --git a/src/NoteBookmark.Api.Tests/Fixtures/NoteBookmarkApiTestFactory.cs b/src/NoteBookmark.Api.Tests/Fixtures/NoteBookmarkApiTestFactory.cs index da53256..cd0686c 100644 --- a/src/NoteBookmark.Api.Tests/Fixtures/NoteBookmarkApiTestFactory.cs +++ b/src/NoteBookmark.Api.Tests/Fixtures/NoteBookmarkApiTestFactory.cs @@ -34,6 +34,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(new TableServiceClient(connectionString)); services.AddSingleton(new BlobServiceClient(connectionString)); } + + // Register FakePostParserClient for integration tests + services.AddSingleton(); }); } diff --git a/src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs b/src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs new file mode 100644 index 0000000..dea9f8a --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq.Protected; + +namespace NoteBookmark.Api.Tests.Services; + +public class PostParserClientTests +{ + private readonly Mock _mockConfig; + private readonly Mock _mockHandler; + + public PostParserClientTests() + { + _mockConfig = new Mock(); + _mockHandler = new Mock(MockBehavior.Strict); + + // Default config setups + _mockConfig.Setup(c => c["Parser:BaseUrl"]).Returns((string?)null); + _mockConfig.Setup(c => c["Parser:ApiKey"]).Returns((string?)null); + } + + private PostParserClient CreateSut(HttpClient httpClient) => + new(httpClient, _mockConfig.Object, NullLogger.Instance); + + [Fact] + public async Task ExtractContentAsync_WithDefaults_CallsDefaultUrlWithoutApiKey() + { + // Arrange + var expectedUrl = "https://azpostlight-parser.azurewebsites.net/api/parser"; + var sourceUrl = "https://example.com/blog-post"; + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString() == expectedUrl && + !req.Headers.Contains("x-functions-key")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"content\":\"extracted blog content\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().Be("extracted blog content"); + _mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => req.RequestUri != null && req.RequestUri.ToString() == expectedUrl), + ItExpr.IsAny()); + } + + [Fact] + public async Task ExtractContentAsync_WithApiKey_SendsXFunctionsKeyHeader() + { + // Arrange + var expectedUrl = "https://azpostlight-parser.azurewebsites.net/api/parser"; + var sourceUrl = "https://example.com/blog-post"; + var apiKey = "test-api-key-123"; + + _mockConfig.Setup(c => c["Parser:ApiKey"]).Returns(apiKey); + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString() == expectedUrl && + req.Headers.Contains("x-functions-key") && + string.Join("", req.Headers.GetValues("x-functions-key")) == apiKey), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"content\":\"content with auth\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().Be("content with auth"); + } + + [Fact] + public async Task ExtractContentAsync_WithCustomUrl_CallsCustomUrl() + { + // Arrange + var customUrl = "https://my-custom-parser.com/api/parser"; + var sourceUrl = "https://example.com/blog-post"; + + _mockConfig.Setup(c => c["Parser:BaseUrl"]).Returns(customUrl); + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString() == customUrl), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"content\":\"custom url content\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().Be("custom url content"); + } + + [Fact] + public async Task ExtractContentAsync_ParserReturnsErrorCode_ReturnsNull() + { + // Arrange + var sourceUrl = "https://example.com/blog-post"; + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.InternalServerError + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().BeNull(); + } +} diff --git a/src/NoteBookmark.Api/IPostParserClient.cs b/src/NoteBookmark.Api/IPostParserClient.cs new file mode 100644 index 0000000..72507ab --- /dev/null +++ b/src/NoteBookmark.Api/IPostParserClient.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace NoteBookmark.Api; + +public interface IPostParserClient +{ + Task ExtractContentAsync(string url, CancellationToken cancellationToken = default); +} diff --git a/src/NoteBookmark.Api/PostEndpoints.cs b/src/NoteBookmark.Api/PostEndpoints.cs index 87c1bb2..66a8a7e 100644 --- a/src/NoteBookmark.Api/PostEndpoints.cs +++ b/src/NoteBookmark.Api/PostEndpoints.cs @@ -94,7 +94,11 @@ static Results SavePost(Post post, TableServiceClient tblClient, } return TypedResults.BadRequest(); } - static async Task, BadRequest>> ExtractPostDetails(ExtractPostRequest request, TableServiceClient tblClient, BlobServiceClient blobClient) + static async Task, BadRequest>> ExtractPostDetails( + ExtractPostRequest request, + TableServiceClient tblClient, + BlobServiceClient blobClient, + PostExtractionQueue queue) { var dataStorageService = new DataStorageService(tblClient, blobClient); @@ -105,6 +109,10 @@ static async Task, BadRequest>> ExtractPostDetails(ExtractPostR if (post != null) { dataStorageService.SavePost(post); + + // Queue background HTML extraction task + queue.QueueBackgroundWorkItem(new ExtractionTask(post.Id ?? post.RowKey, post.Url ?? decodeUrl)); + return TypedResults.Ok(post); } return TypedResults.BadRequest(); diff --git a/src/NoteBookmark.Api/PostExtractionBackgroundWorker.cs b/src/NoteBookmark.Api/PostExtractionBackgroundWorker.cs new file mode 100644 index 0000000..801f507 --- /dev/null +++ b/src/NoteBookmark.Api/PostExtractionBackgroundWorker.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.Storage.Blobs; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace NoteBookmark.Api; + +public class PostExtractionBackgroundWorker : BackgroundService +{ + private readonly PostExtractionQueue _queue; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public PostExtractionBackgroundWorker( + PostExtractionQueue queue, + IServiceProvider serviceProvider, + ILogger logger) + { + _queue = queue; + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Post Extraction Background Worker started."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var task = await _queue.DequeueAsync(stoppingToken); + _logger.LogInformation("Processing extraction for Post: {PostId}, URL: {Url}", task.PostId, task.Url); + + await ProcessExtractionAsync(task, stoppingToken); + } + catch (OperationCanceledException) + { + // Normal shutdown + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred executing background extraction task."); + } + } + + _logger.LogInformation("Post Extraction Background Worker stopped."); + } + + private async Task ProcessExtractionAsync(ExtractionTask task, CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var parserClient = scope.ServiceProvider.GetRequiredService(); + var blobServiceClient = scope.ServiceProvider.GetRequiredService(); + + try + { + var content = await parserClient.ExtractContentAsync(task.Url, cancellationToken); + if (string.IsNullOrEmpty(content)) + { + _logger.LogWarning("No content returned for URL: {Url}. Skipping blob upload.", task.Url); + return; + } + + var containerClient = blobServiceClient.GetBlobContainerClient("cleanedposts"); + await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + + var blobClient = containerClient.GetBlobClient($"{task.PostId}.html"); + + byte[] contentBytes = Encoding.UTF8.GetBytes(content); + using var stream = new MemoryStream(contentBytes); + + await blobClient.UploadAsync(stream, overwrite: true, cancellationToken: cancellationToken); + _logger.LogInformation("Successfully saved extracted HTML for Post {PostId} to Blob Storage.", task.PostId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process extraction for Post {PostId} / URL: {Url}", task.PostId, task.Url); + } + } +} diff --git a/src/NoteBookmark.Api/PostExtractionQueue.cs b/src/NoteBookmark.Api/PostExtractionQueue.cs new file mode 100644 index 0000000..b1698a5 --- /dev/null +++ b/src/NoteBookmark.Api/PostExtractionQueue.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace NoteBookmark.Api; + +public record ExtractionTask(string PostId, string Url); + +public class PostExtractionQueue +{ + private readonly Channel _queue; + + public PostExtractionQueue() + { + // Unbounded channel is simple and suitable for this task queue. + _queue = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + } + + public void QueueBackgroundWorkItem(ExtractionTask task) + { + ArgumentNullException.ThrowIfNull(task); + _queue.Writer.TryWrite(task); + } + + public async ValueTask DequeueAsync(CancellationToken cancellationToken) + { + return await _queue.Reader.ReadAsync(cancellationToken); + } +} diff --git a/src/NoteBookmark.Api/PostParserClient.cs b/src/NoteBookmark.Api/PostParserClient.cs new file mode 100644 index 0000000..91f1507 --- /dev/null +++ b/src/NoteBookmark.Api/PostParserClient.cs @@ -0,0 +1,66 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace NoteBookmark.Api; + +public class PostParserClient : IPostParserClient +{ + private readonly HttpClient _httpClient; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public PostParserClient(HttpClient httpClient, IConfiguration config, ILogger logger) + { + _httpClient = httpClient; + _config = config; + _logger = logger; + } + + public async Task ExtractContentAsync(string url, CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("Calling parser API for URL: {Url}", url); + var requestBody = new { url = url }; + + var endpoint = _config["Parser:BaseUrl"] ?? "https://azpostlight-parser.azurewebsites.net/api/parser"; + var apiKey = _config["Parser:ApiKey"]; + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Content = JsonContent.Create(requestBody); + + if (!string.IsNullOrEmpty(apiKey)) + { + request.Headers.Add("x-functions-key", apiKey); + } + + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Parser API returned error status: {StatusCode}", response.StatusCode); + return null; + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return result?.Content; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to extract content for URL: {Url}", url); + return null; + } + } + + private class ParserResponse + { + [JsonPropertyName("content")] + public string? Content { get; set; } + } +} diff --git a/src/NoteBookmark.Api/Program.cs b/src/NoteBookmark.Api/Program.cs index 6def1b2..55fd0f6 100644 --- a/src/NoteBookmark.Api/Program.cs +++ b/src/NoteBookmark.Api/Program.cs @@ -15,6 +15,11 @@ // Register data storage service builder.Services.AddScoped(); +// Register background extraction queue and worker +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + // Register AI settings provider builder.Services.AddScoped(); diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index 531e157..024f1f8 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -5,6 +5,12 @@ var builder = DistributedApplication.CreateBuilder(args); +var parserUrl = builder.Configuration["Parser:BaseUrl"] + ?? Environment.GetEnvironmentVariable("PARSER_BASE_URL") + ?? "https://azpostlight-parser.azurewebsites.net/api/parser"; +var parserKey = builder.Configuration["Parser:ApiKey"] + ?? Environment.GetEnvironmentVariable("PARSER_API_KEY"); + // Load docker-compose environment var compose = builder.AddDockerComposeEnvironment("docker-env"); @@ -24,12 +30,19 @@ var tables = noteStorage.AddTables("nb-tables"); var blobs = noteStorage.AddBlobs("nb-blobs"); - var api = builder.AddProject("api") + var apiBuilder = builder.AddProject("api") .WithReference(tables) .WithReference(blobs) .WaitFor(tables) .WaitFor(blobs) - .PublishAsDockerComposeService((resource, service) => + .WithEnvironment("Parser__BaseUrl", parserUrl); + + if (!string.IsNullOrEmpty(parserKey)) + { + apiBuilder = apiBuilder.WithEnvironment("Parser__ApiKey", parserKey); + } + + var api = apiBuilder.PublishAsDockerComposeService((resource, service) => { service.ContainerName = "notebookmark-api"; }); @@ -58,12 +71,19 @@ var tables = noteStorage.AddTables("nb-tables"); var blobs = noteStorage.AddBlobs("nb-blobs"); - var api = builder.AddProject("api") + var apiBuilder = builder.AddProject("api") .WithReference(tables) .WithReference(blobs) .WaitFor(tables) .WaitFor(blobs) - .PublishAsDockerComposeService((resource, service) => + .WithEnvironment("Parser__BaseUrl", parserUrl); + + if (!string.IsNullOrEmpty(parserKey)) + { + apiBuilder = apiBuilder.WithEnvironment("Parser__ApiKey", parserKey); + } + + var api = apiBuilder.PublishAsDockerComposeService((resource, service) => { service.ContainerName = "notebookmark-api"; }); diff --git a/src/NoteBookmark.AppHost/appsettings.json b/src/NoteBookmark.AppHost/appsettings.json index 219b4a5..b3b4cb6 100644 --- a/src/NoteBookmark.AppHost/appsettings.json +++ b/src/NoteBookmark.AppHost/appsettings.json @@ -9,6 +9,10 @@ "AppSettings": { "REKA_API_KEY": "KEY_HERE" }, + "Parser": { + "BaseUrl": "https://azpostlight-parser.azurewebsites.net/api/parser", + "ApiKey": "KEY_HERE" + }, "Keycloak": { "Authority": "http://localhost:8080/realms/notebookmark", "ClientId": "notebookmark", diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs index 67605b3..70ee8ea 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs @@ -39,7 +39,6 @@ public PostsTests() Services.AddSingleton(_dataServiceMock.Object); Services.AddSingleton(new Mock().Object); Services.AddSingleton(new Mock().Object); - Services.AddSingleton(new NoteBookmark.BlazorApp.AlwaysAvailableHtmlCache()); } [Fact] @@ -126,36 +125,12 @@ public void Posts_RendersEmptyState_WhenNoPostsReturned() cut.Markup.Should().Contain("Nothing to see here"); } -} -public sealed class PostsHtmlCacheTests : BunitContext -{ [Fact] - public void Posts_ChecksHtmlCacheWithPostId_WhenIdIsPresent() + public void Posts_ReadPostButton_IsAlwaysRendered() { - this.AddFluentUI(); - this.AddAuthorization().SetAuthorized("testuser"); - - var dataServiceMock = new Mock(); - dataServiceMock.Setup(s => s.GetUnreadPosts()).ReturnsAsync([ - new PostL { PartitionKey = "p", RowKey = "row-key-456", Id = "custom-id-123", Title = "Post With Id", Url = "https://example.com/id", Date_published = "2025-01-15T00:00:00", is_read = false } - ]); - 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); - - var htmlCacheMock = new Mock(); - htmlCacheMock.Setup(c => c.IsHtmlCached("custom-id-123")).Returns(true); - - Services.AddSingleton(dataServiceMock.Object); - Services.AddSingleton(new Mock().Object); - Services.AddSingleton(new Mock().Object); - Services.AddSingleton(htmlCacheMock.Object); - var cut = Render(); - htmlCacheMock.Verify(c => c.IsHtmlCached("custom-id-123"), Times.Once); cut.Markup.Should().Contain("Read post"); } } diff --git a/src/NoteBookmark.BlazorApp/AlwaysAvailableHtmlCache.cs b/src/NoteBookmark.BlazorApp/AlwaysAvailableHtmlCache.cs deleted file mode 100644 index 2bed86b..0000000 --- a/src/NoteBookmark.BlazorApp/AlwaysAvailableHtmlCache.cs +++ /dev/null @@ -1,8 +0,0 @@ -using NoteBookmark.SharedUI; - -namespace NoteBookmark.BlazorApp; - -public class AlwaysAvailableHtmlCache : ILocalHtmlCache -{ - public bool IsHtmlCached(string postId) => true; -} diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index 7699cbb..06f72ef 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -18,7 +18,6 @@ client.BaseAddress = new Uri("https+http://api"); }); builder.Services.AddTransient(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); builder.Services.AddScoped(); // Register server-side AI settings provider (direct database access, unmasked) diff --git a/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs b/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs index 30e7d53..7f4b08c 100644 --- a/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs +++ b/src/NoteBookmark.MauiApp/Data/LocalHtmlStorageService.cs @@ -2,16 +2,18 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using NoteBookmark.SharedUI; namespace NoteBookmark.MauiApp.Data; -public class LocalHtmlStorageService(string baseDirectory) : ILocalHtmlStorageService, ILocalHtmlCache +public class LocalHtmlStorageService(string baseDirectory) : ILocalHtmlStorageService { private string FilePath(string postId) => Path.Combine(baseDirectory, $"{postId}.html"); public async Task SavePostHtmlAsync(string postId, string html) - => await File.WriteAllTextAsync(FilePath(postId), html); + { + Directory.CreateDirectory(baseDirectory); + await File.WriteAllTextAsync(FilePath(postId), html); + } public async Task GetPostHtmlAsync(string postId) { @@ -22,8 +24,6 @@ public async Task SavePostHtmlAsync(string postId, string html) public bool IsPostHtmlCached(string postId) => File.Exists(FilePath(postId)); - public bool IsHtmlCached(string postId) => IsPostHtmlCached(postId); - public void RemovePostHtml(string postId) { var path = FilePath(postId); diff --git a/src/NoteBookmark.MauiApp/MauiProgram.cs b/src/NoteBookmark.MauiApp/MauiProgram.cs index 3c74ed6..d458a55 100644 --- a/src/NoteBookmark.MauiApp/MauiProgram.cs +++ b/src/NoteBookmark.MauiApp/MauiProgram.cs @@ -39,8 +39,6 @@ public static MauiHostingApp CreateMauiApp() sp => new NoteBookmark.MauiApp.Data.LocalHtmlStorageService(FileSystem.AppDataDirectory)); builder.Services.AddSingleton( sp => sp.GetRequiredService()); - builder.Services.AddSingleton( - sp => sp.GetRequiredService()); builder.Services.AddTransient(); builder.Services.AddHttpClient(client => diff --git a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj index 4e2da8e..fce28f8 100644 --- a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj +++ b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj @@ -97,8 +97,14 @@ + + + + None + +