Compare commits

..

1 Commits

Author SHA1 Message Date
gamer147
f88f340d0a [FA-misc] Mass transit overhaul, needs testing and review 2026-01-21 23:16:31 -05:00
102 changed files with 1190 additions and 1587 deletions

View File

@@ -1,7 +1,6 @@
using FictionArchive.Service.AuthenticationService.Models.Requests; using FictionArchive.Service.AuthenticationService.Models.Requests;
using FictionArchive.Service.AuthenticationService.Models.IntegrationEvents; using FictionArchive.Service.Shared.MassTransit.Contracts.Events;
using FictionArchive.Service.Shared.Services.EventBus; using MassTransit;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace FictionArchive.Service.AuthenticationService.Controllers namespace FictionArchive.Service.AuthenticationService.Controllers
@@ -10,11 +9,11 @@ namespace FictionArchive.Service.AuthenticationService.Controllers
[ApiController] [ApiController]
public class AuthenticationWebhookController : ControllerBase public class AuthenticationWebhookController : ControllerBase
{ {
private readonly IEventBus _eventBus; private readonly IPublishEndpoint _publishEndpoint;
public AuthenticationWebhookController(IEventBus eventBus) public AuthenticationWebhookController(IPublishEndpoint publishEndpoint)
{ {
_eventBus = eventBus; _publishEndpoint = publishEndpoint;
} }
[HttpPost(nameof(UserRegistered))] [HttpPost(nameof(UserRegistered))]
@@ -28,7 +27,7 @@ namespace FictionArchive.Service.AuthenticationService.Controllers
EventUserUsername = payload.EventUserUsername EventUserUsername = payload.EventUserUsername
}; };
await _eventBus.Publish(authUserAddedEvent); await _publishEndpoint.Publish(authUserAddedEvent);
return Ok(); return Ok();
} }

View File

@@ -1,16 +0,0 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.AuthenticationService.Models.IntegrationEvents;
public class AuthUserAddedEvent : IIntegrationEvent
{
public string OAuthProviderId { get; set; }
public string InviterOAuthProviderId { get; set; }
// The email of the user that created the event
public string EventUserEmail { get; set; }
// The username of the user that created the event
public string EventUserUsername { get; set; }
}

View File

@@ -1,5 +1,4 @@
using FictionArchive.Service.Shared; using FictionArchive.Service.Shared.MassTransit;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
namespace FictionArchive.Service.AuthenticationService; namespace FictionArchive.Service.AuthenticationService;
@@ -16,12 +15,9 @@ public class Program
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
#region Event Bus #region MassTransit
builder.Services.AddRabbitMQ(opt => builder.Services.AddFictionArchiveMassTransit(builder.Configuration);
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
});
#endregion #endregion

View File

@@ -6,8 +6,10 @@
} }
}, },
"RabbitMQ": { "RabbitMQ": {
"ConnectionString": "amqp://localhost", "Host": "localhost",
"ClientIdentifier": "AuthenticationService" "VirtualHost": "/",
"Username": "guest",
"Password": "guest"
}, },
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@@ -1,10 +0,0 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.FileService.Models.IntegrationEvents;
public class FileUploadRequestCreatedEvent : IIntegrationEvent
{
public Guid RequestId { get; set; }
public string FilePath { get; set; }
public byte[] FileData { get; set; }
}

View File

@@ -1,22 +0,0 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.FileService.Models.IntegrationEvents;
public class FileUploadRequestStatusUpdateEvent : IIntegrationEvent
{
public Guid RequestId { get; set; }
public RequestStatus Status { get; set; }
#region Success
public string? FileAccessUrl { get; set; }
#endregion
#region Failure
public string? ErrorMessage { get; set; }
#endregion
}

View File

@@ -2,10 +2,9 @@ using Amazon.Runtime;
using Amazon.S3; using Amazon.S3;
using FictionArchive.Common.Extensions; using FictionArchive.Common.Extensions;
using FictionArchive.Service.FileService.Models; using FictionArchive.Service.FileService.Models;
using FictionArchive.Service.FileService.Models.IntegrationEvents;
using FictionArchive.Service.FileService.Services.EventHandlers; using FictionArchive.Service.FileService.Services.EventHandlers;
using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.Shared.MassTransit;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace FictionArchive.Service.FileService; namespace FictionArchive.Service.FileService;
@@ -24,13 +23,14 @@ public class Program
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
#region Event Bus #region MassTransit
builder.Services.AddRabbitMQ(opt => builder.Services.AddFictionArchiveMassTransit(
builder.Configuration,
x =>
{ {
builder.Configuration.GetSection("RabbitMQ").Bind(opt); x.AddConsumer<UploadFileCommandConsumer>();
}) });
.Subscribe<FileUploadRequestCreatedEvent, FileUploadRequestCreatedEventHandler>();
#endregion #endregion

View File

@@ -2,57 +2,62 @@ using Amazon.S3;
using Amazon.S3.Model; using Amazon.S3.Model;
using FictionArchive.Common.Enums; using FictionArchive.Common.Enums;
using FictionArchive.Service.FileService.Models; using FictionArchive.Service.FileService.Models;
using FictionArchive.Service.FileService.Models.IntegrationEvents; using FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.Shared.MassTransit.Contracts.Events;
using MassTransit;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace FictionArchive.Service.FileService.Services.EventHandlers; namespace FictionArchive.Service.FileService.Services.EventHandlers;
public class FileUploadRequestCreatedEventHandler : IIntegrationEventHandler<FileUploadRequestCreatedEvent> public class UploadFileCommandConsumer : IConsumer<UploadFileCommand>
{ {
private readonly ILogger<FileUploadRequestCreatedEventHandler> _logger; private readonly ILogger<UploadFileCommandConsumer> _logger;
private readonly AmazonS3Client _amazonS3Client; private readonly AmazonS3Client _amazonS3Client;
private readonly IEventBus _eventBus;
private readonly S3Configuration _s3Configuration; private readonly S3Configuration _s3Configuration;
private readonly ProxyConfiguration _proxyConfiguration; private readonly ProxyConfiguration _proxyConfiguration;
public FileUploadRequestCreatedEventHandler(ILogger<FileUploadRequestCreatedEventHandler> logger, AmazonS3Client amazonS3Client, IEventBus eventBus, IOptions<S3Configuration> s3Configuration, IOptions<ProxyConfiguration> proxyConfiguration) public UploadFileCommandConsumer(
ILogger<UploadFileCommandConsumer> logger,
AmazonS3Client amazonS3Client,
IOptions<S3Configuration> s3Configuration,
IOptions<ProxyConfiguration> proxyConfiguration)
{ {
_logger = logger; _logger = logger;
_amazonS3Client = amazonS3Client; _amazonS3Client = amazonS3Client;
_eventBus = eventBus;
_proxyConfiguration = proxyConfiguration.Value; _proxyConfiguration = proxyConfiguration.Value;
_s3Configuration = s3Configuration.Value; _s3Configuration = s3Configuration.Value;
} }
public async Task Handle(FileUploadRequestCreatedEvent @event) public async Task Consume(ConsumeContext<UploadFileCommand> context)
{ {
var command = context.Message;
var putObjectRequest = new PutObjectRequest(); var putObjectRequest = new PutObjectRequest();
putObjectRequest.BucketName = _s3Configuration.Bucket; putObjectRequest.BucketName = _s3Configuration.Bucket;
putObjectRequest.Key = @event.FilePath; putObjectRequest.Key = command.FilePath;
putObjectRequest.UseChunkEncoding = false; // Needed to avoid an error with Garage putObjectRequest.UseChunkEncoding = false; // Needed to avoid an error with Garage
using MemoryStream memoryStream = new MemoryStream(@event.FileData); using MemoryStream memoryStream = new MemoryStream(command.FileData);
putObjectRequest.InputStream = memoryStream; putObjectRequest.InputStream = memoryStream;
var s3Response = await _amazonS3Client.PutObjectAsync(putObjectRequest); var s3Response = await _amazonS3Client.PutObjectAsync(putObjectRequest);
if (s3Response.HttpStatusCode != System.Net.HttpStatusCode.OK) if (s3Response.HttpStatusCode != System.Net.HttpStatusCode.OK)
{ {
_logger.LogError("An error occurred while uploading file to S3. Response code: {responsecode}", s3Response.HttpStatusCode); _logger.LogError("An error occurred while uploading file to S3. Response code: {responsecode}", s3Response.HttpStatusCode);
await _eventBus.Publish(new FileUploadRequestStatusUpdateEvent() await context.Publish(new FileUploadCompletedEvent
{ {
RequestId = @event.RequestId, RequestId = command.RequestId,
Status = RequestStatus.Failed, Status = RequestStatus.Failed,
ErrorMessage = "An error occurred while uploading file to S3." ErrorMessage = "An error occurred while uploading file to S3."
}); });
return; return;
} }
await _eventBus.Publish(new FileUploadRequestStatusUpdateEvent() await context.Publish(new FileUploadCompletedEvent
{ {
Status = RequestStatus.Success, Status = RequestStatus.Success,
RequestId = @event.RequestId, RequestId = command.RequestId,
FileAccessUrl = _proxyConfiguration.BaseUrl + "/" + @event.FilePath FileAccessUrl = _proxyConfiguration.BaseUrl + "/" + command.FilePath
}); });
} }
} }

View File

@@ -9,8 +9,10 @@
"BaseUrl": "https://localhost:7247/api" "BaseUrl": "https://localhost:7247/api"
}, },
"RabbitMQ": { "RabbitMQ": {
"ConnectionString": "amqp://localhost", "Host": "localhost",
"ClientIdentifier": "FileService" "VirtualHost": "/",
"Username": "guest",
"Password": "guest"
}, },
"S3": { "S3": {
"Url": "https://s3.orfl.xyz", "Url": "https://s3.orfl.xyz",

View File

@@ -1,5 +1,4 @@
using FictionArchive.Common.Enums; using FictionArchive.Common.Enums;
using FictionArchive.Service.FileService.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Configuration;
using FictionArchive.Service.NovelService.Models.Enums; using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.Images;
@@ -8,8 +7,9 @@ using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters; using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.SourceAdapters; using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
using FluentAssertions; using FluentAssertions;
using MassTransit;
using HtmlAgilityPack; using HtmlAgilityPack;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -72,7 +72,8 @@ public class NovelUpdateServiceTests
private static NovelUpdateService CreateService( private static NovelUpdateService CreateService(
NovelServiceDbContext dbContext, NovelServiceDbContext dbContext,
ISourceAdapter adapter, ISourceAdapter adapter,
IEventBus eventBus, IPublishEndpoint publishEndpoint,
ISendEndpointProvider sendEndpointProvider,
string pendingImageUrl = "https://pending/placeholder.jpg") string pendingImageUrl = "https://pending/placeholder.jpg")
{ {
var options = Options.Create(new NovelUpdateServiceConfiguration var options = Options.Create(new NovelUpdateServiceConfiguration
@@ -80,7 +81,7 @@ public class NovelUpdateServiceTests
PendingImageUrl = pendingImageUrl PendingImageUrl = pendingImageUrl
}); });
return new NovelUpdateService(dbContext, NullLogger<NovelUpdateService>.Instance, new[] { adapter }, eventBus, options); return new NovelUpdateService(dbContext, NullLogger<NovelUpdateService>.Instance, new[] { adapter }, publishEndpoint, sendEndpointProvider, options);
} }
[Fact] [Fact]
@@ -102,13 +103,15 @@ public class NovelUpdateServiceTests
ImageData = new List<ImageData> { image1, image2 } ImageData = new List<ImageData> { image1, image2 }
})); }));
var publishedEvents = new List<FileUploadRequestCreatedEvent>(); var publishedCommands = new List<UploadFileCommand>();
var eventBus = Substitute.For<IEventBus>(); var publishEndpoint = Substitute.For<IPublishEndpoint>();
eventBus.Publish(Arg.Do<FileUploadRequestCreatedEvent>(publishedEvents.Add)).Returns(Task.CompletedTask); var sendEndpointProvider = Substitute.For<ISendEndpointProvider>();
eventBus.Publish(Arg.Any<object>(), Arg.Any<string>()).Returns(Task.CompletedTask); var sendEndpoint = Substitute.For<ISendEndpoint>();
sendEndpointProvider.GetSendEndpoint(Arg.Any<Uri>()).Returns(Task.FromResult(sendEndpoint));
sendEndpoint.Send(Arg.Do<UploadFileCommand>(publishedCommands.Add), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
var pendingImageUrl = "https://pending/placeholder.jpg"; var pendingImageUrl = "https://pending/placeholder.jpg";
var service = CreateService(dbContext, adapter, eventBus, pendingImageUrl); var service = CreateService(dbContext, adapter, publishEndpoint, sendEndpointProvider, pendingImageUrl);
var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order); var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order);
@@ -127,10 +130,10 @@ public class NovelUpdateServiceTests
.Should() .Should()
.BeEquivalentTo(updatedChapter.Images.Select(img => img.Id.ToString())); .BeEquivalentTo(updatedChapter.Images.Select(img => img.Id.ToString()));
publishedEvents.Should().HaveCount(2); publishedCommands.Should().HaveCount(2);
publishedEvents.Select(e => e.RequestId).Should().BeEquivalentTo(updatedChapter.Images.Select(i => i.Id)); publishedCommands.Select(e => e.RequestId).Should().BeEquivalentTo(updatedChapter.Images.Select(i => i.Id));
publishedEvents.Select(e => e.FileData).Should().BeEquivalentTo(new[] { image1.Data, image2.Data }); publishedCommands.Select(e => e.FileData).Should().BeEquivalentTo(new[] { image1.Data, image2.Data });
publishedEvents.Should().OnlyContain(e => e.FilePath.StartsWith($"{novel.Id}/Images/Chapter-{updatedChapter.Id}/")); publishedCommands.Should().OnlyContain(e => e.FilePath.StartsWith($"{novel.Id}/Images/Chapter-{updatedChapter.Id}/"));
} }
[Fact] [Fact]
@@ -151,11 +154,12 @@ public class NovelUpdateServiceTests
ImageData = new List<ImageData> { image } ImageData = new List<ImageData> { image }
})); }));
var eventBus = Substitute.For<IEventBus>(); var publishEndpoint = Substitute.For<IPublishEndpoint>();
eventBus.Publish(Arg.Any<FileUploadRequestCreatedEvent>()).Returns(Task.CompletedTask); var sendEndpointProvider = Substitute.For<ISendEndpointProvider>();
eventBus.Publish(Arg.Any<object>(), Arg.Any<string>()).Returns(Task.CompletedTask); var sendEndpoint = Substitute.For<ISendEndpoint>();
sendEndpointProvider.GetSendEndpoint(Arg.Any<Uri>()).Returns(Task.FromResult(sendEndpoint));
var service = CreateService(dbContext, adapter, eventBus); var service = CreateService(dbContext, adapter, publishEndpoint, sendEndpointProvider);
var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order); var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order);
@@ -186,8 +190,9 @@ public class NovelUpdateServiceTests
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
var adapter = Substitute.For<ISourceAdapter>(); var adapter = Substitute.For<ISourceAdapter>();
var eventBus = Substitute.For<IEventBus>(); var publishEndpoint = Substitute.For<IPublishEndpoint>();
var service = CreateService(dbContext, adapter, eventBus); var sendEndpointProvider = Substitute.For<ISendEndpointProvider>();
var service = CreateService(dbContext, adapter, publishEndpoint, sendEndpointProvider);
var newUrl = "https://cdn.example.com/uploaded/cover.jpg"; var newUrl = "https://cdn.example.com/uploaded/cover.jpg";
@@ -228,8 +233,9 @@ public class NovelUpdateServiceTests
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
var adapter = Substitute.For<ISourceAdapter>(); var adapter = Substitute.For<ISourceAdapter>();
var eventBus = Substitute.For<IEventBus>(); var publishEndpoint = Substitute.For<IPublishEndpoint>();
var service = CreateService(dbContext, adapter, eventBus, pendingUrl); var sendEndpointProvider = Substitute.For<ISendEndpointProvider>();
var service = CreateService(dbContext, adapter, publishEndpoint, sendEndpointProvider, pendingUrl);
var newUrl = "https://cdn.example.com/uploaded/image.jpg"; var newUrl = "https://cdn.example.com/uploaded/image.jpg";
@@ -277,8 +283,9 @@ public class NovelUpdateServiceTests
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
var adapter = Substitute.For<ISourceAdapter>(); var adapter = Substitute.For<ISourceAdapter>();
var eventBus = Substitute.For<IEventBus>(); var publishEndpoint = Substitute.For<IPublishEndpoint>();
var service = CreateService(dbContext, adapter, eventBus, pendingUrl); var sendEndpointProvider = Substitute.For<ISendEndpointProvider>();
var service = CreateService(dbContext, adapter, publishEndpoint, sendEndpointProvider, pendingUrl);
var newUrl = "https://cdn.example.com/uploaded/img1.jpg"; var newUrl = "https://cdn.example.com/uploaded/img1.jpg";

View File

@@ -1,27 +1,20 @@
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.SourceAdapters; using FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
using FictionArchive.Service.Shared.Services.EventBus;
using HotChocolate.Authorization; using HotChocolate.Authorization;
using HotChocolate.Types; using HotChocolate.Types;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.GraphQL; namespace FictionArchive.Service.NovelService.GraphQL;
public class Mutation public class Mutation
{ {
[Authorize] [Authorize]
public async Task<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, NovelUpdateService service) public async Task<ImportNovelCommand> ImportNovel(string novelUrl, NovelUpdateService service)
{ {
return await service.QueueNovelImport(novelUrl); return await service.QueueNovelImport(novelUrl);
} }
[Authorize] [Authorize]
public async Task<ChapterPullRequestedEvent> FetchChapterContents( public async Task<PullChapterContentCommand> FetchChapterContents(
uint novelId, uint novelId,
uint volumeId, uint volumeId,
uint chapterOrder, uint chapterOrder,

View File

@@ -1,10 +0,0 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class ChapterPullRequestedEvent : IIntegrationEvent
{
public uint NovelId { get; set; }
public uint VolumeId { get; set; }
public uint ChapterOrder { get; set; }
}

View File

@@ -1,10 +0,0 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.FileService.IntegrationEvents;
public class FileUploadRequestCreatedEvent : IIntegrationEvent
{
public Guid RequestId { get; set; }
public string FilePath { get; set; }
public byte[] FileData { get; set; }
}

View File

@@ -1,22 +0,0 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class FileUploadRequestStatusUpdateEvent : IIntegrationEvent
{
public Guid RequestId { get; set; }
public RequestStatus Status { get; set; }
#region Success
public string? FileAccessUrl { get; set; }
#endregion
#region Failure
public string? ErrorMessage { get; set; }
#endregion
}

View File

@@ -1,8 +0,0 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class NovelUpdateRequestedEvent : IIntegrationEvent
{
public string NovelUrl { get; set; }
}

View File

@@ -1,17 +0,0 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class TranslationRequestCompletedEvent : IIntegrationEvent
{
/// <summary>
/// Maps this event back to a triggering request.
/// </summary>
public Guid? TranslationRequestId { get; set; }
/// <summary>
/// The resulting text.
/// </summary>
public string? TranslatedText { get; set; }
}

View File

@@ -1,13 +0,0 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class TranslationRequestCreatedEvent : IIntegrationEvent
{
public Guid TranslationRequestId { get; set; }
public Language From { get; set; }
public Language To { get; set; }
public string Body { get; set; }
public string TranslationEngineKey { get; set; }
}

View File

@@ -1,14 +1,13 @@
using FictionArchive.Common.Extensions; using FictionArchive.Common.Extensions;
using FictionArchive.Service.NovelService.GraphQL; using FictionArchive.Service.NovelService.GraphQL;
using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Configuration;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.EventHandlers; using FictionArchive.Service.NovelService.Services.Consumers;
using FictionArchive.Service.NovelService.Services.SourceAdapters; using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia; using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
using FictionArchive.Service.Shared; using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.Shared.MassTransit;
using FictionArchive.Service.Shared.Services.GraphQL; using FictionArchive.Service.Shared.Services.GraphQL;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -25,18 +24,19 @@ public class Program
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
#region Event Bus #region MassTransit
if (!isSchemaExport) if (!isSchemaExport)
{ {
builder.Services.AddRabbitMQ(opt => builder.Services.AddFictionArchiveMassTransit<NovelServiceDbContext>(
builder.Configuration,
cfg =>
{ {
builder.Configuration.GetSection("RabbitMQ").Bind(opt); cfg.AddConsumer<ImportNovelCommandConsumer>();
}) cfg.AddConsumer<PullChapterContentCommandConsumer>();
.Subscribe<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>() cfg.AddConsumer<TranslationCompletedEventConsumer>();
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>() cfg.AddConsumer<FileUploadCompletedEventConsumer>();
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>() });
.Subscribe<FileUploadRequestStatusUpdateEvent, FileUploadRequestStatusUpdateEventHandler>();
} }
#endregion #endregion

View File

@@ -0,0 +1,53 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.MassTransit.Contracts.Events;
using MassTransit;
namespace FictionArchive.Service.NovelService.Services.Consumers;
public class FileUploadCompletedEventConsumer : IConsumer<FileUploadCompletedEvent>
{
private readonly ILogger<FileUploadCompletedEventConsumer> _logger;
private readonly NovelServiceDbContext _dbContext;
private readonly NovelUpdateService _novelUpdateService;
public FileUploadCompletedEventConsumer(
ILogger<FileUploadCompletedEventConsumer> logger,
NovelServiceDbContext dbContext,
NovelUpdateService novelUpdateService)
{
_logger = logger;
_dbContext = dbContext;
_novelUpdateService = novelUpdateService;
}
public async Task Consume(ConsumeContext<FileUploadCompletedEvent> context)
{
var @event = context.Message;
var image = await _dbContext.Images.FindAsync(@event.RequestId);
if (image == null)
{
// Not a request we care about.
_logger.LogDebug(
"FileUploadCompletedEvent received for unknown image: {RequestId}",
@event.RequestId);
return;
}
if (@event.Status == RequestStatus.Failed)
{
_logger.LogError(
"Image upload failed for image with id {ImageId}: {ErrorMessage}",
image.Id, @event.ErrorMessage);
return;
}
if (@event.Status == RequestStatus.Success)
{
_logger.LogInformation(
"Image upload succeeded for image with id {ImageId}",
image.Id);
await _novelUpdateService.UpdateImage(image.Id, @event.FileAccessUrl!);
}
}
}

View File

@@ -0,0 +1,26 @@
using FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
using MassTransit;
namespace FictionArchive.Service.NovelService.Services.Consumers;
public class ImportNovelCommandConsumer : IConsumer<ImportNovelCommand>
{
private readonly ILogger<ImportNovelCommandConsumer> _logger;
private readonly NovelUpdateService _novelUpdateService;
public ImportNovelCommandConsumer(
ILogger<ImportNovelCommandConsumer> logger,
NovelUpdateService novelUpdateService)
{
_logger = logger;
_novelUpdateService = novelUpdateService;
}
public async Task Consume(ConsumeContext<ImportNovelCommand> context)
{
var command = context.Message;
_logger.LogInformation("Processing ImportNovelCommand for URL: {NovelUrl}", command.NovelUrl);
await _novelUpdateService.ImportNovel(command.NovelUrl);
}
}

View File

@@ -0,0 +1,28 @@
using FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
using MassTransit;
namespace FictionArchive.Service.NovelService.Services.Consumers;
public class PullChapterContentCommandConsumer : IConsumer<PullChapterContentCommand>
{
private readonly ILogger<PullChapterContentCommandConsumer> _logger;
private readonly NovelUpdateService _novelUpdateService;
public PullChapterContentCommandConsumer(
ILogger<PullChapterContentCommandConsumer> logger,
NovelUpdateService novelUpdateService)
{
_logger = logger;
_novelUpdateService = novelUpdateService;
}
public async Task Consume(ConsumeContext<PullChapterContentCommand> context)
{
var command = context.Message;
_logger.LogInformation(
"Processing PullChapterContentCommand for Novel: {NovelId}, Volume: {VolumeId}, Chapter: {ChapterOrder}",
command.NovelId, command.VolumeId, command.ChapterOrder);
await _novelUpdateService.PullChapterContents(command.NovelId, command.VolumeId, command.ChapterOrder);
}
}

View File

@@ -0,0 +1,52 @@
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.Shared.MassTransit.Contracts.Events;
using MassTransit;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.Services.Consumers;
public class TranslationCompletedEventConsumer : IConsumer<TranslationCompletedEvent>
{
private readonly ILogger<TranslationCompletedEventConsumer> _logger;
private readonly NovelServiceDbContext _dbContext;
public TranslationCompletedEventConsumer(
ILogger<TranslationCompletedEventConsumer> logger,
NovelServiceDbContext dbContext)
{
_logger = logger;
_dbContext = dbContext;
}
public async Task Consume(ConsumeContext<TranslationCompletedEvent> context)
{
var @event = context.Message;
var localizationRequest = await _dbContext.LocalizationRequests
.Include(r => r.KeyRequestedForTranslation)
.ThenInclude(lk => lk.Texts)
.FirstOrDefaultAsync(lk => lk.Id == @event.TranslationRequestId);
if (localizationRequest == null)
{
// Not one of our requests, discard it
_logger.LogDebug(
"TranslationCompletedEvent received for unknown request: {RequestId}",
@event.TranslationRequestId);
return;
}
localizationRequest.KeyRequestedForTranslation.Texts.Add(new LocalizationText()
{
Language = localizationRequest.TranslateTo,
Text = @event.TranslatedText,
TranslationEngine = localizationRequest.Engine
});
_dbContext.LocalizationRequests.Remove(localizationRequest);
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"Completed translation for request: {RequestId}",
@event.TranslationRequestId);
}
}

View File

@@ -1,19 +0,0 @@
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class ChapterPullRequestedEventHandler : IIntegrationEventHandler<ChapterPullRequestedEvent>
{
private readonly NovelUpdateService _novelUpdateService;
public ChapterPullRequestedEventHandler(NovelUpdateService novelUpdateService)
{
_novelUpdateService = novelUpdateService;
}
public async Task Handle(ChapterPullRequestedEvent @event)
{
await _novelUpdateService.PullChapterContents(@event.NovelId, @event.VolumeId, @event.ChapterOrder);
}
}

View File

@@ -1,39 +0,0 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class FileUploadRequestStatusUpdateEventHandler : IIntegrationEventHandler<FileUploadRequestStatusUpdateEvent>
{
private readonly ILogger<FileUploadRequestStatusUpdateEventHandler> _logger;
private readonly NovelServiceDbContext _context;
private readonly NovelUpdateService _novelUpdateService;
public FileUploadRequestStatusUpdateEventHandler(ILogger<FileUploadRequestStatusUpdateEventHandler> logger, NovelServiceDbContext context, NovelUpdateService novelUpdateService)
{
_logger = logger;
_context = context;
_novelUpdateService = novelUpdateService;
}
public async Task Handle(FileUploadRequestStatusUpdateEvent @event)
{
var image = await _context.Images.FindAsync(@event.RequestId);
if (image == null)
{
// Not a request we care about.
return;
}
if (@event.Status == RequestStatus.Failed)
{
_logger.LogError("Image upload failed for image with id {imageId}", image.Id);
return;
}
else if (@event.Status == RequestStatus.Success)
{
_logger.LogInformation("Image upload succeeded for image with id {imageId}", image.Id);
await _novelUpdateService.UpdateImage(image.Id, @event.FileAccessUrl);
}
}
}

View File

@@ -1,23 +0,0 @@
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class NovelUpdateRequestedEventHandler : IIntegrationEventHandler<NovelUpdateRequestedEvent>
{
private readonly ILogger<NovelUpdateRequestedEventHandler> _logger;
private readonly IEventBus _eventBus;
private readonly NovelUpdateService _novelUpdateService;
public NovelUpdateRequestedEventHandler(ILogger<NovelUpdateRequestedEventHandler> logger, IEventBus eventBus, NovelUpdateService novelUpdateService)
{
_logger = logger;
_eventBus = eventBus;
_novelUpdateService = novelUpdateService;
}
public async Task Handle(NovelUpdateRequestedEvent @event)
{
await _novelUpdateService.ImportNovel(@event.NovelUrl);
}
}

View File

@@ -1,39 +0,0 @@
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.Shared.Services.EventBus;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class TranslationRequestCompletedEventHandler : IIntegrationEventHandler<TranslationRequestCompletedEvent>
{
private readonly ILogger<TranslationRequestCompletedEventHandler> _logger;
private readonly NovelServiceDbContext _dbContext;
public TranslationRequestCompletedEventHandler(ILogger<TranslationRequestCompletedEventHandler> logger, NovelServiceDbContext dbContext)
{
_logger = logger;
_dbContext = dbContext;
}
public async Task Handle(TranslationRequestCompletedEvent @event)
{
var localizationRequest = await _dbContext.LocalizationRequests.Include(r => r.KeyRequestedForTranslation)
.ThenInclude(lk => lk.Texts)
.FirstOrDefaultAsync(lk => lk.Id == @event.TranslationRequestId);
if (localizationRequest == null)
{
// Not one of our requests, discard it
return;
}
localizationRequest.KeyRequestedForTranslation.Texts.Add(new LocalizationText()
{
Language = localizationRequest.TranslateTo,
Text = @event.TranslatedText,
TranslationEngine = localizationRequest.Engine
});
_dbContext.LocalizationRequests.Remove(localizationRequest);
await _dbContext.SaveChangesAsync();
}
}

View File

@@ -1,15 +1,15 @@
using FictionArchive.Common.Enums; using FictionArchive.Common.Enums;
using FictionArchive.Service.FileService.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Configuration;
using FictionArchive.Service.NovelService.Models.Enums; using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.Images;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters; using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services.SourceAdapters; using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
using FictionArchive.Service.Shared.MassTransit.Contracts.Events;
using HtmlAgilityPack; using HtmlAgilityPack;
using MassTransit;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -20,15 +20,23 @@ public class NovelUpdateService
private readonly NovelServiceDbContext _dbContext; private readonly NovelServiceDbContext _dbContext;
private readonly ILogger<NovelUpdateService> _logger; private readonly ILogger<NovelUpdateService> _logger;
private readonly IEnumerable<ISourceAdapter> _sourceAdapters; private readonly IEnumerable<ISourceAdapter> _sourceAdapters;
private readonly IEventBus _eventBus; private readonly IPublishEndpoint _publishEndpoint;
private readonly ISendEndpointProvider _sendEndpointProvider;
private readonly NovelUpdateServiceConfiguration _novelUpdateServiceConfiguration; private readonly NovelUpdateServiceConfiguration _novelUpdateServiceConfiguration;
public NovelUpdateService(NovelServiceDbContext dbContext, ILogger<NovelUpdateService> logger, IEnumerable<ISourceAdapter> sourceAdapters, IEventBus eventBus, IOptions<NovelUpdateServiceConfiguration> novelUpdateServiceConfiguration) public NovelUpdateService(
NovelServiceDbContext dbContext,
ILogger<NovelUpdateService> logger,
IEnumerable<ISourceAdapter> sourceAdapters,
IPublishEndpoint publishEndpoint,
ISendEndpointProvider sendEndpointProvider,
IOptions<NovelUpdateServiceConfiguration> novelUpdateServiceConfiguration)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_logger = logger; _logger = logger;
_sourceAdapters = sourceAdapters; _sourceAdapters = sourceAdapters;
_eventBus = eventBus; _publishEndpoint = publishEndpoint;
_sendEndpointProvider = sendEndpointProvider;
_novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value; _novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value;
} }
@@ -393,7 +401,7 @@ public class NovelUpdateService
// Publish novel created event for new novels // Publish novel created event for new novels
if (existingNovel == null) if (existingNovel == null)
{ {
await _eventBus.Publish(new NovelCreatedEvent await _publishEndpoint.Publish(new NovelCreatedEvent
{ {
NovelId = novel.Id, NovelId = novel.Id,
Title = novel.Name.Texts.First(t => t.Language == novel.RawLanguage).Text, Title = novel.Name.Texts.First(t => t.Language == novel.RawLanguage).Text,
@@ -408,7 +416,7 @@ public class NovelUpdateService
{ {
foreach (var chapter in volume.Chapters.Where(c => !existingChapterIds.Contains(c.Id))) foreach (var chapter in volume.Chapters.Where(c => !existingChapterIds.Contains(c.Id)))
{ {
await _eventBus.Publish(new ChapterCreatedEvent await _publishEndpoint.Publish(new ChapterCreatedEvent
{ {
ChapterId = chapter.Id, ChapterId = chapter.Id,
NovelId = novel.Id, NovelId = novel.Id,
@@ -420,10 +428,11 @@ public class NovelUpdateService
} }
} }
// Publish cover image event if needed // Send cover image upload command if needed
if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null) if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null)
{ {
await _eventBus.Publish(new FileUploadRequestCreatedEvent var uploadEndpoint = await _sendEndpointProvider.GetSendEndpoint(new Uri("queue:upload-file-command"));
await uploadEndpoint.Send(new UploadFileCommand
{ {
RequestId = novel.CoverImage.Id, RequestId = novel.CoverImage.Id,
FileData = metadata.CoverImage.Data, FileData = metadata.CoverImage.Data,
@@ -431,7 +440,8 @@ public class NovelUpdateService
}); });
} }
// Publish chapter pull events for chapters without body content // Send chapter pull commands for chapters without body content
var pullChapterEndpoint = await _sendEndpointProvider.GetSendEndpoint(new Uri("queue:pull-chapter-content-command"));
foreach (var volume in novel.Volumes) foreach (var volume in novel.Volumes)
{ {
var chaptersNeedingPull = volume.Chapters var chaptersNeedingPull = volume.Chapters
@@ -440,7 +450,7 @@ public class NovelUpdateService
foreach (var chapter in chaptersNeedingPull) foreach (var chapter in chaptersNeedingPull)
{ {
await _eventBus.Publish(new ChapterPullRequestedEvent await pullChapterEndpoint.Send(new PullChapterContentCommand
{ {
NovelId = novel.Id, NovelId = novel.Id,
VolumeId = volume.Id, VolumeId = volume.Id,
@@ -513,12 +523,13 @@ public class NovelUpdateService
localizationText.Text = chapterDoc.DocumentNode.OuterHtml; localizationText.Text = chapterDoc.DocumentNode.OuterHtml;
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
// Body was updated, raise image request // Body was updated, send upload commands for images
var uploadEndpoint = await _sendEndpointProvider.GetSendEndpoint(new Uri("queue:upload-file-command"));
int imgCount = 0; int imgCount = 0;
foreach (var image in chapter.Images) foreach (var image in chapter.Images)
{ {
var data = rawChapter.ImageData.FirstOrDefault(img => img.Url == image.OriginalPath); var data = rawChapter.ImageData.FirstOrDefault(img => img.Url == image.OriginalPath);
await _eventBus.Publish(new FileUploadRequestCreatedEvent() await uploadEndpoint.Send(new UploadFileCommand
{ {
FileData = data.Data, FileData = data.Data,
FilePath = $"{novel.Id}/Images/Chapter-{chapter.Id}/{imgCount++}.jpg", FilePath = $"{novel.Id}/Images/Chapter-{chapter.Id}/{imgCount++}.jpg",
@@ -557,26 +568,28 @@ public class NovelUpdateService
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
} }
public async Task<NovelUpdateRequestedEvent> QueueNovelImport(string novelUrl) public async Task<ImportNovelCommand> QueueNovelImport(string novelUrl)
{ {
var importNovelRequestEvent = new NovelUpdateRequestedEvent() var command = new ImportNovelCommand
{ {
NovelUrl = novelUrl NovelUrl = novelUrl
}; };
await _eventBus.Publish(importNovelRequestEvent); var endpoint = await _sendEndpointProvider.GetSendEndpoint(new Uri("queue:import-novel-command"));
return importNovelRequestEvent; await endpoint.Send(command);
return command;
} }
public async Task<ChapterPullRequestedEvent> QueueChapterPull(uint novelId, uint volumeId, uint chapterOrder) public async Task<PullChapterContentCommand> QueueChapterPull(uint novelId, uint volumeId, uint chapterOrder)
{ {
var chapterPullEvent = new ChapterPullRequestedEvent() var command = new PullChapterContentCommand
{ {
NovelId = novelId, NovelId = novelId,
VolumeId = volumeId, VolumeId = volumeId,
ChapterOrder = chapterOrder ChapterOrder = chapterOrder
}; };
await _eventBus.Publish(chapterPullEvent); var endpoint = await _sendEndpointProvider.GetSendEndpoint(new Uri("queue:pull-chapter-content-command"));
return chapterPullEvent; await endpoint.Send(command);
return command;
} }
public async Task DeleteNovel(uint novelId) public async Task DeleteNovel(uint novelId)

View File

@@ -16,8 +16,10 @@
"DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres" "DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres"
}, },
"RabbitMQ": { "RabbitMQ": {
"ConnectionString": "amqp://localhost", "Host": "localhost",
"ClientIdentifier": "NovelService" "VirtualHost": "/",
"Username": "guest",
"Password": "guest"
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"OIDC": { "OIDC": {

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj", "FictionArchive.Service.ReportingService/"]
RUN dotnet restore "FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj"
COPY . .
WORKDIR "/src/FictionArchive.Service.ReportingService"
RUN dotnet build "./FictionArchive.Service.ReportingService.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.Service.ReportingService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FictionArchive.Service.ReportingService.dll"]

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using HotChocolate;
using HotChocolate.Authorization;
namespace FictionArchive.Service.ReportingService.GraphQL;
public class Mutation
{
/// <summary>
/// Placeholder mutation for GraphQL schema requirements.
/// The ReportingService is primarily read-only, consuming events from other services.
/// </summary>
[Authorize(Roles = ["admin"])]
[GraphQLDescription("Placeholder mutation. ReportingService is primarily read-only.")]
public bool Ping() => true;
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json;
using FictionArchive.Service.ReportingService.Models.Database;
using FictionArchive.Service.ReportingService.Models.DTOs;
using FictionArchive.Service.ReportingService.Services;
using HotChocolate;
using HotChocolate.Data;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.ReportingService.GraphQL;
public class Query
{
[UseProjection]
[UseFiltering]
[UseSorting]
[GraphQLName("reportingJobs")]
public IQueryable<Job> GetReportingJobs(ReportingServiceDbContext dbContext)
=> dbContext.Jobs.Include(j => j.History);
[GraphQLName("reportingJob")]
public async Task<JobDto?> GetReportingJob(Guid id, ReportingServiceDbContext dbContext)
{
var job = await dbContext.Jobs
.Include(j => j.History.OrderBy(h => h.Timestamp))
.FirstOrDefaultAsync(j => j.Id == id);
if (job == null) return null;
return new JobDto
{
Id = job.Id,
JobType = job.JobType,
Status = job.Status,
CurrentStep = job.CurrentStep,
ErrorMessage = job.ErrorMessage,
Metadata = job.Metadata != null
? JsonSerializer.Deserialize<Dictionary<string, object>>(job.Metadata.RootElement.GetRawText())
: null,
History = job.History.Select(h => new JobHistoryEntryDto
{
FromState = h.FromState,
ToState = h.ToState,
Message = h.Message,
Error = h.Error,
Timestamp = h.Timestamp
}).ToList(),
CreatedTime = job.CreatedTime,
UpdatedTime = job.UpdatedTime,
CompletedTime = job.CompletedTime
};
}
}

View File

@@ -0,0 +1,17 @@
using NodaTime;
namespace FictionArchive.Service.ReportingService.Models.DTOs;
public class JobDto
{
public Guid Id { get; set; }
public required string JobType { get; set; }
public required string Status { get; set; }
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
public Dictionary<string, object>? Metadata { get; set; }
public List<JobHistoryEntryDto> History { get; set; } = new();
public Instant CreatedTime { get; set; }
public Instant UpdatedTime { get; set; }
public Instant? CompletedTime { get; set; }
}

View File

@@ -0,0 +1,12 @@
using NodaTime;
namespace FictionArchive.Service.ReportingService.Models.DTOs;
public class JobHistoryEntryDto
{
public required string FromState { get; set; }
public required string ToState { get; set; }
public string? Message { get; set; }
public string? Error { get; set; }
public Instant Timestamp { get; set; }
}

View File

@@ -0,0 +1,19 @@
using System.Text.Json;
using NodaTime;
namespace FictionArchive.Service.ReportingService.Models.Database;
public class Job
{
public Guid Id { get; set; }
public required string JobType { get; set; }
public required string Status { get; set; }
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
public JsonDocument? Metadata { get; set; }
public Instant CreatedTime { get; set; }
public Instant UpdatedTime { get; set; }
public Instant? CompletedTime { get; set; }
public ICollection<JobHistoryEntry> History { get; set; } = new List<JobHistoryEntry>();
}

View File

@@ -0,0 +1,16 @@
using NodaTime;
namespace FictionArchive.Service.ReportingService.Models.Database;
public class JobHistoryEntry
{
public int Id { get; set; }
public Guid JobId { get; set; }
public required string FromState { get; set; }
public required string ToState { get; set; }
public string? Message { get; set; }
public string? Error { get; set; }
public Instant Timestamp { get; set; }
public Job Job { get; set; } = null!;
}

View File

@@ -0,0 +1,76 @@
using FictionArchive.Common.Extensions;
using FictionArchive.Service.ReportingService.GraphQL;
using FictionArchive.Service.ReportingService.Services;
using FictionArchive.Service.ReportingService.Services.Consumers;
using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.MassTransit;
namespace FictionArchive.Service.ReportingService;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
builder.AddLocalAppsettings();
builder.Services.AddHealthChecks();
#region Database
builder.Services.RegisterDbContext<ReportingServiceDbContext>(
builder.Configuration.GetConnectionString("DefaultConnection")!,
skipInfrastructure: isSchemaExport);
#endregion
#region MassTransit
if (!isSchemaExport)
{
builder.Services.AddFictionArchiveMassTransit<ReportingServiceDbContext>(
builder.Configuration,
x =>
{
x.AddConsumer<JobStateChangedEventConsumer>();
});
}
#endregion
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.AddAuthorization();
#endregion
// Authentication & Authorization
builder.Services.AddOidcAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
var app = builder.Build();
if (!isSchemaExport)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ReportingServiceDbContext>();
dbContext.UpdateDatabase();
}
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.RunWithGraphQLCommands(args);
}
}

View File

@@ -0,0 +1,39 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:45320",
"sslPort": 44320
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "graphql",
"applicationUrl": "https://localhost:7320;http://localhost:5180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,74 @@
using System.Text.Json;
using FictionArchive.Service.ReportingService.Models.Database;
using FictionArchive.Service.Shared.MassTransit.Contracts;
using MassTransit;
namespace FictionArchive.Service.ReportingService.Services.Consumers;
public class JobStateChangedEventConsumer : IConsumer<JobStateChangedEvent>
{
private readonly ReportingServiceDbContext _dbContext;
private readonly ILogger<JobStateChangedEventConsumer> _logger;
public JobStateChangedEventConsumer(
ReportingServiceDbContext dbContext,
ILogger<JobStateChangedEventConsumer> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task Consume(ConsumeContext<JobStateChangedEvent> context)
{
var @event = context.Message;
var job = await _dbContext.Jobs.FindAsync(@event.JobId);
if (job == null)
{
job = new Job
{
Id = @event.JobId,
JobType = @event.JobType,
Status = @event.ToState,
CreatedTime = @event.Timestamp,
UpdatedTime = @event.Timestamp,
Metadata = @event.Metadata != null
? JsonSerializer.SerializeToDocument(@event.Metadata)
: null
};
_dbContext.Jobs.Add(job);
}
else
{
job.Status = @event.ToState;
job.UpdatedTime = @event.Timestamp;
if (@event.Error != null)
{
job.ErrorMessage = @event.Error;
}
if (@event.ToState is "Completed" or "Failed")
{
job.CompletedTime = @event.Timestamp;
}
}
var historyEntry = new JobHistoryEntry
{
JobId = @event.JobId,
FromState = @event.FromState,
ToState = @event.ToState,
Message = @event.Message,
Error = @event.Error,
Timestamp = @event.Timestamp
};
_dbContext.JobHistoryEntries.Add(historyEntry);
await _dbContext.SaveChangesAsync();
_logger.LogDebug("Recorded job state change: {JobId} {FromState} -> {ToState}",
@event.JobId, @event.FromState, @event.ToState);
}
}

View File

@@ -0,0 +1,36 @@
using FictionArchive.Service.ReportingService.Models.Database;
using FictionArchive.Service.Shared.Services.Database;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.ReportingService.Services;
public class ReportingServiceDbContext : FictionArchiveDbContext
{
public ReportingServiceDbContext(DbContextOptions options, ILogger<ReportingServiceDbContext> logger)
: base(options, logger) { }
public DbSet<Job> Jobs => Set<Job>();
public DbSet<JobHistoryEntry> JobHistoryEntries => Set<JobHistoryEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Job>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.JobType);
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.CreatedTime);
});
modelBuilder.Entity<JobHistoryEntry>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasOne(e => e.Job)
.WithMany(j => j.History)
.HasForeignKey(e => e.JobId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,28 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_ReportingService;Username=postgres;password=postgres"
},
"RabbitMQ": {
"Host": "localhost",
"VirtualHost": "/",
"Username": "guest",
"Password": "guest"
},
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,6 @@
{
"subgraph": "Reporting",
"http": {
"baseAddress": "http://localhost:5180/graphql"
}
}

View File

@@ -19,6 +19,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" Version="0.5.1" /> <PackageReference Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" Version="0.5.1" />
<PackageReference Include="MassTransit" Version="8.4.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -1,4 +1,4 @@
using FictionArchive.Service.Shared.Services.EventBus; using MassTransit;
using Newtonsoft.Json; using Newtonsoft.Json;
using Quartz; using Quartz;
@@ -6,15 +6,22 @@ namespace FictionArchive.Service.SchedulerService.Models.JobTemplates;
public class EventJobTemplate : IJob public class EventJobTemplate : IJob
{ {
private readonly IEventBus _eventBus; private readonly IPublishEndpoint _publishEndpoint;
private readonly ISendEndpointProvider _sendEndpointProvider;
private readonly ILogger<EventJobTemplate> _logger; private readonly ILogger<EventJobTemplate> _logger;
public const string EventTypeParameter = "RoutingKey"; public const string EventTypeParameter = "MessageType";
public const string EventDataParameter = "MessageData"; public const string EventDataParameter = "MessageData";
public const string IsCommandParameter = "IsCommand";
public const string DestinationQueueParameter = "DestinationQueue";
public EventJobTemplate(IEventBus eventBus, ILogger<EventJobTemplate> logger) public EventJobTemplate(
IPublishEndpoint publishEndpoint,
ISendEndpointProvider sendEndpointProvider,
ILogger<EventJobTemplate> logger)
{ {
_eventBus = eventBus; _publishEndpoint = publishEndpoint;
_sendEndpointProvider = sendEndpointProvider;
_logger = logger; _logger = logger;
} }
@@ -22,14 +29,47 @@ public class EventJobTemplate : IJob
{ {
try try
{ {
var eventData = context.MergedJobDataMap.GetString(EventDataParameter); var messageData = context.MergedJobDataMap.GetString(EventDataParameter);
var eventType = context.MergedJobDataMap.GetString(EventTypeParameter); var messageTypeName = context.MergedJobDataMap.GetString(EventTypeParameter);
var eventObject = JsonConvert.DeserializeObject(eventData); var isCommand = context.MergedJobDataMap.GetBoolean(IsCommandParameter);
await _eventBus.Publish(eventObject, eventType);
var messageType = Type.GetType(messageTypeName!);
if (messageType == null)
{
_logger.LogError("Could not resolve message type: {MessageType}", messageTypeName);
return;
}
var message = JsonConvert.DeserializeObject(messageData!, messageType);
if (message == null)
{
_logger.LogError("Could not deserialize message data for type: {MessageType}", messageTypeName);
return;
}
if (isCommand)
{
var destinationQueue = context.MergedJobDataMap.GetString(DestinationQueueParameter);
if (string.IsNullOrEmpty(destinationQueue))
{
_logger.LogError("Destination queue not specified for command message");
return;
}
var endpoint = await _sendEndpointProvider.GetSendEndpoint(new Uri($"queue:{destinationQueue}"));
await endpoint.Send(message, messageType);
_logger.LogInformation("Sent command {MessageType} to queue {Queue}", messageTypeName, destinationQueue);
}
else
{
await _publishEndpoint.Publish(message, messageType);
_logger.LogInformation("Published event {MessageType}", messageTypeName);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "An error occurred while running an event job."); _logger.LogError(ex, "An error occurred while running an event job.");
throw; // Re-throw to let Quartz handle retries
} }
} }
} }

View File

@@ -2,7 +2,7 @@ using FictionArchive.Service.SchedulerService.GraphQL;
using FictionArchive.Service.SchedulerService.Services; using FictionArchive.Service.SchedulerService.Services;
using FictionArchive.Service.Shared; using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.Shared.MassTransit;
using Quartz; using Quartz;
using Quartz.Impl.AdoJobStore; using Quartz.Impl.AdoJobStore;
@@ -34,14 +34,11 @@ public class Program
#endregion #endregion
#region Event Bus #region MassTransit
if (!isSchemaExport) if (!isSchemaExport)
{ {
builder.Services.AddRabbitMQ(opt => builder.Services.AddFictionArchiveMassTransit(builder.Configuration);
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
});
} }
#endregion #endregion

View File

@@ -1,7 +1,6 @@
using System.Data; using System.Data;
using FictionArchive.Service.SchedulerService.Models; using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Models.JobTemplates; using FictionArchive.Service.SchedulerService.Models.JobTemplates;
using FictionArchive.Service.Shared.Services.EventBus;
using Quartz; using Quartz;
using Quartz.Impl.Matchers; using Quartz.Impl.Matchers;

View File

@@ -6,8 +6,10 @@
} }
}, },
"RabbitMQ": { "RabbitMQ": {
"ConnectionString": "amqp://localhost", "Host": "localhost",
"ClientIdentifier": "SchedulerService" "VirtualHost": "/",
"Username": "guest",
"Password": "guest"
}, },
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_SchedulerService;Username=postgres;password=postgres" "DefaultConnection": "Host=localhost;Database=FictionArchive_SchedulerService;Username=postgres;password=postgres"

View File

@@ -31,7 +31,9 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="Polly" Version="8.6.5" /> <PackageReference Include="Polly" Version="8.6.5" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" /> <PackageReference Include="MassTransit" Version="8.4.0" />
<PackageReference Include="MassTransit.RabbitMQ" Version="8.4.0" />
<PackageReference Include="MassTransit.EntityFrameworkCore" Version="8.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,9 @@
namespace FictionArchive.Service.Shared.MassTransit.Configuration;
public class MassTransitOptions
{
public string Host { get; set; } = "localhost";
public string VirtualHost { get; set; } = "/";
public string Username { get; set; } = "guest";
public string Password { get; set; } = "guest";
}

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
public record ImportNovelCommand : ICommand
{
public required string NovelUrl { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
public record PullChapterContentCommand : ICommand
{
public required uint NovelId { get; init; }
public required uint VolumeId { get; init; }
public required uint ChapterOrder { get; init; }
}

View File

@@ -0,0 +1,12 @@
using FictionArchive.Common.Enums;
namespace FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
public record TranslateTextCommand : ICommand
{
public Guid TranslationRequestId { get; init; }
public Language From { get; init; }
public Language To { get; init; }
public required string Body { get; init; }
public required string TranslationEngineKey { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
public record UploadFileCommand : ICommand
{
public Guid RequestId { get; init; }
public required string FilePath { get; init; }
public required byte[] FileData { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace FictionArchive.Service.Shared.MassTransit.Contracts.Events;
public record AuthUserAddedEvent : IEvent
{
public required string OAuthProviderId { get; init; }
public required string InviterOAuthProviderId { get; init; }
public required string EventUserEmail { get; init; }
public required string EventUserUsername { get; init; }
}

View File

@@ -1,8 +1,6 @@
using FictionArchive.Service.Shared.Services.EventBus; namespace FictionArchive.Service.Shared.MassTransit.Contracts.Events;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; public record ChapterCreatedEvent : IEvent
public class ChapterCreatedEvent : IIntegrationEvent
{ {
public required uint ChapterId { get; init; } public required uint ChapterId { get; init; }
public required uint NovelId { get; init; } public required uint NovelId { get; init; }

View File

@@ -0,0 +1,11 @@
using FictionArchive.Common.Enums;
namespace FictionArchive.Service.Shared.MassTransit.Contracts.Events;
public record FileUploadCompletedEvent : IEvent
{
public Guid RequestId { get; init; }
public RequestStatus Status { get; init; }
public string? FileAccessUrl { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@@ -1,9 +1,8 @@
using FictionArchive.Common.Enums; using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; namespace FictionArchive.Service.Shared.MassTransit.Contracts.Events;
public class NovelCreatedEvent : IIntegrationEvent public record NovelCreatedEvent : IEvent
{ {
public required uint NovelId { get; init; } public required uint NovelId { get; init; }
public required string Title { get; init; } public required string Title { get; init; }

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.Shared.MassTransit.Contracts.Events;
public record TranslationCompletedEvent : IEvent
{
public Guid TranslationRequestId { get; init; }
public required string TranslatedText { get; init; }
}

View File

@@ -0,0 +1,13 @@
namespace FictionArchive.Service.Shared.MassTransit.Contracts.Events;
public record UserInvitedEvent : IEvent
{
public Guid InvitedUserId { get; init; }
public required string InvitedUsername { get; init; }
public required string InvitedEmail { get; init; }
public required string InvitedOAuthProviderId { get; init; }
public Guid InviterId { get; init; }
public required string InviterUsername { get; init; }
public required string InviterOAuthProviderId { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.Shared.MassTransit.Contracts;
/// <summary>
/// Marker interface for commands (do something, single consumer)
/// </summary>
public interface ICommand { }

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.Shared.MassTransit.Contracts;
/// <summary>
/// Marker interface for events (something happened, multiple subscribers)
/// </summary>
public interface IEvent { }

View File

@@ -0,0 +1,18 @@
using NodaTime;
namespace FictionArchive.Service.Shared.MassTransit.Contracts;
/// <summary>
/// Published by sagas on state transitions for centralized job tracking
/// </summary>
public record JobStateChangedEvent : IEvent
{
public Guid JobId { get; init; }
public required string JobType { get; init; }
public required string FromState { get; init; }
public required string ToState { get; init; }
public string? Message { get; init; }
public string? Error { get; init; }
public Instant Timestamp { get; init; }
public Dictionary<string, object>? Metadata { get; init; }
}

View File

@@ -0,0 +1,103 @@
using FictionArchive.Service.Shared.MassTransit.Configuration;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace FictionArchive.Service.Shared.MassTransit;
public static class MassTransitExtensions
{
/// <summary>
/// Adds MassTransit with RabbitMQ and Entity Framework outbox
/// </summary>
public static IServiceCollection AddFictionArchiveMassTransit<TDbContext>(
this IServiceCollection services,
IConfiguration configuration,
Action<IBusRegistrationConfigurator>? configureConsumers = null)
where TDbContext : DbContext
{
services.AddMassTransit(x =>
{
configureConsumers?.Invoke(x);
x.AddEntityFrameworkOutbox<TDbContext>(o =>
{
o.UsePostgres();
o.UseBusOutbox();
});
x.UsingRabbitMq((context, cfg) =>
{
var options = configuration.GetSection("RabbitMQ").Get<MassTransitOptions>()
?? new MassTransitOptions();
cfg.Host(options.Host, options.VirtualHost, h =>
{
h.Username(options.Username);
h.Password(options.Password);
});
// Immediate retries for transient failures
cfg.UseMessageRetry(r => r.Intervals(
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(1)));
// Delayed redelivery for longer outages
cfg.UseDelayedRedelivery(r => r.Intervals(
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(30),
TimeSpan.FromMinutes(2),
TimeSpan.FromMinutes(10),
TimeSpan.FromMinutes(30)));
cfg.ConfigureEndpoints(context);
});
});
return services;
}
/// <summary>
/// Adds MassTransit with RabbitMQ without outbox (for services without EF)
/// </summary>
public static IServiceCollection AddFictionArchiveMassTransit(
this IServiceCollection services,
IConfiguration configuration,
Action<IBusRegistrationConfigurator>? configureConsumers = null)
{
services.AddMassTransit(x =>
{
configureConsumers?.Invoke(x);
x.UsingRabbitMq((context, cfg) =>
{
var options = configuration.GetSection("RabbitMQ").Get<MassTransitOptions>()
?? new MassTransitOptions();
cfg.Host(options.Host, options.VirtualHost, h =>
{
h.Username(options.Username);
h.Password(options.Password);
});
cfg.UseMessageRetry(r => r.Intervals(
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(1)));
cfg.UseDelayedRedelivery(r => r.Intervals(
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(30),
TimeSpan.FromMinutes(2),
TimeSpan.FromMinutes(10),
TimeSpan.FromMinutes(30)));
cfg.ConfigureEndpoints(context);
});
});
return services;
}
}

View File

@@ -1,25 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace FictionArchive.Service.Shared.Services.EventBus;
public class EventBusBuilder<TEventBus> where TEventBus : class, IEventBus
{
private readonly IServiceCollection _services;
private readonly SubscriptionManager _subscriptionManager;
public EventBusBuilder(IServiceCollection services)
{
_services = services;
_services.AddSingleton<IEventBus, TEventBus>();
_subscriptionManager = new SubscriptionManager();
_services.AddSingleton<SubscriptionManager>(_subscriptionManager);
}
public EventBusBuilder<TEventBus> Subscribe<TEvent, TEventHandler>() where TEvent : IIntegrationEvent where TEventHandler : class, IIntegrationEventHandler<TEvent>
{
_services.AddKeyedTransient<IIntegrationEventHandler, TEventHandler>(typeof(TEvent).Name);
_subscriptionManager.RegisterSubscription<TEvent>();
return this;
}
}

View File

@@ -1,12 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace FictionArchive.Service.Shared.Services.EventBus;
public static class EventBusExtensions
{
public static EventBusBuilder<TEventBus> AddEventBus<TEventBus>(this IServiceCollection services)
where TEventBus : class, IEventBus
{
return new EventBusBuilder<TEventBus>(services);
}
}

View File

@@ -1,7 +0,0 @@
namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IEventBus
{
Task Publish<TEvent>(TEvent integrationEvent) where TEvent : IIntegrationEvent;
Task Publish(object integrationEvent, string eventType);
}

View File

@@ -1,7 +0,0 @@
using NodaTime;
namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IIntegrationEvent
{
}

View File

@@ -1,12 +0,0 @@
namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IIntegrationEventHandler<in TEvent> : IIntegrationEventHandler where TEvent : IIntegrationEvent
{
Task Handle(TEvent @event);
Task IIntegrationEventHandler.Handle(IIntegrationEvent @event) => Handle((TEvent)@event);
}
public interface IIntegrationEventHandler
{
Task Handle(IIntegrationEvent @event);
}

View File

@@ -1,35 +0,0 @@
using RabbitMQ.Client;
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public class RabbitMQConnectionProvider
{
private readonly IConnectionFactory _connectionFactory;
private IConnection Connection { get; set; }
private IChannel DefaultChannel { get; set; }
public RabbitMQConnectionProvider(IConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<IConnection> GetConnectionAsync()
{
if (Connection == null)
{
Connection = await _connectionFactory.CreateConnectionAsync();
}
return Connection;
}
public async Task<IChannel> GetDefaultChannelAsync()
{
if (DefaultChannel == null)
{
DefaultChannel = await (await GetConnectionAsync()).CreateChannelAsync();
}
return DefaultChannel;
}
}

View File

@@ -1,137 +0,0 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using NodaTime;
using NodaTime.Serialization.JsonNet;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public class RabbitMQEventBus : IEventBus, IHostedService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly RabbitMQConnectionProvider _connectionProvider;
private readonly RabbitMQOptions _options;
private readonly SubscriptionManager _subscriptionManager;
private readonly ILogger<RabbitMQEventBus> _logger;
private readonly JsonSerializerSettings _jsonSerializerSettings;
private const string ExchangeName = "fiction-archive-event-bus";
private const string CreatedAtHeader = "X-Created-At";
private const string EventIdHeader = "X-Event-Id";
public RabbitMQEventBus(IServiceScopeFactory serviceScopeFactory, RabbitMQConnectionProvider connectionProvider, IOptions<RabbitMQOptions> options, SubscriptionManager subscriptionManager, ILogger<RabbitMQEventBus> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_connectionProvider = connectionProvider;
_subscriptionManager = subscriptionManager;
_logger = logger;
_options = options.Value;
_jsonSerializerSettings = new JsonSerializerSettings().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}
public async Task Publish<TEvent>(TEvent integrationEvent) where TEvent : IIntegrationEvent
{
var routingKey = typeof(TEvent).Name;
await Publish(integrationEvent, routingKey);
}
public async Task Publish(object integrationEvent, string eventType)
{
var channel = await _connectionProvider.GetDefaultChannelAsync();
var body = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(integrationEvent));
// headers
var props = new BasicProperties();
props.Headers = new Dictionary<string, object?>()
{
{ CreatedAtHeader, Instant.FromDateTimeUtc(DateTime.UtcNow).ToString() },
{ EventIdHeader, Guid.NewGuid().ToString() }
};
await channel.BasicPublishAsync(ExchangeName, eventType, true, props, body);
_logger.LogInformation("Published event {EventName}", eventType);
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_ = Task.Factory.StartNew(async () =>
{
try
{
var channel = await _connectionProvider.GetDefaultChannelAsync();
await channel.ExchangeDeclareAsync(ExchangeName, ExchangeType.Direct,
cancellationToken: cancellationToken);
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: cancellationToken);
await channel.QueueDeclareAsync(_options.ClientIdentifier, true, false, false,
cancellationToken: cancellationToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += (sender, @event) =>
{
return OnReceivedEvent(sender, @event, channel);
};
await channel.BasicConsumeAsync(_options.ClientIdentifier, false, consumer, cancellationToken: cancellationToken);
foreach (var subscription in _subscriptionManager.Subscriptions)
{
await channel.QueueBindAsync(_options.ClientIdentifier, ExchangeName, subscription.Key,
cancellationToken: cancellationToken);
_logger.LogInformation("Subscribed to {SubscriptionKey}", subscription.Key);
}
_logger.LogInformation("RabbitMQ EventBus started.");
}
catch (Exception e)
{
_logger.LogError(e, "An error occurred while starting the RabbitMQ EventBus");
}
}, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private async Task OnReceivedEvent(object sender, BasicDeliverEventArgs @event, IChannel channel)
{
var eventName = @event.RoutingKey;
_logger.LogInformation("Received event {EventName}", eventName);
try
{
if (!_subscriptionManager.Subscriptions.ContainsKey(eventName))
{
_logger.LogWarning("Received event without subscription entry.");
return;
}
var eventBody = Encoding.UTF8.GetString(@event.Body.Span);
var eventObject = JsonConvert.DeserializeObject(eventBody, _subscriptionManager.Subscriptions[eventName], _jsonSerializerSettings) as IIntegrationEvent;
using var scope = _serviceScopeFactory.CreateScope();
foreach (var service in scope.ServiceProvider.GetKeyedServices<IIntegrationEventHandler>(eventName))
{
await service.Handle(eventObject);
}
_logger.LogInformation("Finished handling event with name {EventName}", eventName);
}
catch (Exception e)
{
_logger.LogError(e, "An error occurred while handling an event.");
}
finally
{
await channel.BasicAckAsync(@event.DeliveryTag, false);
}
}
}

View File

@@ -1,24 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public static class RabbitMQExtensions
{
public static EventBusBuilder<RabbitMQEventBus> AddRabbitMQ(this IServiceCollection services, Action<RabbitMQOptions> configure)
{
services.Configure(configure);
services.AddSingleton<IConnectionFactory, ConnectionFactory>(provider =>
{
var options = provider.GetService<IOptions<RabbitMQOptions>>();
ConnectionFactory factory = new ConnectionFactory();
factory.Uri = new Uri(options.Value.ConnectionString);
return factory;
});
services.AddSingleton<RabbitMQConnectionProvider>();
services.AddHostedService<RabbitMQEventBus>();
return services.AddEventBus<RabbitMQEventBus>();
}
}

View File

@@ -1,7 +0,0 @@
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public class RabbitMQOptions
{
public string ConnectionString { get; set; }
public string ClientIdentifier { get; set; }
}

View File

@@ -1,11 +0,0 @@
namespace FictionArchive.Service.Shared.Services.EventBus;
public class SubscriptionManager
{
public Dictionary<string, Type> Subscriptions { get; } = new Dictionary<string, Type>();
public void RegisterSubscription<TEvent>()
{
Subscriptions.Add(typeof(TEvent).Name, typeof(TEvent));
}
}

View File

@@ -23,6 +23,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="DeepL.net" Version="1.17.0" /> <PackageReference Include="DeepL.net" Version="1.17.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
<PackageReference Include="MassTransit" Version="8.4.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,18 +0,0 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.TranslationService.Models.Enums;
namespace FictionArchive.Service.TranslationService.Models.IntegrationEvents;
public class TranslationRequestCompletedEvent : IIntegrationEvent
{
/// <summary>
/// Maps this event back to a triggering request.
/// </summary>
public Guid? TranslationRequestId { get; set; }
/// <summary>
/// The resulting text.
/// </summary>
public string? TranslatedText { get; set; }
}

View File

@@ -1,13 +0,0 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.TranslationService.Models.IntegrationEvents;
public class TranslationRequestCreatedEvent : IIntegrationEvent
{
public Guid TranslationRequestId { get; set; }
public Language From { get; set; }
public Language To { get; set; }
public string Body { get; set; }
public string TranslationEngineKey { get; set; }
}

View File

@@ -2,16 +2,14 @@ using DeepL;
using FictionArchive.Common.Extensions; using FictionArchive.Common.Extensions;
using FictionArchive.Service.Shared; using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.Shared.MassTransit;
using FictionArchive.Service.Shared.Services.GraphQL; using FictionArchive.Service.Shared.Services.GraphQL;
using FictionArchive.Service.TranslationService.GraphQL; using FictionArchive.Service.TranslationService.GraphQL;
using FictionArchive.Service.TranslationService.Models.IntegrationEvents;
using FictionArchive.Service.TranslationService.Services; using FictionArchive.Service.TranslationService.Services;
using FictionArchive.Service.TranslationService.Services.Database; using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.EventHandlers; using FictionArchive.Service.TranslationService.Services.EventHandlers;
using FictionArchive.Service.TranslationService.Services.TranslationEngines; using FictionArchive.Service.TranslationService.Services.TranslationEngines;
using FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate; using FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate;
using RabbitMQ.Client;
namespace FictionArchive.Service.TranslationService; namespace FictionArchive.Service.TranslationService;
@@ -26,15 +24,16 @@ public class Program
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
#region Event Bus #region MassTransit
if (!isSchemaExport) if (!isSchemaExport)
{ {
builder.Services.AddRabbitMQ(opt => builder.Services.AddFictionArchiveMassTransit<TranslationServiceDbContext>(
builder.Configuration,
x =>
{ {
builder.Configuration.GetSection("RabbitMQ").Bind(opt); x.AddConsumer<TranslateTextCommandConsumer>();
}) });
.Subscribe<TranslationRequestCreatedEvent, TranslationRequestCreatedEventHandler>();
} }
#endregion #endregion

View File

@@ -0,0 +1,40 @@
using FictionArchive.Service.Shared.MassTransit.Contracts.Commands;
using FictionArchive.Service.Shared.MassTransit.Contracts.Events;
using FictionArchive.Service.TranslationService.Models.Enums;
using MassTransit;
namespace FictionArchive.Service.TranslationService.Services.EventHandlers;
public class TranslateTextCommandConsumer : IConsumer<TranslateTextCommand>
{
private readonly ILogger<TranslateTextCommandConsumer> _logger;
private readonly TranslationEngineService _translationEngineService;
public TranslateTextCommandConsumer(
ILogger<TranslateTextCommandConsumer> logger,
TranslationEngineService translationEngineService)
{
_logger = logger;
_translationEngineService = translationEngineService;
}
public async Task Consume(ConsumeContext<TranslateTextCommand> context)
{
var command = context.Message;
var result = await _translationEngineService.Translate(
command.From,
command.To,
command.Body,
command.TranslationEngineKey);
if (result.Status == TranslationRequestStatus.Success)
{
await context.Publish(new TranslationCompletedEvent
{
TranslatedText = result.TranslatedText!,
TranslationRequestId = command.TranslationRequestId,
});
}
}
}

View File

@@ -1,31 +0,0 @@
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.TranslationService.Models.Enums;
using FictionArchive.Service.TranslationService.Models.IntegrationEvents;
namespace FictionArchive.Service.TranslationService.Services.EventHandlers;
public class TranslationRequestCreatedEventHandler : IIntegrationEventHandler<TranslationRequestCreatedEvent>
{
private readonly ILogger<TranslationRequestCreatedEventHandler> _logger;
private readonly TranslationEngineService _translationEngineService;
private readonly IEventBus _eventBus;
public TranslationRequestCreatedEventHandler(ILogger<TranslationRequestCreatedEventHandler> logger, TranslationEngineService translationEngineService)
{
_logger = logger;
_translationEngineService = translationEngineService;
}
public async Task Handle(TranslationRequestCreatedEvent @event)
{
var result = await _translationEngineService.Translate(@event.From, @event.To, @event.Body, @event.TranslationEngineKey);
if (result.Status == TranslationRequestStatus.Success)
{
await _eventBus.Publish(new TranslationRequestCompletedEvent()
{
TranslatedText = result.TranslatedText,
TranslationRequestId = @event.TranslationRequestId,
});
}
}
}

View File

@@ -1,28 +1,21 @@
using System.Text;
using FictionArchive.Common.Enums; using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.TranslationService.Models; using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Database; using FictionArchive.Service.TranslationService.Models.Database;
using FictionArchive.Service.TranslationService.Models.Enums; using FictionArchive.Service.TranslationService.Models.Enums;
using FictionArchive.Service.TranslationService.Models.IntegrationEvents;
using FictionArchive.Service.TranslationService.Services.Database; using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines; using FictionArchive.Service.TranslationService.Services.TranslationEngines;
using RabbitMQ.Client;
namespace FictionArchive.Service.TranslationService.Services; namespace FictionArchive.Service.TranslationService.Services;
public class TranslationEngineService public class TranslationEngineService
{ {
private readonly IEnumerable<ITranslationEngine> _translationEngines; private readonly IEnumerable<ITranslationEngine> _translationEngines;
private readonly IEventBus _eventBus;
private readonly TranslationServiceDbContext _dbContext; private readonly TranslationServiceDbContext _dbContext;
public TranslationEngineService(IEnumerable<ITranslationEngine> translationEngines, TranslationServiceDbContext dbContext, IEventBus eventBus) public TranslationEngineService(IEnumerable<ITranslationEngine> translationEngines, TranslationServiceDbContext dbContext)
{ {
_translationEngines = translationEngines; _translationEngines = translationEngines;
_dbContext = dbContext; _dbContext = dbContext;
_eventBus = eventBus;
} }
public async Task<TranslationResult> Translate(Language from, Language to, string text, string translationEngineKey) public async Task<TranslationResult> Translate(Language from, Language to, string text, string translationEngineKey)

View File

@@ -12,8 +12,10 @@
"DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres" "DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres"
}, },
"RabbitMQ": { "RabbitMQ": {
"ConnectionString": "amqp://localhost", "Host": "localhost",
"ClientIdentifier": "TranslationService" "VirtualHost": "/",
"Username": "guest",
"Password": "guest"
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"OIDC": { "OIDC": {

View File

@@ -1,13 +0,0 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
public class ChapterCreatedEvent : IIntegrationEvent
{
public required uint ChapterId { get; init; }
public required uint NovelId { get; init; }
public required uint VolumeId { get; init; }
public required int VolumeOrder { get; init; }
public required uint ChapterOrder { get; init; }
public required string ChapterTitle { get; init; }
}

View File

@@ -1,13 +0,0 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
public class NovelCreatedEvent : IIntegrationEvent
{
public required uint NovelId { get; init; }
public required string Title { get; init; }
public required Language OriginalLanguage { get; init; }
public required string Source { get; init; }
public required string AuthorName { get; init; }
}

View File

@@ -1,15 +0,0 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
public class UserInvitedEvent : IIntegrationEvent
{
public Guid InvitedUserId { get; set; }
public required string InvitedUsername { get; set; }
public required string InvitedEmail { get; set; }
public required string InvitedOAuthProviderId { get; set; }
public Guid InviterId { get; set; }
public required string InviterUsername { get; set; }
public required string InviterOAuthProviderId { get; set; }
}

View File

@@ -1,9 +1,8 @@
using FictionArchive.Common.Extensions; using FictionArchive.Common.Extensions;
using FictionArchive.Service.Shared; using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.Shared.MassTransit;
using FictionArchive.Service.UserNovelDataService.GraphQL; using FictionArchive.Service.UserNovelDataService.GraphQL;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
using FictionArchive.Service.UserNovelDataService.Services; using FictionArchive.Service.UserNovelDataService.Services;
using FictionArchive.Service.UserNovelDataService.Services.EventHandlers; using FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
@@ -22,17 +21,18 @@ public class Program
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
#region Event Bus #region MassTransit
if (!isSchemaExport) if (!isSchemaExport)
{ {
builder.Services.AddRabbitMQ(opt => builder.Services.AddFictionArchiveMassTransit<UserNovelDataServiceDbContext>(
builder.Configuration,
x =>
{ {
builder.Configuration.GetSection("RabbitMQ").Bind(opt); x.AddConsumer<NovelCreatedEventConsumer>();
}) x.AddConsumer<ChapterCreatedEventConsumer>();
.Subscribe<NovelCreatedEvent, NovelCreatedEventHandler>() x.AddConsumer<UserInvitedEventConsumer>();
.Subscribe<ChapterCreatedEvent, ChapterCreatedEventHandler>() });
.Subscribe<UserInvitedEvent, UserInvitedEventHandler>();
} }
#endregion #endregion

View File

@@ -1,25 +1,27 @@
using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.Shared.MassTransit.Contracts.Events;
using FictionArchive.Service.UserNovelDataService.Models.Database; using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; using MassTransit;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers; namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
public class ChapterCreatedEventHandler : IIntegrationEventHandler<ChapterCreatedEvent> public class ChapterCreatedEventConsumer : IConsumer<ChapterCreatedEvent>
{ {
private readonly UserNovelDataServiceDbContext _dbContext; private readonly UserNovelDataServiceDbContext _dbContext;
private readonly ILogger<ChapterCreatedEventHandler> _logger; private readonly ILogger<ChapterCreatedEventConsumer> _logger;
public ChapterCreatedEventHandler( public ChapterCreatedEventConsumer(
UserNovelDataServiceDbContext dbContext, UserNovelDataServiceDbContext dbContext,
ILogger<ChapterCreatedEventHandler> logger) ILogger<ChapterCreatedEventConsumer> logger)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_logger = logger; _logger = logger;
} }
public async Task Handle(ChapterCreatedEvent @event) public async Task Consume(ConsumeContext<ChapterCreatedEvent> context)
{ {
var @event = context.Message;
// Ensure novel exists // Ensure novel exists
var novelExists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId); var novelExists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId);
if (!novelExists) if (!novelExists)

View File

@@ -1,25 +1,27 @@
using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.Shared.MassTransit.Contracts.Events;
using FictionArchive.Service.UserNovelDataService.Models.Database; using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; using MassTransit;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers; namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
public class NovelCreatedEventHandler : IIntegrationEventHandler<NovelCreatedEvent> public class NovelCreatedEventConsumer : IConsumer<NovelCreatedEvent>
{ {
private readonly UserNovelDataServiceDbContext _dbContext; private readonly UserNovelDataServiceDbContext _dbContext;
private readonly ILogger<NovelCreatedEventHandler> _logger; private readonly ILogger<NovelCreatedEventConsumer> _logger;
public NovelCreatedEventHandler( public NovelCreatedEventConsumer(
UserNovelDataServiceDbContext dbContext, UserNovelDataServiceDbContext dbContext,
ILogger<NovelCreatedEventHandler> logger) ILogger<NovelCreatedEventConsumer> logger)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_logger = logger; _logger = logger;
} }
public async Task Handle(NovelCreatedEvent @event) public async Task Consume(ConsumeContext<NovelCreatedEvent> context)
{ {
var @event = context.Message;
var exists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId); var exists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId);
if (exists) if (exists)
{ {

View File

@@ -1,25 +1,27 @@
using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.Shared.MassTransit.Contracts.Events;
using FictionArchive.Service.UserNovelDataService.Models.Database; using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; using MassTransit;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers; namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
public class UserInvitedEventHandler : IIntegrationEventHandler<UserInvitedEvent> public class UserInvitedEventConsumer : IConsumer<UserInvitedEvent>
{ {
private readonly UserNovelDataServiceDbContext _dbContext; private readonly UserNovelDataServiceDbContext _dbContext;
private readonly ILogger<UserInvitedEventHandler> _logger; private readonly ILogger<UserInvitedEventConsumer> _logger;
public UserInvitedEventHandler( public UserInvitedEventConsumer(
UserNovelDataServiceDbContext dbContext, UserNovelDataServiceDbContext dbContext,
ILogger<UserInvitedEventHandler> logger) ILogger<UserInvitedEventConsumer> logger)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_logger = logger; _logger = logger;
} }
public async Task Handle(UserInvitedEvent @event) public async Task Consume(ConsumeContext<UserInvitedEvent> context)
{ {
var @event = context.Message;
var exists = await _dbContext.Users.AnyAsync(u => u.Id == @event.InvitedUserId); var exists = await _dbContext.Users.AnyAsync(u => u.Id == @event.InvitedUserId);
if (exists) if (exists)
{ {

View File

@@ -9,8 +9,10 @@
"DefaultConnection": "Host=localhost;Database=FictionArchive_UserNovelDataService;Username=postgres;password=postgres" "DefaultConnection": "Host=localhost;Database=FictionArchive_UserNovelDataService;Username=postgres;password=postgres"
}, },
"RabbitMQ": { "RabbitMQ": {
"ConnectionString": "amqp://localhost", "Host": "localhost",
"ClientIdentifier": "UserNovelDataService" "VirtualHost": "/",
"Username": "guest",
"Password": "guest"
}, },
"OIDC": { "OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",

View File

@@ -1,9 +1,9 @@
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.UserService.Models.Database; using FictionArchive.Service.UserService.Models.Database;
using FictionArchive.Service.UserService.Services; using FictionArchive.Service.UserService.Services;
using FictionArchive.Service.UserService.Services.AuthenticationClient; using FictionArchive.Service.UserService.Services.AuthenticationClient;
using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
using FluentAssertions; using FluentAssertions;
using MassTransit;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute; using NSubstitute;
@@ -27,13 +27,13 @@ public class UserManagementServiceTests
private static UserManagementService CreateService( private static UserManagementService CreateService(
UserServiceDbContext dbContext, UserServiceDbContext dbContext,
IAuthenticationServiceClient authClient, IAuthenticationServiceClient authClient,
IEventBus? eventBus = null) IPublishEndpoint? publishEndpoint = null)
{ {
return new UserManagementService( return new UserManagementService(
dbContext, dbContext,
NullLogger<UserManagementService>.Instance, NullLogger<UserManagementService>.Instance,
authClient, authClient,
eventBus ?? Substitute.For<IEventBus>()); publishEndpoint ?? Substitute.For<IPublishEndpoint>());
} }
private static User CreateTestUser(string username, string email, int availableInvites = 5) private static User CreateTestUser(string username, string email, int availableInvites = 5)

View File

@@ -1,17 +0,0 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.UserService.Models.IntegrationEvents;
public class UserInvitedEvent : IIntegrationEvent
{
// Invited user info
public Guid InvitedUserId { get; set; }
public required string InvitedUsername { get; set; }
public required string InvitedEmail { get; set; }
public required string InvitedOAuthProviderId { get; set; }
// Inviter info
public Guid InviterId { get; set; }
public required string InviterUsername { get; set; }
public required string InviterOAuthProviderId { get; set; }
}

View File

@@ -2,7 +2,7 @@ using System.Net.Http.Headers;
using FictionArchive.Common.Extensions; using FictionArchive.Common.Extensions;
using FictionArchive.Service.Shared; using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.Shared.MassTransit;
using FictionArchive.Service.UserService.GraphQL; using FictionArchive.Service.UserService.GraphQL;
using FictionArchive.Service.UserService.Services; using FictionArchive.Service.UserService.Services;
using FictionArchive.Service.UserService.Services.AuthenticationClient; using FictionArchive.Service.UserService.Services.AuthenticationClient;
@@ -19,14 +19,11 @@ public class Program
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.AddLocalAppsettings(); builder.AddLocalAppsettings();
#region Event Bus #region MassTransit
if (!isSchemaExport) if (!isSchemaExport)
{ {
builder.Services.AddRabbitMQ(opt => builder.Services.AddFictionArchiveMassTransit<UserServiceDbContext>(builder.Configuration);
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
});
} }
#endregion #endregion

View File

@@ -1,7 +1,7 @@
using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.Shared.MassTransit.Contracts.Events;
using FictionArchive.Service.UserService.Models.Database; using FictionArchive.Service.UserService.Models.Database;
using FictionArchive.Service.UserService.Models.IntegrationEvents;
using FictionArchive.Service.UserService.Services.AuthenticationClient; using FictionArchive.Service.UserService.Services.AuthenticationClient;
using MassTransit;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserService.Services; namespace FictionArchive.Service.UserService.Services;
@@ -11,18 +11,18 @@ public class UserManagementService
private readonly ILogger<UserManagementService> _logger; private readonly ILogger<UserManagementService> _logger;
private readonly UserServiceDbContext _dbContext; private readonly UserServiceDbContext _dbContext;
private readonly IAuthenticationServiceClient _authClient; private readonly IAuthenticationServiceClient _authClient;
private readonly IEventBus _eventBus; private readonly IPublishEndpoint _publishEndpoint;
public UserManagementService( public UserManagementService(
UserServiceDbContext dbContext, UserServiceDbContext dbContext,
ILogger<UserManagementService> logger, ILogger<UserManagementService> logger,
IAuthenticationServiceClient authClient, IAuthenticationServiceClient authClient,
IEventBus eventBus) IPublishEndpoint publishEndpoint)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_logger = logger; _logger = logger;
_authClient = authClient; _authClient = authClient;
_eventBus = eventBus; _publishEndpoint = publishEndpoint;
} }
/// <summary> /// <summary>
@@ -99,7 +99,7 @@ public class UserManagementService
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
await _eventBus.Publish(new UserInvitedEvent await _publishEndpoint.Publish(new UserInvitedEvent
{ {
InvitedUserId = newUser.Id, InvitedUserId = newUser.Id,
InvitedUsername = newUser.Username, InvitedUsername = newUser.Username,

View File

@@ -9,8 +9,10 @@
"DefaultConnection": "Host=localhost;Database=FictionArchive_UserService;Username=postgres;password=postgres" "DefaultConnection": "Host=localhost;Database=FictionArchive_UserService;Username=postgres;password=postgres"
}, },
"RabbitMQ": { "RabbitMQ": {
"ConnectionString": "amqp://localhost", "Host": "localhost",
"ClientIdentifier": "UserService" "VirtualHost": "/",
"Username": "guest",
"Password": "guest"
}, },
"Authentik": { "Authentik": {
"BaseUrl": "https://auth.orfl.xyz", "BaseUrl": "https://auth.orfl.xyz",

View File

@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.User
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserNovelDataService", "FictionArchive.Service.UserNovelDataService\FictionArchive.Service.UserNovelDataService.csproj", "{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserNovelDataService", "FictionArchive.Service.UserNovelDataService\FictionArchive.Service.UserNovelDataService.csproj", "{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService", "FictionArchive.Service.ReportingService\FictionArchive.Service.ReportingService.csproj", "{24C8871B-1ECA-47A0-92DC-AD155C17EDF0}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -73,5 +75,9 @@ Global
{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Debug|Any CPU.Build.0 = Debug|Any CPU {A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Release|Any CPU.ActiveCfg = Release|Any CPU {A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Release|Any CPU.Build.0 = Release|Any CPU {A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Release|Any CPU.Build.0 = Release|Any CPU
{24C8871B-1ECA-47A0-92DC-AD155C17EDF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{24C8871B-1ECA-47A0-92DC-AD155C17EDF0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{24C8871B-1ECA-47A0-92DC-AD155C17EDF0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{24C8871B-1ECA-47A0-92DC-AD155C17EDF0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -136,6 +136,22 @@ services:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
reporting-service:
build:
context: .
dockerfile: FictionArchive.Service.ReportingService/Dockerfile
networks:
- fictionarchive
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_ReportingService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
file-service: file-service:
image: git.orfl.xyz/conco/fictionarchive-file-service:latest image: git.orfl.xyz/conco/fictionarchive-file-service:latest
networks: networks:

View File

@@ -8,7 +8,6 @@
"name": "fictionarchive-web-astro", "name": "fictionarchive-web-astro",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.1", "@astrojs/node": "^9.5.1",
"@astrojs/svelte": "^7.2.2", "@astrojs/svelte": "^7.2.2",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
@@ -143,24 +142,6 @@
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@astrojs/check": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.6.tgz",
"integrity": "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA==",
"license": "MIT",
"dependencies": {
"@astrojs/language-server": "^2.16.1",
"chokidar": "^4.0.1",
"kleur": "^4.1.5",
"yargs": "^17.7.2"
},
"bin": {
"astro-check": "bin/astro-check.js"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
},
"node_modules/@astrojs/compiler": { "node_modules/@astrojs/compiler": {
"version": "2.13.0", "version": "2.13.0",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz",
@@ -174,47 +155,6 @@
"integrity": "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==", "integrity": "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@astrojs/language-server": {
"version": "2.16.3",
"resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.3.tgz",
"integrity": "sha512-yO5K7RYCMXUfeDlnU6UnmtnoXzpuQc0yhlaCNZ67k1C/MiwwwvMZz+LGa+H35c49w5QBfvtr4w4Zcf5PcH8uYA==",
"license": "MIT",
"dependencies": {
"@astrojs/compiler": "^2.13.0",
"@astrojs/yaml2ts": "^0.2.2",
"@jridgewell/sourcemap-codec": "^1.5.5",
"@volar/kit": "~2.4.27",
"@volar/language-core": "~2.4.27",
"@volar/language-server": "~2.4.27",
"@volar/language-service": "~2.4.27",
"muggle-string": "^0.4.1",
"tinyglobby": "^0.2.15",
"volar-service-css": "0.0.68",
"volar-service-emmet": "0.0.68",
"volar-service-html": "0.0.68",
"volar-service-prettier": "0.0.68",
"volar-service-typescript": "0.0.68",
"volar-service-typescript-twoslash-queries": "0.0.68",
"volar-service-yaml": "0.0.68",
"vscode-html-languageservice": "^5.6.1",
"vscode-uri": "^3.1.0"
},
"bin": {
"astro-ls": "bin/nodeServer.js"
},
"peerDependencies": {
"prettier": "^3.0.0",
"prettier-plugin-astro": ">=0.11.0"
},
"peerDependenciesMeta": {
"prettier": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
}
}
},
"node_modules/@astrojs/markdown-remark": { "node_modules/@astrojs/markdown-remark": {
"version": "6.3.9", "version": "6.3.9",
"resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.9.tgz", "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.9.tgz",
@@ -307,15 +247,6 @@
"node": "18.20.8 || ^20.3.0 || >=22.0.0" "node": "18.20.8 || ^20.3.0 || >=22.0.0"
} }
}, },
"node_modules/@astrojs/yaml2ts": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.2.tgz",
"integrity": "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==",
"license": "MIT",
"dependencies": {
"yaml": "^2.5.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -735,61 +666,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@emmetio/abbreviation": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
"integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
"license": "MIT",
"dependencies": {
"@emmetio/scanner": "^1.0.4"
}
},
"node_modules/@emmetio/css-abbreviation": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
"integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
"license": "MIT",
"dependencies": {
"@emmetio/scanner": "^1.0.4"
}
},
"node_modules/@emmetio/css-parser": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz",
"integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==",
"license": "MIT",
"dependencies": {
"@emmetio/stream-reader": "^2.2.0",
"@emmetio/stream-reader-utils": "^0.1.0"
}
},
"node_modules/@emmetio/html-matcher": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz",
"integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==",
"license": "ISC",
"dependencies": {
"@emmetio/scanner": "^1.0.0"
}
},
"node_modules/@emmetio/scanner": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
"integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
"license": "MIT"
},
"node_modules/@emmetio/stream-reader": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz",
"integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==",
"license": "MIT"
},
"node_modules/@emmetio/stream-reader-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz",
"integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==",
"license": "MIT"
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.7.1", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
@@ -4456,96 +4332,6 @@
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
} }
}, },
"node_modules/@volar/kit": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.27.tgz",
"integrity": "sha512-ilZoQDMLzqmSsImJRWx4YiZ4FcvvPrPnFVmL6hSsIWB6Bn3qc7k88J9yP32dagrs5Y8EXIlvvD/mAFaiuEOACQ==",
"license": "MIT",
"dependencies": {
"@volar/language-service": "2.4.27",
"@volar/typescript": "2.4.27",
"typesafe-path": "^0.2.2",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"typescript": "*"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz",
"integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==",
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.27"
}
},
"node_modules/@volar/language-server": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.27.tgz",
"integrity": "sha512-SymGNkErcHg8GjiG65iQN8sLkhqu1pwKhFySmxeBuYq5xFYagKBW36eiNITXQTdvT0tutI1GXcXdq/FdE/IyjA==",
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.27",
"@volar/language-service": "2.4.27",
"@volar/typescript": "2.4.27",
"path-browserify": "^1.0.1",
"request-light": "^0.7.0",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@volar/language-service": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.27.tgz",
"integrity": "sha512-SxKZ8yLhpWa7Y5e/RDxtNfm7j7xsXp/uf2urijXEffRNpPSmVdfzQrFFy5d7l8PNpZy+bHg+yakmqBPjQN+MOw==",
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.27",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz",
"integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==",
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz",
"integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==",
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.27",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vscode/emmet-helper": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
"integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==",
"license": "MIT",
"dependencies": {
"emmet": "^2.4.3",
"jsonc-parser": "^2.3.0",
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.15.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vscode/l10n": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
"license": "MIT"
},
"node_modules/@whatwg-node/disposablestack": { "node_modules/@whatwg-node/disposablestack": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz",
@@ -4743,6 +4529,7 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -5558,6 +5345,7 @@
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"string-width": "^4.2.0", "string-width": "^4.2.0",
@@ -5572,6 +5360,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -5581,12 +5370,14 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cliui/node_modules/is-fullwidth-code-point": { "node_modules/cliui/node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -5596,6 +5387,7 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -5610,6 +5402,7 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -5622,6 +5415,7 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
@@ -5657,6 +5451,7 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -5669,6 +5464,7 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/colorette": { "node_modules/colorette": {
@@ -6323,22 +6119,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emmet": {
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
"integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
"license": "MIT",
"workspaces": [
"./packages/scanner",
"./packages/abbreviation",
"./packages/css-abbreviation",
"./"
],
"dependencies": {
"@emmetio/abbreviation": "^2.3.3",
"@emmetio/css-abbreviation": "^2.1.8"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
@@ -6463,6 +6243,7 @@
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -6875,22 +6656,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.19.1", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -7125,6 +6890,7 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@@ -8379,12 +8145,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonc-parser": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
"license": "MIT"
},
"node_modules/jwt-decode": { "node_modules/jwt-decode": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
@@ -8775,6 +8535,7 @@
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
@@ -9880,12 +9641,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"license": "MIT"
},
"node_modules/mute-stream": { "node_modules/mute-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
@@ -10327,12 +10082,6 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/path-case": { "node_modules/path-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz",
@@ -10568,22 +10317,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prismjs": { "node_modules/prismjs": {
"version": "1.30.0", "version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@@ -10895,16 +10628,11 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/request-light": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz",
"integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==",
"license": "MIT"
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -11993,12 +11721,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/typesafe-path": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
"integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -12013,27 +11735,6 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/typescript-auto-import-cache": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
"integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.8"
}
},
"node_modules/typescript-auto-import-cache/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.48.0", "version": "8.48.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz",
@@ -12618,255 +12319,6 @@
} }
} }
}, },
"node_modules/volar-service-css": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.68.tgz",
"integrity": "sha512-lJSMh6f3QzZ1tdLOZOzovLX0xzAadPhx8EKwraDLPxBndLCYfoTvnNuiFFV8FARrpAlW5C0WkH+TstPaCxr00Q==",
"license": "MIT",
"dependencies": {
"vscode-css-languageservice": "^6.3.0",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-emmet": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.68.tgz",
"integrity": "sha512-nHvixrRQ83EzkQ4G/jFxu9Y4eSsXS/X2cltEPDM+K9qZmIv+Ey1w0tg1+6caSe8TU5Hgw4oSTwNMf/6cQb3LzQ==",
"license": "MIT",
"dependencies": {
"@emmetio/css-parser": "^0.4.1",
"@emmetio/html-matcher": "^1.3.0",
"@vscode/emmet-helper": "^2.9.3",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-html": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.68.tgz",
"integrity": "sha512-fru9gsLJxy33xAltXOh4TEdi312HP80hpuKhpYQD4O5hDnkNPEBdcQkpB+gcX0oK0VxRv1UOzcGQEUzWCVHLfA==",
"license": "MIT",
"dependencies": {
"vscode-html-languageservice": "^5.3.0",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-prettier": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.68.tgz",
"integrity": "sha512-grUmWHkHlebMOd6V8vXs2eNQUw/bJGJMjekh/EPf/p2ZNTK0Uyz7hoBRngcvGfJHMsSXZH8w/dZTForIW/4ihw==",
"license": "MIT",
"dependencies": {
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0",
"prettier": "^2.2 || ^3.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
},
"prettier": {
"optional": true
}
}
},
"node_modules/volar-service-typescript": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.68.tgz",
"integrity": "sha512-z7B/7CnJ0+TWWFp/gh2r5/QwMObHNDiQiv4C9pTBNI2Wxuwymd4bjEORzrJ/hJ5Yd5+OzeYK+nFCKevoGEEeKw==",
"license": "MIT",
"dependencies": {
"path-browserify": "^1.0.1",
"semver": "^7.6.2",
"typescript-auto-import-cache": "^0.3.5",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-nls": "^5.2.0",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-typescript-twoslash-queries": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.68.tgz",
"integrity": "sha512-NugzXcM0iwuZFLCJg47vI93su5YhTIweQuLmZxvz5ZPTaman16JCvmDZexx2rd5T/75SNuvvZmrTOTNYUsfe5w==",
"license": "MIT",
"dependencies": {
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-typescript/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/volar-service-yaml": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.68.tgz",
"integrity": "sha512-84XgE02LV0OvTcwfqhcSwVg4of3MLNUWPMArO6Aj8YXqyEVnPu8xTEMY2btKSq37mVAPuaEVASI4e3ptObmqcA==",
"license": "MIT",
"dependencies": {
"vscode-uri": "^3.0.8",
"yaml-language-server": "~1.19.2"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/vscode-css-languageservice": {
"version": "6.3.9",
"resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.9.tgz",
"integrity": "sha512-1tLWfp+TDM5ZuVWht3jmaY5y7O6aZmpeXLoHl5bv1QtRsRKt4xYGRMmdJa5Pqx/FTkgRbsna9R+Gn2xE+evVuA==",
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-html-languageservice": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.1.tgz",
"integrity": "sha512-5Mrqy5CLfFZUgkyhNZLA1Ye5g12Cb/v6VM7SxUzZUaRKWMDz4md+y26PrfRTSU0/eQAl3XpO9m2og+GGtDMuaA==",
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "^3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-json-languageservice": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz",
"integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==",
"license": "MIT",
"dependencies": {
"jsonc-parser": "^3.0.0",
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.16.0",
"vscode-nls": "^5.0.0",
"vscode-uri": "^3.0.2"
},
"engines": {
"npm": ">=7.0.0"
}
},
"node_modules/vscode-json-languageservice/node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
"bin": {
"installServerIntoExtension": "bin/installServerIntoExtension"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-nls": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
"integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -13123,6 +12575,7 @@
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -13139,6 +12592,7 @@
"version": "2.8.2", "version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"peer": true, "peer": true,
"bin": { "bin": {
@@ -13151,88 +12605,11 @@
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/yaml-language-server": {
"version": "1.19.2",
"resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.19.2.tgz",
"integrity": "sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg==",
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"ajv": "^8.17.1",
"ajv-draft-04": "^1.0.0",
"lodash": "4.17.21",
"prettier": "^3.5.0",
"request-light": "^0.5.7",
"vscode-json-languageservice": "4.1.8",
"vscode-languageserver": "^9.0.0",
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.16.0",
"vscode-uri": "^3.0.2",
"yaml": "2.7.1"
},
"bin": {
"yaml-language-server": "bin/yaml-language-server"
}
},
"node_modules/yaml-language-server/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/yaml-language-server/node_modules/ajv-draft-04": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
"license": "MIT",
"peerDependencies": {
"ajv": "^8.5.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/yaml-language-server/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/yaml-language-server/node_modules/request-light": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
"integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
"license": "MIT"
},
"node_modules/yaml-language-server/node_modules/yaml": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cliui": "^8.0.1", "cliui": "^8.0.1",
@@ -13260,6 +12637,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -13269,12 +12647,14 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/yargs/node_modules/is-fullwidth-code-point": { "node_modules/yargs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -13284,6 +12664,7 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -13298,6 +12679,7 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"

View File

@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"version": "0.0.1", "version": "0.0.1",
"scripts": { "scripts": {
"dev": "node --env-file=.env node_modules/astro/astro.js dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
@@ -12,7 +12,6 @@
"lint:fix": "eslint . --fix" "lint:fix": "eslint . --fix"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.1", "@astrojs/node": "^9.5.1",
"@astrojs/svelte": "^7.2.2", "@astrojs/svelte": "^7.2.2",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",

View File

@@ -75,12 +75,13 @@
if (result.data) { if (result.data) {
readingLists = result.data.readingLists; readingLists = result.data.readingLists;
// Build the set of list IDs that contain this novel // Build the set of list IDs that contain this novel
novelInLists.clear(); const inLists = new SvelteSet<number>();
for (const list of readingLists) { for (const list of readingLists) {
if (list.items.some((item) => item.novelId === novelId)) { if (list.items.some((item) => item.novelId === novelId)) {
novelInLists.add(list.id); inLists.add(list.id);
} }
} }
novelInLists = inLists;
} }
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Failed to load reading lists'; error = e instanceof Error ? e.message : 'Failed to load reading lists';
@@ -91,7 +92,7 @@
async function toggleNovelInList(listId: number) { async function toggleNovelInList(listId: number) {
const isInList = novelInLists.has(listId); const isInList = novelInLists.has(listId);
loadingListIds.add(listId); loadingListIds = new SvelteSet([...loadingListIds, listId]);
try { try {
if (isInList) { if (isInList) {
@@ -114,7 +115,7 @@
if (result.data?.removeFromReadingList?.readingListPayload?.success) { if (result.data?.removeFromReadingList?.readingListPayload?.success) {
// Update local state // Update local state
novelInLists.delete(listId); novelInLists = new SvelteSet([...novelInLists].filter((id) => id !== listId));
// Update item count in list // Update item count in list
readingLists = readingLists.map((list) => readingLists = readingLists.map((list) =>
list.id === listId list.id === listId
@@ -142,7 +143,7 @@
if (result.data?.addToReadingList?.readingListPayload?.success) { if (result.data?.addToReadingList?.readingListPayload?.success) {
// Update local state // Update local state
novelInLists.add(listId); novelInLists = new SvelteSet([...novelInLists, listId]);
// Update item count in list // Update item count in list
readingLists = readingLists.map((list) => readingLists = readingLists.map((list) =>
list.id === listId list.id === listId
@@ -158,7 +159,7 @@
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'An error occurred'; error = e instanceof Error ? e.message : 'An error occurred';
} finally { } finally {
loadingListIds.delete(listId); loadingListIds = new SvelteSet([...loadingListIds].filter((id) => id !== listId));
} }
} }
@@ -217,7 +218,7 @@
items: [{ novelId, order: 0, addedTime: new Date().toISOString() }] items: [{ novelId, order: 0, addedTime: new Date().toISOString() }]
}; };
readingLists = [...readingLists, fullNewList]; readingLists = [...readingLists, fullNewList];
novelInLists.add(newList.id); novelInLists = new SvelteSet([...novelInLists, newList.id]);
// Reset quick-create form // Reset quick-create form
showQuickCreate = false; showQuickCreate = false;

Some files were not shown because too many files have changed in this diff Show More