diff --git a/.vscode/settings.json b/.vscode/settings.json index 3fd491f1c..2767675d4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,13 +15,18 @@ "i18n-ally.sortKeys": true, "i18n-ally.keystyle": "nested", "i18n-ally.tabStyle": "tab", - "eslint.experimental.useFlatConfig": true, + "eslint.useFlatConfig": true, "eslint.workingDirectories": [ - { "directory": "./frontend" } + "./frontend", + "./frontend/viewer" ], "sort-imports.on-save": true, "editor.detectIndentation": false, "eslint.validate": [ + "javascript", + "typescript", + "html", + "json", "svelte" ], "yaml.schemas": { diff --git a/backend/FwHeadless/CrdtSyncService.cs b/backend/FwHeadless/CrdtSyncService.cs new file mode 100644 index 000000000..473227e11 --- /dev/null +++ b/backend/FwHeadless/CrdtSyncService.cs @@ -0,0 +1,27 @@ +using LcmCrdt; +using LcmCrdt.RemoteSync; +using SIL.Harmony; + +namespace FwHeadless; + +public class CrdtSyncService( + CrdtHttpSyncService httpSyncService, + IHttpClientFactory httpClientFactory, + CurrentProjectService currentProjectService, + DataModel dataModel, + ILogger logger) +{ + public async Task Sync() + { + var lexboxRemoteServer = await httpSyncService.CreateProjectSyncable( + currentProjectService.ProjectData, + httpClientFactory.CreateClient(FwHeadlessKernel.LexboxHttpClientName) + ); + var syncResults = await dataModel.SyncWith(lexboxRemoteServer); + if (!syncResults.IsSynced) throw new InvalidOperationException("Sync failed"); + logger.LogInformation( + "Synced with Lexbox, Downloaded changes: {MissingFromLocal}, Uploaded changes: {MissingFromRemote}", + syncResults.MissingFromLocal.Length, + syncResults.MissingFromRemote.Length); + } +} diff --git a/backend/FwHeadless/FwHeadlessKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs index 9a0d42b95..4a7981381 100644 --- a/backend/FwHeadless/FwHeadlessKernel.cs +++ b/backend/FwHeadless/FwHeadlessKernel.cs @@ -1,11 +1,14 @@ using FwDataMiniLcmBridge; +using FwHeadless.Services; using FwLiteProjectSync; using LcmCrdt; +using Microsoft.Extensions.Options; namespace FwHeadless; public static class FwHeadlessKernel { + public const string LexboxHttpClientName = "LexboxHttpClient"; public static void AddFwHeadless(this IServiceCollection services) { services @@ -16,9 +19,18 @@ public static void AddFwHeadless(this IServiceCollection services) .ValidateOnStart(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services .AddLcmCrdtClient() .AddFwDataBridge() .AddFwLiteProjectSync(); + services.AddScoped(); + services.AddTransient(); + services.AddHttpClient(LexboxHttpClientName, + (provider, client) => + { + client.BaseAddress = new Uri(provider.GetRequiredService>().Value.LexboxUrl); + }).AddHttpMessageHandler(); } -}; +} diff --git a/backend/FwHeadless/HttpClientAuthHandler.cs b/backend/FwHeadless/HttpClientAuthHandler.cs new file mode 100644 index 000000000..cc3efa799 --- /dev/null +++ b/backend/FwHeadless/HttpClientAuthHandler.cs @@ -0,0 +1,73 @@ +using System.Net; +using LexCore; +using LexCore.Auth; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace FwHeadless; + +public class HttpClientAuthHandler(IOptions config, IMemoryCache cache, ILogger logger) : DelegatingHandler +{ + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + throw new NotSupportedException("use async apis"); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var lexboxUrl = new Uri(config.Value.LexboxUrl); + if (request.RequestUri?.Authority != lexboxUrl.Authority) + { + return await base.SendAsync(request, cancellationToken); + } + try + { + await SetAuthHeader(request, cancellationToken, lexboxUrl); + } + catch (Exception e) + { + throw new InvalidOperationException("Unable to set auth header", e); + } + return await base.SendAsync(request, cancellationToken); + } + + private async Task SetAuthHeader(HttpRequestMessage request, CancellationToken cancellationToken, Uri lexboxUrl) + { + var cookieContainer = new CookieContainer(); + cookieContainer.Add(new Cookie(LexAuthConstants.AuthCookieName, await GetToken(cancellationToken), null, lexboxUrl.Authority)); + request.Headers.Add("Cookie", cookieContainer.GetCookieHeader(lexboxUrl)); + } + + private async ValueTask GetToken(CancellationToken cancellationToken) + { + try + { + return await cache.GetOrCreateAsync("LexboxAuthToken", + async entry => + { + if (InnerHandler is null) throw new InvalidOperationException("InnerHandler is null"); + logger.LogInformation("Getting auth token"); + var client = new HttpClient(InnerHandler); + client.BaseAddress = new Uri(config.Value.LexboxUrl); + var response = await client.PostAsJsonAsync("/api/login", + new LoginRequest(config.Value.LexboxPassword, config.Value.LexboxUsername), + cancellationToken); + response.EnsureSuccessStatusCode(); + var cookies = response.Headers.GetValues("Set-Cookie"); + var cookieContainer = new CookieContainer(); + cookieContainer.SetCookies(response.RequestMessage!.RequestUri!, cookies.Single()); + var authCookie = cookieContainer.GetAllCookies() + .FirstOrDefault(c => c.Name == LexAuthConstants.AuthCookieName); + if (authCookie is null) throw new InvalidOperationException("Auth cookie not found"); + entry.SetValue(authCookie.Value); + entry.AbsoluteExpiration = authCookie.Expires; + logger.LogInformation("Got auth token: {AuthToken}", authCookie.Value); + return authCookie.Value; + }) ?? throw new NullReferenceException("unable to get the login token"); + } + catch (Exception e) + { + throw new InvalidOperationException("Unable to get auth token", e); + } + } +} diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs index 261dedd9b..e4e897016 100644 --- a/backend/FwHeadless/Program.cs +++ b/backend/FwHeadless/Program.cs @@ -1,7 +1,9 @@ using FwHeadless; using FwDataMiniLcmBridge; +using FwDataMiniLcmBridge.Api; using FwLiteProjectSync; using LcmCrdt; +using LcmCrdt.RemoteSync; using LexData; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.Options; @@ -25,6 +27,14 @@ var app = builder.Build(); +// Add lexbox-version header to all requests +app.Logger.LogInformation("FwHeadless version: {version}", AppVersionService.Version); +app.Use(async (context, next) => +{ + context.Response.Headers["lexbox-version"] = AppVersionService.Version; + await next(); +}); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { @@ -37,11 +47,11 @@ app.MapHealthChecks("/api/healthz"); -app.MapPost("/sync", ExecuteMergeRequest); +app.MapPost("/api/crdt-sync", ExecuteMergeRequest); app.Run(); -static async Task, NotFound>> ExecuteMergeRequest( +static async Task, NotFound, ProblemHttpResult>> ExecuteMergeRequest( ILogger logger, IServiceProvider services, SendReceiveService srService, @@ -50,6 +60,8 @@ ProjectsService projectsService, ProjectLookupService projectLookupService, CrdtFwdataProjectSyncService syncService, + CrdtHttpSyncService crdtHttpSyncService, + IHttpClientFactory httpClientFactory, Guid projectId, bool dryRun = false) { @@ -67,6 +79,11 @@ return TypedResults.NotFound(); } logger.LogInformation("Project code is {projectCode}", projectCode); + //if we can't sync with lexbox fail fast + if (!await crdtHttpSyncService.TestAuth(httpClientFactory.CreateClient(FwHeadlessKernel.LexboxHttpClientName))) + { + return TypedResults.Problem("Unable to authenticate with Lexbox"); + } var projectFolder = Path.Join(config.Value.ProjectStorageRoot, $"{projectCode}-{projectId}"); if (!Directory.Exists(projectFolder)) Directory.CreateDirectory(projectFolder); @@ -77,25 +94,71 @@ logger.LogDebug("crdtFile: {crdtFile}", crdtFile); logger.LogDebug("fwDataFile: {fwDataFile}", fwDataProject.FilePath); + var fwdataApi = await SetupFwData(fwDataProject, srService, projectCode, logger, fwDataFactory); + using var deferCloseFwData = fwDataFactory.DeferClose(fwDataProject); + var crdtProject = await SetupCrdtProject(crdtFile, projectLookupService, projectId, projectsService, projectFolder, fwdataApi.ProjectId, config.Value.LexboxUrl); + + var miniLcmApi = await services.OpenCrdtProject(crdtProject); + var crdtSyncService = services.GetRequiredService(); + await crdtSyncService.Sync(); + + + var result = await syncService.Sync(miniLcmApi, fwdataApi, dryRun); + logger.LogInformation("Sync result, CrdtChanges: {CrdtChanges}, FwdataChanges: {FwdataChanges}", result.CrdtChanges, result.FwdataChanges); + + await crdtSyncService.Sync(); + var srResult2 = await srService.SendReceive(fwDataProject, projectCode); + logger.LogInformation("Send/Receive result after CRDT sync: {srResult2}", srResult2.Output); + return TypedResults.Ok(result); +} + +static async Task SetupFwData(FwDataProject fwDataProject, + SendReceiveService srService, + string projectCode, + ILogger logger, + FwDataFactory fwDataFactory) +{ if (File.Exists(fwDataProject.FilePath)) { - var srResult = srService.SendReceive(fwDataProject, projectCode); + var srResult = await srService.SendReceive(fwDataProject, projectCode); logger.LogInformation("Send/Receive result: {srResult}", srResult.Output); } else { - var srResult = srService.Clone(fwDataProject, projectCode); + var srResult = await srService.Clone(fwDataProject, projectCode); logger.LogInformation("Send/Receive result: {srResult}", srResult.Output); } + var fwdataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, true); - var crdtProject = File.Exists(crdtFile) ? - new CrdtProject("crdt", crdtFile) : - await projectsService.CreateProject(new("crdt", SeedNewProjectData: false, Path: projectFolder, FwProjectId: fwdataApi.ProjectId)); - var miniLcmApi = await services.OpenCrdtProject(crdtProject); - var result = await syncService.Sync(miniLcmApi, fwdataApi, dryRun); - logger.LogInformation("Sync result, CrdtChanges: {CrdtChanges}, FwdataChanges: {FwdataChanges}", result.CrdtChanges, result.FwdataChanges); - var srResult2 = srService.SendReceive(fwDataProject, projectCode); - logger.LogInformation("Send/Receive result after CRDT sync: {srResult2}", srResult2.Output); - return TypedResults.Ok(result); + return fwdataApi; +} + +static async Task SetupCrdtProject(string crdtFile, + ProjectLookupService projectLookupService, + Guid projectId, + ProjectsService projectsService, + string projectFolder, + Guid fwProjectId, + string lexboxUrl) +{ + if (File.Exists(crdtFile)) + { + return new CrdtProject("crdt", crdtFile); + } + else + { + if (await projectLookupService.IsCrdtProject(projectId)) + { + //todo determine what to do in this case, maybe we just download the project? + throw new InvalidOperationException("Project already exists, not sure why it's not on the server"); + } + return await projectsService.CreateProject(new("crdt", + SeedNewProjectData: false, + Id: projectId, + Path: projectFolder, + FwProjectId: fwProjectId, + Domain: new Uri(lexboxUrl))); + } + } diff --git a/backend/FwHeadless/ProjectLookupService.cs b/backend/FwHeadless/ProjectLookupService.cs index 9cb8cb971..ad3c6fa20 100644 --- a/backend/FwHeadless/ProjectLookupService.cs +++ b/backend/FwHeadless/ProjectLookupService.cs @@ -1,5 +1,6 @@ using LexData; using Microsoft.EntityFrameworkCore; +using SIL.Harmony.Core; namespace FwHeadless; @@ -13,4 +14,9 @@ public class ProjectLookupService(LexBoxDbContext dbContext) .FirstOrDefaultAsync(); return projectCode; } + + public async Task IsCrdtProject(Guid projectId) + { + return await dbContext.Set().AnyAsync(c => c.ProjectId == projectId); + } } diff --git a/backend/FwHeadless/SendReceiveHelpers.cs b/backend/FwHeadless/SendReceiveHelpers.cs index 9c8ac9494..68cdeddfa 100644 --- a/backend/FwHeadless/SendReceiveHelpers.cs +++ b/backend/FwHeadless/SendReceiveHelpers.cs @@ -17,11 +17,15 @@ public SendReceiveAuth(FwHeadlessConfig config) : this(config.LexboxUsername, co public record LfMergeBridgeResult(string Output, string ProgressMessages); - private static LfMergeBridgeResult CallLfMergeBridge(string method, IDictionary flexBridgeOptions) + private static async Task CallLfMergeBridge(string method, IDictionary flexBridgeOptions, IProgress? progress = null) { - var progress = new StringBuilderProgress(); - LfMergeBridge.LfMergeBridge.Execute(method, progress, flexBridgeOptions.ToDictionary(), out var lfMergeBridgeOutputForClient); - return new LfMergeBridgeResult(lfMergeBridgeOutputForClient, progress.ToString()); + var sbProgress = new StringBuilderProgress(); + var lfMergeBridgeOutputForClient = await Task.Run(() => + { + LfMergeBridge.LfMergeBridge.Execute(method, progress ?? sbProgress, flexBridgeOptions.ToDictionary(), out var output); + return output; + }); + return new LfMergeBridgeResult(lfMergeBridgeOutputForClient, progress == null ? sbProgress.ToString() : ""); } private static Uri BuildSendReceiveUrl(string baseUrl, string projectCode, SendReceiveAuth? auth) @@ -45,7 +49,7 @@ private static Uri BuildSendReceiveUrl(string baseUrl, string projectCode, SendR return builder.Uri; } - public static LfMergeBridgeResult SendReceive(FwDataProject project, string? projectCode = null, string baseUrl = "http://localhost", SendReceiveAuth? auth = null, string fdoDataModelVersion = "7000072", string? commitMessage = null) + public static async Task SendReceive(FwDataProject project, string? projectCode = null, string baseUrl = "http://localhost", SendReceiveAuth? auth = null, string fdoDataModelVersion = "7000072", string? commitMessage = null, IProgress? progress = null) { projectCode ??= project.Name; var fwdataInfo = new FileInfo(project.FilePath); @@ -65,10 +69,10 @@ public static LfMergeBridgeResult SendReceive(FwDataProject project, string? pro { "user", "LexBox" }, }; if (commitMessage is not null) flexBridgeOptions["commitMessage"] = commitMessage; - return CallLfMergeBridge("Language_Forge_Send_Receive", flexBridgeOptions); + return await CallLfMergeBridge("Language_Forge_Send_Receive", flexBridgeOptions, progress); } - public static LfMergeBridgeResult CloneProject(FwDataProject project, string? projectCode = null, string baseUrl = "http://localhost", SendReceiveAuth? auth = null, string fdoDataModelVersion = "7000072") + public static async Task CloneProject(FwDataProject project, string? projectCode = null, string baseUrl = "http://localhost", SendReceiveAuth? auth = null, string fdoDataModelVersion = "7000072", IProgress? progress = null) { projectCode ??= project.Name; var fwdataInfo = new FileInfo(project.FilePath); @@ -84,6 +88,6 @@ public static LfMergeBridgeResult CloneProject(FwDataProject project, string? pr { "languageDepotRepoUri", repoUrl.ToString() }, { "deleteRepoIfNoSuchBranch", "false" }, }; - return CallLfMergeBridge("Language_Forge_Clone", flexBridgeOptions); + return await CallLfMergeBridge("Language_Forge_Clone", flexBridgeOptions, progress); } } diff --git a/backend/FwHeadless/SendReceiveService.cs b/backend/FwHeadless/SendReceiveService.cs index 37023dc8f..a0508ddc3 100644 --- a/backend/FwHeadless/SendReceiveService.cs +++ b/backend/FwHeadless/SendReceiveService.cs @@ -1,30 +1,33 @@ using FwDataMiniLcmBridge; +using FwHeadless.Services; using Microsoft.Extensions.Options; namespace FwHeadless; -public class SendReceiveService(IOptions config) +public class SendReceiveService(IOptions config, SafeLoggingProgress progress) { - public SendReceiveHelpers.LfMergeBridgeResult SendReceive(FwDataProject project, string? projectCode, string? commitMessage = null) + public async Task SendReceive(FwDataProject project, string? projectCode, string? commitMessage = null) { - return SendReceiveHelpers.SendReceive( + return await SendReceiveHelpers.SendReceive( project: project, projectCode: projectCode, baseUrl: config.Value.HgWebUrl, auth: new SendReceiveHelpers.SendReceiveAuth(config.Value), fdoDataModelVersion: config.Value.FdoDataModelVersion, - commitMessage: commitMessage + commitMessage: commitMessage, + progress: progress ); } - public SendReceiveHelpers.LfMergeBridgeResult Clone(FwDataProject project, string? projectCode) + public async Task Clone(FwDataProject project, string? projectCode) { - return SendReceiveHelpers.CloneProject( + return await SendReceiveHelpers.CloneProject( project: project, projectCode: projectCode, baseUrl: config.Value.HgWebUrl, auth: new SendReceiveHelpers.SendReceiveAuth(config.Value), - fdoDataModelVersion: config.Value.FdoDataModelVersion + fdoDataModelVersion: config.Value.FdoDataModelVersion, + progress: progress ); } } diff --git a/backend/FwHeadless/Services/AppVersionService.cs b/backend/FwHeadless/Services/AppVersionService.cs new file mode 100644 index 000000000..4bc753899 --- /dev/null +++ b/backend/FwHeadless/Services/AppVersionService.cs @@ -0,0 +1,9 @@ +using System.Reflection; + +namespace FwHeadless; + +public static class AppVersionService +{ + public static readonly string Version = typeof(AppVersionService).Assembly + .GetCustomAttribute()?.InformationalVersion ?? "dev"; +} diff --git a/backend/FwHeadless/Services/LogSanitizerService.cs b/backend/FwHeadless/Services/LogSanitizerService.cs new file mode 100644 index 000000000..6d5b00944 --- /dev/null +++ b/backend/FwHeadless/Services/LogSanitizerService.cs @@ -0,0 +1,29 @@ +using System.Text; +using Microsoft.Extensions.Options; + +namespace FwHeadless.Services; + +public class LogSanitizerService +{ + private string Password { get; init; } + private string Base64Password { get; init; } + private string Base64UrlPassword { get; init; } + private bool ShouldSanitize { get; init; } + + public LogSanitizerService(IOptions config) + { + Password = config.Value.LexboxPassword; + Base64Password = Convert.ToBase64String(Encoding.UTF8.GetBytes(Password)); + Base64UrlPassword = Convert.ToBase64String(Encoding.UTF8.GetBytes(Password)).Replace('+', '-').Replace('/', '_').TrimEnd('='); + ShouldSanitize = Password != "pass"; + } + + public string SanitizeLogMessage(string original) + { + if (!ShouldSanitize) return original; + return original + .Replace(Password, "***") + .Replace(Base64Password, "***") + .Replace(Base64UrlPassword, "***"); + } +} diff --git a/backend/FwHeadless/Services/SafeLoggingProgress.cs b/backend/FwHeadless/Services/SafeLoggingProgress.cs new file mode 100644 index 000000000..f492bd307 --- /dev/null +++ b/backend/FwHeadless/Services/SafeLoggingProgress.cs @@ -0,0 +1,53 @@ +using SIL.Progress; + +namespace FwHeadless.Services; + +public class SafeLoggingProgress(ILoggerFactory loggerFactory, LogSanitizerService sanitizer) : IProgress +{ + private readonly ILogger logger = loggerFactory.CreateLogger("SendReceive"); + public bool ShowVerbose { get; set; } + public bool CancelRequested { get; set; } + public bool ErrorEncountered { get; set; } + public IProgressIndicator ProgressIndicator { get; set; } = null!; + public SynchronizationContext SyncContext { get; set; } = null!; + + private string Sanitize(string message, params object[] args) + { + return sanitizer.SanitizeLogMessage(GenericProgress.SafeFormat(message, args)); + } + + public void WriteError(string message, params object[] args) + { + logger.LogError(Sanitize(message, args)); + } + + public void WriteException(Exception error) + { + WriteError(error.ToString()); + } + + public void WriteMessage(string message, params object[] args) + { + logger.LogInformation(Sanitize(message, args)); + } + + public void WriteMessageWithColor(string colorName, string message, params object[] args) + { + WriteMessage(message, args); + } + + public void WriteStatus(string message, params object[] args) + { + WriteMessage(message, args); + } + + public void WriteVerbose(string message, params object[] args) + { + logger.LogDebug(Sanitize(message, args)); + } + + public void WriteWarning(string message, params object[] args) + { + logger.LogWarning(Sanitize(message, args)); + } +} diff --git a/backend/FwHeadless/appsettings.Development.json b/backend/FwHeadless/appsettings.Development.json index 22a96c2a5..9c76f6180 100644 --- a/backend/FwHeadless/appsettings.Development.json +++ b/backend/FwHeadless/appsettings.Development.json @@ -11,8 +11,7 @@ }, "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Default": "Information" } } } diff --git a/backend/FwHeadless/appsettings.json b/backend/FwHeadless/appsettings.json index d56001975..07fe8e9d0 100644 --- a/backend/FwHeadless/appsettings.json +++ b/backend/FwHeadless/appsettings.json @@ -5,7 +5,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Information", + "SendReceive": "Debug" } }, "AllowedHosts": "*" diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index cc80012ba..7aee40533 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -193,25 +193,56 @@ public IAsyncEnumerable GetPartsOfSpeech() .AllInstances() .OrderBy(p => p.Name.BestAnalysisAlternative.Text) .ToAsyncEnumerable() - .Select(partOfSpeech => new PartOfSpeech - { - Id = partOfSpeech.Guid, - Name = FromLcmMultiString(partOfSpeech.Name) - }); + .Select(FromLcmPartOfSpeech); + } + + public Task GetPartOfSpeech(Guid id) + { + return Task.FromResult( + PartOfSpeechRepository + .TryGetObject(id, out var partOfSpeech) + ? FromLcmPartOfSpeech(partOfSpeech) : null); } - public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) + public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) { + IPartOfSpeech? lcmPartOfSpeech = null; if (partOfSpeech.Id == default) partOfSpeech.Id = Guid.NewGuid(); UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Part of Speech", "Remove part of speech", Cache.ServiceLocator.ActionHandler, () => { - var lcmPartOfSpeech = Cache.ServiceLocator.GetInstance() + lcmPartOfSpeech = Cache.ServiceLocator.GetInstance() .Create(partOfSpeech.Id, Cache.LangProject.PartsOfSpeechOA); UpdateLcmMultiString(lcmPartOfSpeech.Name, partOfSpeech.Name); }); + return Task.FromResult(FromLcmPartOfSpeech(lcmPartOfSpeech ?? throw new InvalidOperationException("Part of speech was not created"))); + } + + public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) + { + var lcmPartOfSpeech = PartOfSpeechRepository.GetObject(id); + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Update Part of Speech", + "Revert Part of Speech", + Cache.ServiceLocator.ActionHandler, + () => + { + var updateProxy = new UpdatePartOfSpeechProxy(lcmPartOfSpeech, this); + update.Apply(updateProxy); + }); + return Task.FromResult(FromLcmPartOfSpeech(lcmPartOfSpeech)); + } + + public Task DeletePartOfSpeech(Guid id) + { + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Part of Speech", + "Revert delete", + Cache.ServiceLocator.ActionHandler, + () => + { + PartOfSpeechRepository.GetObject(id).Delete(); + }); return Task.CompletedTask; } @@ -290,6 +321,17 @@ public IAsyncEnumerable GetVariantTypes() .ToAsyncEnumerable(); } + private PartOfSpeech FromLcmPartOfSpeech(IPartOfSpeech lcmPos) + { + return new PartOfSpeech + { + Id = lcmPos.Guid, + Name = FromLcmMultiString(lcmPos.Name), + // TODO: Abreviation = FromLcmMultiString(partOfSpeech.Abreviation), + Predefined = true, // NOTE: the !string.IsNullOrEmpty(lcmPos.CatalogSourceId) check doesn't work if the PoS originated in CRDT + }; + } + private Entry FromLexEntry(ILexEntry entry) { return new Entry @@ -547,20 +589,6 @@ public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent return Task.CompletedTask; } - public Task ReplaceComplexFormComponent(ComplexFormComponent old, ComplexFormComponent @new) - { - UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Replace Complex Form Component", - "Replace Complex Form Component", - Cache.ServiceLocator.ActionHandler, - () => - { - var lexEntry = EntriesRepository.GetObject(old.ComplexFormEntryId); - RemoveComplexFormComponent(lexEntry, old); - AddComplexFormComponent(lexEntry, @new); - }); - return Task.CompletedTask; - } - public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) { UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Add Complex Form Type", @@ -598,9 +626,20 @@ internal void AddComplexFormComponent(ILexEntry lexEntry, ComplexFormComponent c internal void RemoveComplexFormComponent(ILexEntry lexEntry, ComplexFormComponent component) { - ICmObject lexComponent = component.ComponentSenseId is not null - ? SenseRepository.GetObject(component.ComponentSenseId.Value) - : EntriesRepository.GetObject(component.ComponentEntryId); + ICmObject lexComponent; + if (component.ComponentSenseId is not null) + { + //sense has been deleted, so this complex form has been deleted already + if (!SenseRepository.TryGetObject(component.ComponentSenseId.Value, out var sense)) return; + lexComponent = sense; + } + else + { + //entry has been deleted, so this complex form has been deleted already + if (!EntriesRepository.TryGetObject(component.ComponentEntryId, out var entry)) return; + lexComponent = entry; + } + var entryRef = lexEntry.ComplexFormEntryRefs.Single(); if (!entryRef.ComponentLexemesRS.Remove(lexComponent)) { diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdatePartOfSpeechProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdatePartOfSpeechProxy.cs new file mode 100644 index 000000000..7154a5fbc --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdatePartOfSpeechProxy.cs @@ -0,0 +1,23 @@ +using MiniLcm.Models; +using SIL.LCModel; + +namespace FwDataMiniLcmBridge.Api.UpdateProxy; + +public class UpdatePartOfSpeechProxy : PartOfSpeech +{ + private readonly IPartOfSpeech _lcmPartOfSpeech; + private readonly FwDataMiniLcmApi _lexboxLcmApi; + + public UpdatePartOfSpeechProxy(IPartOfSpeech lcmPartOfSpeech, FwDataMiniLcmApi lexboxLcmApi) + { + _lcmPartOfSpeech = lcmPartOfSpeech; + Id = lcmPartOfSpeech.Guid; + _lexboxLcmApi = lexboxLcmApi; + } + + public override MultiString Name + { + get => new UpdateMultiStringProxy(_lcmPartOfSpeech.Name, _lexboxLcmApi); + set => throw new NotImplementedException(); + } +} diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs b/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs index 3490145a8..78fc74092 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs @@ -1,5 +1,6 @@ using FwDataMiniLcmBridge.Api; using FwDataMiniLcmBridge.LcmUtils; +using LexCore.Utils; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -120,7 +121,7 @@ public void CloseCurrentProject() CloseProject(fwDataProject); } - private void CloseProject(FwDataProject project) + public void CloseProject(FwDataProject project) { // if we are shutting down, don't do anything because we want project dispose to be called as part of the shutdown process. if (_shuttingDown) return; @@ -130,4 +131,9 @@ private void CloseProject(FwDataProject project) if (lcmCache is null) return; cache.Remove(cacheKey); } + + public IDisposable DeferClose(FwDataProject project) + { + return Defer.Action(() => CloseProject(project)); + } } diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj index 457f4d84e..2192d7724 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj @@ -28,6 +28,7 @@ + diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs index 66b29d003..0b0443e0d 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs @@ -39,7 +39,7 @@ private SyncFixture(string projectName) _services = crdtServices.CreateAsyncScope(); } - public SyncFixture(): this("sena-3") + public SyncFixture(): this("sena-3_" + Guid.NewGuid().ToString("N")) { } @@ -53,11 +53,12 @@ public async Task InitializeAsync() .NewProject(new FwDataProject(_projectName, projectsFolder), "en", "fr"); FwDataApi = _services.ServiceProvider.GetRequiredService().GetFwDataMiniLcmApi(_projectName, false); - var crdtProjectsFolder = _services.ServiceProvider.GetRequiredService>().Value.ProjectPath; + var crdtProjectsFolder = + _services.ServiceProvider.GetRequiredService>().Value.ProjectPath; if (Path.Exists(crdtProjectsFolder)) Directory.Delete(crdtProjectsFolder, true); Directory.CreateDirectory(crdtProjectsFolder); var crdtProject = await _services.ServiceProvider.GetRequiredService() - .CreateProject(new(_projectName, FwProjectId: FwDataApi.ProjectId)); + .CreateProject(new(_projectName, FwProjectId: FwDataApi.ProjectId, SeedNewProjectData: true)); CrdtApi = (CrdtMiniLcmApi) await _services.ServiceProvider.OpenCrdtProject(crdtProject); } diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs index 793103513..a01130ba3 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs @@ -138,6 +138,82 @@ await crdtApi.CreateEntry(new Entry() .For(e => e.ComplexForms).Exclude(c => c.Id)); } + [Fact] + public async Task PartsOfSpeechSyncBothWays() + { + var crdtApi = _fixture.CrdtApi; + var fwdataApi = _fixture.FwDataApi; + await _syncService.Sync(crdtApi, fwdataApi); + + var noun = new PartOfSpeech() + { + Id = new Guid("a8e41fd3-e343-4c7c-aa05-01ea3dd5cfb5"), + Name = { { "en", "noun" } }, + Predefined = true, + }; + await fwdataApi.CreatePartOfSpeech(noun); + + var verb = new PartOfSpeech() + { + Id = new Guid("86ff66f6-0774-407a-a0dc-3eeaf873daf7"), + Name = { { "en", "verb" } }, + Predefined = true, + }; + await crdtApi.CreatePartOfSpeech(verb); + + await _syncService.Sync(crdtApi, fwdataApi); + + var crdtPartsOfSpeech = await crdtApi.GetPartsOfSpeech().ToArrayAsync(); + var fwdataPartsOfSpeech = await fwdataApi.GetPartsOfSpeech().ToArrayAsync(); + crdtPartsOfSpeech.Should().ContainEquivalentOf(noun); + crdtPartsOfSpeech.Should().ContainEquivalentOf(verb); + fwdataPartsOfSpeech.Should().ContainEquivalentOf(noun); + fwdataPartsOfSpeech.Should().ContainEquivalentOf(verb); + + crdtPartsOfSpeech.Should().BeEquivalentTo(fwdataPartsOfSpeech); + } + + [Fact] + public async Task PartsOfSpeechSyncInEntries() + { + var crdtApi = _fixture.CrdtApi; + var fwdataApi = _fixture.FwDataApi; + await _syncService.Sync(crdtApi, fwdataApi); + + var noun = new PartOfSpeech() + { + Id = new Guid("a8e41fd3-e343-4c7c-aa05-01ea3dd5cfb5"), + Name = { { "en", "noun" } }, + Predefined = true, + }; + await fwdataApi.CreatePartOfSpeech(noun); + // Note we do *not* call crdtApi.CreatePartOfSpeech(noun); + + await fwdataApi.CreateEntry(new Entry() + { + LexemeForm = { { "en", "Pear" } }, + Senses = + [ + new Sense() { Gloss = { { "en", "Pear" } }, PartOfSpeechId = noun.Id } + ] + }); + await crdtApi.CreateEntry(new Entry() + { + LexemeForm = { { "en", "Banana" } }, + Senses = + [ + new Sense() { Gloss = { { "en", "Banana" } }, PartOfSpeechId = noun.Id } + ] + }); + await _syncService.Sync(crdtApi, fwdataApi); + + var crdtEntries = await crdtApi.GetEntries().ToArrayAsync(); + var fwdataEntries = await fwdataApi.GetEntries().ToArrayAsync(); + crdtEntries.Should().BeEquivalentTo(fwdataEntries, + options => options.For(e => e.Components).Exclude(c => c.Id) + .For(e => e.ComplexForms).Exclude(c => c.Id)); + } + [Fact] public async Task UpdatingAnEntryInEachProjectSyncsAcrossBoth() { @@ -166,6 +242,43 @@ public async Task UpdatingAnEntryInEachProjectSyncsAcrossBoth() .For(e => e.ComplexForms).Exclude(c => c.ComponentHeadword)); } + [Fact] + public async Task CanSyncAnyEntryWithDeletedComplexForm() + { + var crdtApi = _fixture.CrdtApi; + var fwdataApi = _fixture.FwDataApi; + await _syncService.Sync(crdtApi, fwdataApi); + await crdtApi.DeleteEntry(_testEntry.Id); + var newEntryId = Guid.NewGuid(); + await fwdataApi.CreateEntry(new Entry() + { + Id = newEntryId, + LexemeForm = { { "en", "pineapple" } }, + Senses = + [ + new Sense + { + Gloss = { { "en", "fruit" } }, + Definition = { { "en", "a citris fruit" } }, + } + ], + Components = + [ + new ComplexFormComponent() + { + ComponentEntryId = _testEntry.Id, + ComponentHeadword = "apple", + ComplexFormEntryId = newEntryId, + ComplexFormHeadword = "pineapple" + } + ] + }); + + //sync may fail because it will try to create a complex form for an entry which was deleted + await _syncService.Sync(crdtApi, fwdataApi); + + } + [Fact] public async Task AddingASenseToAnEntryInEachProjectSyncsAcrossBoth() { diff --git a/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs index 261677a13..a9f13f49c 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs @@ -1,8 +1,6 @@ using FwLiteProjectSync.Tests.Fixtures; using MiniLcm.Models; using MiniLcm.SyncHelpers; -using MiniLcm.Tests.AutoFakerHelpers; -using MiniLcm.Tests.Helpers; using Soenneker.Utils.AutoBogus; using Soenneker.Utils.AutoBogus.Config; @@ -23,7 +21,7 @@ public void EntryDiffShouldUpdateAllFields() var entryDiffToUpdate = EntrySync.EntryDiffToUpdate(before, after); ArgumentNullException.ThrowIfNull(entryDiffToUpdate); entryDiffToUpdate.Apply(before); - before.Should().BeEquivalentTo(after, options => options.ExcludingVersion().Excluding(x => x.Id) + before.Should().BeEquivalentTo(after, options => options.Excluding(x => x.Id) .Excluding(x => x.DeletedAt).Excluding(x => x.Senses) .Excluding(x => x.Components) .Excluding(x => x.ComplexForms) @@ -38,7 +36,7 @@ public async Task SenseDiffShouldUpdateAllFields() var senseDiffToUpdate = await SenseSync.SenseDiffToUpdate(before, after); ArgumentNullException.ThrowIfNull(senseDiffToUpdate); senseDiffToUpdate.Apply(before); - before.Should().BeEquivalentTo(after, options => options.ExcludingVersion().Excluding(x => x.Id).Excluding(x => x.EntryId).Excluding(x => x.DeletedAt).Excluding(x => x.ExampleSentences)); + before.Should().BeEquivalentTo(after, options => options.Excluding(x => x.Id).Excluding(x => x.EntryId).Excluding(x => x.DeletedAt).Excluding(x => x.ExampleSentences)); } [Fact] @@ -49,6 +47,6 @@ public void ExampleSentenceDiffShouldUpdateAllFields() var exampleSentenceDiffToUpdate = ExampleSentenceSync.DiffToUpdate(before, after); ArgumentNullException.ThrowIfNull(exampleSentenceDiffToUpdate); exampleSentenceDiffToUpdate.Apply(before); - before.Should().BeEquivalentTo(after, options => options.ExcludingVersion().Excluding(x => x.Id).Excluding(x => x.SenseId).Excluding(x => x.DeletedAt)); + before.Should().BeEquivalentTo(after, options => options.Excluding(x => x.Id).Excluding(x => x.SenseId).Excluding(x => x.DeletedAt)); } } diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index a85833c68..167c58fe7 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -28,7 +28,7 @@ public async Task Sync(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataA if (!dryRun) { await SaveProjectSnapshot(fwdataApi.Project.Name, fwdataApi.Project.ProjectsPath, - new ProjectSnapshot(await fwdataApi.GetEntries().ToArrayAsync())); + new ProjectSnapshot(await fwdataApi.GetEntries().ToArrayAsync(), await fwdataApi.GetPartsOfSpeech().ToArrayAsync())); } return result; } @@ -50,11 +50,15 @@ private async Task Sync(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi, //todo sync complex form types, parts of speech, semantic domains, writing systems + var currentFwDataPartsOfSpeech = await fwdataApi.GetPartsOfSpeech().ToArrayAsync(); + var crdtChanges = await PartOfSpeechSync.Sync(currentFwDataPartsOfSpeech, projectSnapshot.PartsOfSpeech, crdtApi); + var fwdataChanges = await PartOfSpeechSync.Sync(await crdtApi.GetPartsOfSpeech().ToArrayAsync(), currentFwDataPartsOfSpeech, fwdataApi); + var currentFwDataEntries = await fwdataApi.GetEntries().ToArrayAsync(); - var crdtChanges = await EntrySync.Sync(currentFwDataEntries, projectSnapshot.Entries, crdtApi); + crdtChanges += await EntrySync.Sync(currentFwDataEntries, projectSnapshot.Entries, crdtApi); LogDryRun(crdtApi, "crdt"); - var fwdataChanges = await EntrySync.Sync(await crdtApi.GetEntries().ToArrayAsync(), currentFwDataEntries, fwdataApi); + fwdataChanges += await EntrySync.Sync(await crdtApi.GetEntries().ToArrayAsync(), currentFwDataEntries, fwdataApi); LogDryRun(fwdataApi, "fwdata"); //todo push crdt changes to lexbox @@ -73,7 +77,7 @@ private void LogDryRun(IMiniLcmApi api, string type) logger.LogInformation($"Dry run {type} changes: {dryRunApi.DryRunRecords.Count}"); } - public record ProjectSnapshot(Entry[] Entries); + public record ProjectSnapshot(Entry[] Entries, PartOfSpeech[] PartsOfSpeech); private async Task GetProjectSnapshot(string projectName, string? projectPath) { diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs index 8f39f6a45..031f26a73 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -39,9 +39,25 @@ public IAsyncEnumerable GetPartsOfSpeech() return api.GetPartsOfSpeech(); } - public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) + public Task GetPartOfSpeech(Guid id) + { + return api.GetPartOfSpeech(id); + } + + public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) { DryRunRecords.Add(new DryRunRecord(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Name}")); + return Task.FromResult(partOfSpeech); // Since this is a dry run, api.GetPartOfSpeech would return null + } + public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {id}")); + return GetPartOfSpeech(id)!; + } + + public Task DeletePartOfSpeech(Guid id) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeletePartOfSpeech), $"Delete part of speech {id}")); return Task.CompletedTask; } @@ -148,6 +164,18 @@ public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) return Task.CompletedTask; } + public Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) + { + DryRunRecords.Add(new DryRunRecord(nameof(AddSemanticDomainToSense), $"Add semantic domain {semanticDomain.Name}")); + return Task.CompletedTask; + } + + public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) + { + DryRunRecords.Add(new DryRunRecord(nameof(RemoveSemanticDomainFromSense), $"Remove semantic domain {semanticDomainId}")); + return Task.CompletedTask; + } + public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence) { DryRunRecords.Add(new DryRunRecord(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}")); @@ -186,12 +214,6 @@ public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent return Task.CompletedTask; } - public Task ReplaceComplexFormComponent(ComplexFormComponent old, ComplexFormComponent @new) - { - DryRunRecords.Add(new DryRunRecord(nameof(ReplaceComplexFormComponent), $"Replace complex form component complex entry: {old.ComplexFormHeadword}, component entry: {old.ComponentHeadword} with complex entry: {@new.ComplexFormHeadword}, component entry: {@new.ComponentHeadword}")); - return Task.CompletedTask; - } - public async Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) { DryRunRecords.Add(new DryRunRecord(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId}, to entry {entryId}")); diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/ComplexFormComponentTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/ComplexFormComponentTests.cs new file mode 100644 index 000000000..486146ae1 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/ComplexFormComponentTests.cs @@ -0,0 +1,19 @@ +namespace LcmCrdt.Tests.MiniLcmTests; + +public class ComplexFormComponentTests : ComplexFormComponentTestsBase +{ + private readonly MiniLcmApiFixture _fixture = new(); + + protected override async Task NewApi() + { + await _fixture.InitializeAsync(); + var api = _fixture.Api; + return api; + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _fixture.DisposeAsync(); + } +} diff --git a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs index e53ea52e8..cdaa72ab0 100644 --- a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs @@ -16,7 +16,7 @@ public async Task OpeningAProjectWorks() var services = host.Services; var asyncScope = services.CreateAsyncScope(); await asyncScope.ServiceProvider.GetRequiredService() - .CreateProject(new(Name: "OpeningAProjectWorks", Path: "")); + .CreateProject(new(Name: "OpeningAProjectWorks", Path: "", SeedNewProjectData: true)); var miniLcmApi = (CrdtMiniLcmApi)await asyncScope.ServiceProvider.OpenCrdtProject(new CrdtProject("OpeningAProjectWorks", sqliteConnectionString)); miniLcmApi.ProjectData.Name.Should().Be("OpeningAProjectWorks"); diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 1f04d6d0e..300ea0d9e 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -7,6 +7,7 @@ using LcmCrdt.Objects; using LinqToDB; using LinqToDB.EntityFrameworkCore; +using MiniLcm.Exceptions; using MiniLcm.SyncHelpers; using SIL.Harmony.Db; @@ -76,9 +77,29 @@ public IAsyncEnumerable GetPartsOfSpeech() return PartsOfSpeech.AsAsyncEnumerable(); } - public async Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) + public Task GetPartOfSpeech(Guid id) { - await dataModel.AddChange(ClientId, new CreatePartOfSpeechChange(partOfSpeech.Id, partOfSpeech.Name, false)); + return dataModel.GetLatest(id); + } + + public async Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) + { + await dataModel.AddChange(ClientId, new CreatePartOfSpeechChange(partOfSpeech.Id, partOfSpeech.Name, partOfSpeech.Predefined)); + return await GetPartOfSpeech(partOfSpeech.Id) ?? throw new NullReferenceException(); + } + + public async Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) + { + var pos = await GetPartOfSpeech(id); + if (pos is null) throw new NullReferenceException($"unable to find part of speech with id {id}"); + + await dataModel.AddChanges(ClientId, [..pos.ToChanges(update.Patch)]); + return await GetPartOfSpeech(id) ?? throw new NullReferenceException(); + } + + public async Task DeletePartOfSpeech(Guid id) + { + await dataModel.AddChange(ClientId, new DeleteChange(id)); } public IAsyncEnumerable GetSemanticDomains() @@ -112,7 +133,7 @@ public async Task CreateComplexFormComponent(ComplexFormCo { var addEntryComponentChange = new AddEntryComponentChange(complexFormComponent); await dataModel.AddChange(ClientId, addEntryComponentChange); - return await ComplexFormComponents.SingleAsync(c => c.Id == addEntryComponentChange.EntityId); + return (await ComplexFormComponents.SingleOrDefaultAsync(c => c.Id == addEntryComponentChange.EntityId)) ?? throw NotFoundException.ForType(); } public async Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) @@ -120,30 +141,6 @@ public async Task DeleteComplexFormComponent(ComplexFormComponent complexFormCom await dataModel.AddChange(ClientId, new DeleteChange(complexFormComponent.Id)); } - public async Task ReplaceComplexFormComponent(ComplexFormComponent old, ComplexFormComponent @new) - { - IChange change; - if (old.ComplexFormEntryId != @new.ComplexFormEntryId) - { - change = SetComplexFormComponentChange.NewComplexForm(old.Id, @new.ComplexFormEntryId); - } - else if (old.ComponentEntryId != @new.ComponentEntryId) - { - change = SetComplexFormComponentChange.NewComponent(old.Id, @new.ComponentEntryId); - } - else if (old.ComponentSenseId != @new.ComponentSenseId) - { - change = SetComplexFormComponentChange.NewComponentSense(old.Id, - @new.ComponentEntryId, - @new.ComponentSenseId); - } - else - { - return; - } - await dataModel.AddChange(ClientId, change); - } - public async Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) { await dataModel.AddChange(ClientId, new AddComplexFormTypeChange(entryId, await ComplexFormTypes.SingleAsync(ct => ct.Id == complexFormTypeId))); @@ -298,22 +295,24 @@ async IAsyncEnumerable ToComplexFormComponents(IList s.Id == complexFormComponent.ComponentSenseId.Value)) - { - throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted sense {complexFormComponent.ComponentSenseId} as its component"); - } + //these tests break under sync when the entry was deleted in a CRDT but that's not yet been synced to FW + //todo enable these tests when the api is not syncing but being called normally + // if (complexFormComponent.ComponentEntryId != entry.Id && + // await IsEntryDeleted(complexFormComponent.ComponentEntryId)) + // { + // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted entry {complexFormComponent.ComponentEntryId} as its component"); + // } + // if (complexFormComponent.ComplexFormEntryId != entry.Id && + // await IsEntryDeleted(complexFormComponent.ComplexFormEntryId)) + // { + // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted entry {complexFormComponent.ComplexFormEntryId} as its complex form"); + // } + + // if (complexFormComponent.ComponentSenseId != null && + // !await Senses.AnyAsyncEF(s => s.Id == complexFormComponent.ComponentSenseId.Value)) + // { + // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted sense {complexFormComponent.ComponentSenseId} as its component"); + // } yield return new AddEntryComponentChange(complexFormComponent); } } @@ -334,7 +333,7 @@ async IAsyncEnumerable ToComplexFormTypes(IList IsEntryDeleted(Guid id) { @@ -405,7 +404,7 @@ public async Task DeleteSense(Guid entryId, Guid senseId) await dataModel.AddChange(ClientId, new DeleteChange(senseId)); } - public async Task AddSemanticDomainToSense(Guid senseId, MiniLcm.Models.SemanticDomain semanticDomain) + public async Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) { await dataModel.AddChange(ClientId, new AddSemanticDomainChange(semanticDomain, senseId)); } diff --git a/backend/FwLite/LcmCrdt/LcmCrdt.csproj b/backend/FwLite/LcmCrdt/LcmCrdt.csproj index c61cc2535..c2382ec8a 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdt.csproj +++ b/backend/FwLite/LcmCrdt/LcmCrdt.csproj @@ -18,6 +18,8 @@ + + diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 32d8f5369..9fd13ce3e 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -6,6 +6,7 @@ using LcmCrdt.Changes; using LcmCrdt.Changes.Entries; using LcmCrdt.Objects; +using LcmCrdt.RemoteSync; using LinqToDB; using LinqToDB.AspNet.Logging; using LinqToDB.Data; @@ -14,6 +15,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Refit; using SIL.Harmony.Db; namespace LcmCrdt; @@ -34,6 +37,17 @@ public static IServiceCollection AddLcmCrdtClient(this IServiceCollection servic services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + + services.AddHttpClient(); + services.AddSingleton(provider => new RefitSettings + { + ContentSerializer = new SystemTextJsonContentSerializer(new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = provider.GetRequiredService>().Value + .MakeJsonTypeResolver() + }) + }); + services.AddSingleton(); return services; } @@ -66,14 +80,6 @@ private static void ConfigureDbOptions(IServiceProvider provider, DbContextOptio public static void ConfigureCrdt(CrdtConfig config) { config.EnableProjectedTables = true; - config.BeforeSaveObject = (obj, snapshot) => - { - if (obj is IObjectWithId objWithId) - { - objWithId.SetVersionGuid(snapshot.CommitId); - } - return ValueTask.CompletedTask; - }; config.ObjectTypeListBuilder .CustomAdapter() .Add(builder => @@ -177,14 +183,4 @@ private static async Task LoadMiniLcmApi(IServiceProvider services) await services.GetRequiredService().PopulateProjectDataCache(); return services.GetRequiredService(); } - - public static Guid GetVersionGuid(this IObjectWithId obj) - { - return Guid.Parse(obj.Version ?? throw new NullReferenceException("Version is null")); - } - public static Guid SetVersionGuid(this IObjectWithId obj, Guid version) - { - obj.Version = version.ToString("N"); - return version; - } } diff --git a/backend/FwLite/LcmCrdt/Objects/JsonPatchChangeExtractor.cs b/backend/FwLite/LcmCrdt/Objects/JsonPatchChangeExtractor.cs index 80d845990..08757b4b6 100644 --- a/backend/FwLite/LcmCrdt/Objects/JsonPatchChangeExtractor.cs +++ b/backend/FwLite/LcmCrdt/Objects/JsonPatchChangeExtractor.cs @@ -148,4 +148,10 @@ IChange RewriteComplexFormComponents(IList components, Com if (patch.Operations.Count > 0) yield return new JsonPatchChange(entry.Id, patch); } + + public static IEnumerable ToChanges(this PartOfSpeech pos, JsonPatchDocument patch) + { + if (patch.Operations.Count > 0) + yield return new JsonPatchChange(pos.Id, patch); + } } diff --git a/backend/FwLite/LcmCrdt/ProjectsService.cs b/backend/FwLite/LcmCrdt/ProjectsService.cs index a9cbeb43f..63aba27fa 100644 --- a/backend/FwLite/LcmCrdt/ProjectsService.cs +++ b/backend/FwLite/LcmCrdt/ProjectsService.cs @@ -38,7 +38,7 @@ public record CreateProjectRequest( Guid? Id = null, Uri? Domain = null, Func? AfterCreate = null, - bool SeedNewProjectData = true, + bool SeedNewProjectData = false, string? Path = null, Guid? FwProjectId = null); diff --git a/backend/FwLite/LocalWebApp/CrdtHttpSyncService.cs b/backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs similarity index 68% rename from backend/FwLite/LocalWebApp/CrdtHttpSyncService.cs rename to backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs index 85e06783e..38028bc03 100644 --- a/backend/FwLite/LocalWebApp/CrdtHttpSyncService.cs +++ b/backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs @@ -1,13 +1,11 @@ -using SIL.Harmony.Core; -using SIL.Harmony; -using SIL.Harmony.Db; -using LcmCrdt; -using LocalWebApp.Auth; +using Microsoft.Extensions.Logging; using Refit; +using SIL.Harmony; +using SIL.Harmony.Core; -namespace LocalWebApp; +namespace LcmCrdt.RemoteSync; -public class CrdtHttpSyncService(AuthHelpersFactory authHelpersFactory, ILogger logger, RefitSettings refitSettings) +public class CrdtHttpSyncService(ILogger logger, RefitSettings refitSettings) { //todo replace with a IMemoryCache check private bool? _isHealthy; @@ -22,6 +20,10 @@ public async ValueTask ShouldSync(ISyncHttp syncHttp) { var responseMessage = await syncHttp.HealthCheck(); _isHealthy = responseMessage.IsSuccessStatusCode; + if (!_isHealthy.Value) + { + logger.LogWarning("Health check failed, response status code {StatusCode}", responseMessage.StatusCode); + } _lastHealthCheck = responseMessage.Headers.Date ?? DateTimeOffset.UtcNow; } catch (HttpRequestException e) @@ -42,26 +44,26 @@ public async ValueTask ShouldSync(ISyncHttp syncHttp) return _isHealthy.Value; } - public async ValueTask CreateProjectSyncable(ProjectData project) + /// + /// Creates a Harmony sync client to represent a remote server + /// + /// project data, used to provide the projectId and clientId + /// should have the base url set to the remote server + /// + public async ValueTask CreateProjectSyncable(ProjectData project, HttpClient client) { - if (string.IsNullOrEmpty(project.OriginDomain)) - { - logger.LogWarning("Project {ProjectName} has no origin domain, unable to create http sync client", project.Name); - return NullSyncable.Instance; - } - - var client = await authHelpersFactory.GetHelper(project).CreateClient(); - if (client is null) - { - logger.LogWarning("Unable to create http client to sync project {ProjectName}, user is not authenticated to {OriginDomain}", project.Name, project.OriginDomain); - return NullSyncable.Instance; - } + return new CrdtProjectSync(RestService.For(client, refitSettings), project.Id, project.ClientId, this); + } - return new CrdtProjectSync(RestService.For(client, refitSettings), project.Id, project.ClientId , project.OriginDomain, this); + public async ValueTask TestAuth(HttpClient client) + { + logger.LogInformation("Testing auth, client base url: {ClientBaseUrl}", client.BaseAddress); + var syncable = await CreateProjectSyncable(new ProjectData("test", Guid.Empty, null, Guid.Empty), client); + return await syncable.ShouldSync(); } } -public class CrdtProjectSync(ISyncHttp restSyncClient, Guid projectId, Guid clientId, string originDomain, CrdtHttpSyncService httpSyncService) : ISyncable +internal class CrdtProjectSync(ISyncHttp restSyncClient, Guid projectId, Guid clientId, CrdtHttpSyncService httpSyncService) : ISyncable { public ValueTask ShouldSync() { diff --git a/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs b/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs index 2d704f891..36fa19493 100644 --- a/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs +++ b/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs @@ -38,6 +38,11 @@ public IAsyncEnumerable GetSemanticDomains() return miniLcmApi.GetSemanticDomains(); } + public IAsyncEnumerable GetComplexFormTypes() + { + return miniLcmApi.GetComplexFormTypes(); + } + public virtual IAsyncEnumerable GetEntries(QueryOptions? options = null) { return miniLcmApi.GetEntries(options); diff --git a/backend/FwLite/LocalWebApp/LocalAppKernel.cs b/backend/FwLite/LocalWebApp/LocalAppKernel.cs index b13345e35..f3e300034 100644 --- a/backend/FwLite/LocalWebApp/LocalAppKernel.cs +++ b/backend/FwLite/LocalWebApp/LocalAppKernel.cs @@ -40,16 +40,6 @@ public static IServiceCollection AddLocalAppServices(this IServiceCollection ser { jsonOptions.PayloadSerializerOptions.TypeInfoResolver = crdtConfig.Value.MakeJsonTypeResolver(); }); - services.AddHttpClient(); - services.AddSingleton(provider => new RefitSettings - { - ContentSerializer = new SystemTextJsonContentSerializer(new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = provider.GetRequiredService>().Value - .MakeJsonTypeResolver() - }) - }); - services.AddSingleton(); return services; } diff --git a/backend/FwLite/LocalWebApp/LocalWebApp.csproj b/backend/FwLite/LocalWebApp/LocalWebApp.csproj index 40cfbee76..eedd69c3e 100644 --- a/backend/FwLite/LocalWebApp/LocalWebApp.csproj +++ b/backend/FwLite/LocalWebApp/LocalWebApp.csproj @@ -24,8 +24,6 @@ - - diff --git a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs index 1ad474bf1..1d945394c 100644 --- a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs @@ -69,7 +69,7 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap return Results.BadRequest("Project already exists"); if (!ProjectName().IsMatch(name)) return Results.BadRequest("Only letters, numbers, '-' and '_' are allowed"); - await projectService.CreateProject(new(name, AfterCreate: AfterCreate)); + await projectService.CreateProject(new(name, AfterCreate: AfterCreate, SeedNewProjectData: true)); return TypedResults.Ok(); }); group.MapPost($"/upload/crdt/{{serverAuthority}}/{{{CrdtMiniLcmApiHub.ProjectRouteKey}}}", diff --git a/backend/FwLite/LocalWebApp/SyncService.cs b/backend/FwLite/LocalWebApp/SyncService.cs index ac8925a00..7c4be59f6 100644 --- a/backend/FwLite/LocalWebApp/SyncService.cs +++ b/backend/FwLite/LocalWebApp/SyncService.cs @@ -1,5 +1,6 @@ using SIL.Harmony; using LcmCrdt; +using LcmCrdt.RemoteSync; using LocalWebApp.Auth; using LocalWebApp.Services; using MiniLcm; @@ -11,7 +12,7 @@ namespace LocalWebApp; public class SyncService( DataModel dataModel, CrdtHttpSyncService remoteSyncServiceServer, - AuthHelpersFactory factory, + AuthHelpersFactory authHelpersFactory, CurrentProjectService currentProjectService, ChangeEventBus changeEventBus, IMiniLcmApi lexboxApi, @@ -19,7 +20,24 @@ public class SyncService( { public async Task ExecuteSync() { - var remoteModel = await remoteSyncServiceServer.CreateProjectSyncable(await currentProjectService.GetProjectData()); + var project = await currentProjectService.GetProjectData(); + if (string.IsNullOrEmpty(project.OriginDomain)) + { + logger.LogWarning("Project {ProjectName} has no origin domain, unable to create http sync client", + project.Name); + return new SyncResults([], [], false); + } + + var httpClient = await authHelpersFactory.GetHelper(project).CreateClient(); + if (httpClient is null) + { + logger.LogWarning( + "Unable to create http client to sync project {ProjectName}, user is not authenticated to {OriginDomain}", + project.Name, + project.OriginDomain); + return new SyncResults([], [], false); + } + var remoteModel = await remoteSyncServiceServer.CreateProjectSyncable(project, httpClient); var syncResults = await dataModel.SyncWith(remoteModel); //need to await this, otherwise the database connection will be closed before the notifications are sent await SendNotifications(syncResults); diff --git a/backend/FwLite/MiniLcm/Exceptions/NotFoundException.cs b/backend/FwLite/MiniLcm/Exceptions/NotFoundException.cs new file mode 100644 index 000000000..a8d4eb85e --- /dev/null +++ b/backend/FwLite/MiniLcm/Exceptions/NotFoundException.cs @@ -0,0 +1,16 @@ +using System.Diagnostics.CodeAnalysis; + +namespace MiniLcm.Exceptions; + +public class NotFoundException(string message, string type) : Exception(message) +{ + public static void ThrowIfNull([AllowNull, NotNull] T arg) + { + if (arg is null) + throw ForType(); + } + + public static NotFoundException ForType() => new($"{typeof(T).Name} not found", typeof(T).Name); + + public string Type { get; set; } = type; +} diff --git a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs index 08c260f06..9769f3705 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs @@ -12,6 +12,7 @@ public interface IMiniLcmReadApi IAsyncEnumerable GetEntries(QueryOptions? options = null); IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null); Task GetEntry(Guid id); + Task GetPartOfSpeech(Guid id); } public record QueryOptions( diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index 50616934f..1a2b7cc4a 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -13,7 +13,9 @@ Task UpdateWritingSystem(WritingSystemId id, UpdateObjectInput update); - Task CreatePartOfSpeech(PartOfSpeech partOfSpeech); + Task CreatePartOfSpeech(PartOfSpeech partOfSpeech); + Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update); + Task DeletePartOfSpeech(Guid id); Task CreateSemanticDomain(SemanticDomain semanticDomain); Task CreateComplexFormType(ComplexFormType complexFormType); @@ -25,7 +27,6 @@ Task UpdateWritingSystem(WritingSystemId id, Task DeleteEntry(Guid id); Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent); Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent); - Task ReplaceComplexFormComponent(ComplexFormComponent old, ComplexFormComponent @new); Task AddComplexFormType(Guid entryId, Guid complexFormTypeId); Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId); #endregion diff --git a/backend/FwLite/MiniLcm/InMemoryApi.cs b/backend/FwLite/MiniLcm/InMemoryApi.cs deleted file mode 100644 index c5c7a5a24..000000000 --- a/backend/FwLite/MiniLcm/InMemoryApi.cs +++ /dev/null @@ -1,335 +0,0 @@ -using MiniLcm.Models; - -namespace MiniLcm; - -public class InMemoryApi : IMiniLcmApi -{ - private readonly List _entries = - [ - new Entry - { - Id = Guid.NewGuid(), - LexemeForm = new MultiString - { - Values = - { - { "en", "apple" }, - } - }, - Senses = - [ - new Sense - { - Id = Guid.NewGuid(), - Gloss = new MultiString - { - Values = - { - {"en", "fruit"} - } - }, - Definition = new MultiString - { - Values = - { - { "en", "A red or green fruit that grows on a tree" }, - } - }, - ExampleSentences = - [ - new ExampleSentence - { - Id = Guid.NewGuid(), - Sentence = new MultiString - { - Values = - { - { "en", "The apple fell from the tree." }, - } - } - }, - ], - }, - ], - }, - new Entry - { - Id = Guid.NewGuid(), - LexemeForm = new MultiString - { - Values = - { - { "en", "banana" }, - } - }, - Senses = - [ - new Sense - { - Id = Guid.NewGuid(), - Gloss = new MultiString - { - Values = - { - { "en", "fruit" } - } - }, - Definition = new MultiString - { - Values = - { - { "en", "A yellow fruit that grows on a tree" }, - } - }, - ExampleSentences = - [ - new ExampleSentence - { - Id = Guid.NewGuid(), - Sentence = new MultiString - { - Values = - { - { "en", "The banana fell from the tree." }, - } - } - }, - ], - }, - ], - }, - ]; - - private readonly WritingSystems _writingSystems = new WritingSystems{ - Analysis = - [ - new WritingSystem { Id = Guid.NewGuid(), Type = WritingSystemType.Analysis, WsId = "en", Name = "English", Abbreviation = "en", Font = "Arial" }, - ], - Vernacular = - [ - new WritingSystem { Id = Guid.NewGuid(), Type = WritingSystemType.Vernacular, WsId = "en", Name = "English", Abbreviation = "en", Font = "Arial" }, - ] - }; - - - public Task GetWritingSystems() - { - return Task.FromResult(_writingSystems); - } - - public Task CreateWritingSystem(WritingSystemType type, WritingSystem writingSystem) - { - if (type == WritingSystemType.Analysis) - { - _writingSystems.Analysis = [.._writingSystems.Analysis, writingSystem]; - } - else - { - _writingSystems.Vernacular = [.._writingSystems.Vernacular, writingSystem]; - } - return Task.FromResult(writingSystem); - } - - public Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) - { - var ws = type == WritingSystemType.Analysis - ? _writingSystems.Analysis.Single(w => w.WsId == id) - : _writingSystems.Vernacular.Single(w => w.WsId == id); - if (ws is null) throw new KeyNotFoundException($"unable to find writing system with id {id}"); - update.Apply(ws); - return Task.FromResult(ws); - } - - public IAsyncEnumerable GetPartsOfSpeech() - { - throw new NotImplementedException(); - } - - public IAsyncEnumerable GetSemanticDomains() - { - throw new NotImplementedException(); - } - - public IAsyncEnumerable GetComplexFormTypes() - { - throw new NotImplementedException(); - } - - public Task CreateComplexFormType(ComplexFormType complexFormType) - { - throw new NotImplementedException(); - } - - public Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent) - { - throw new NotImplementedException(); - } - - public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) - { - throw new NotImplementedException(); - } - - public Task ReplaceComplexFormComponent(ComplexFormComponent old, ComplexFormComponent @new) - { - throw new NotImplementedException(); - } - - public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) - { - throw new NotImplementedException(); - } - - public Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) - { - throw new NotImplementedException(); - } - - private readonly string[] _exemplars = Enumerable.Range('a', 'z').Select(c => ((char)c).ToString()).ToArray(); - - public Task CreateEntry(Entry entry) - { - if (entry.Id == default) entry.Id = Guid.NewGuid(); - _entries.Add(entry); - return Task.FromResult(entry); - } - - public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence) - { - if (exampleSentence.Id == default) exampleSentence.Id = Guid.NewGuid(); - var entry = _entries.Single(e => e.Id == entryId); - var sense = entry.Senses.Single(s => s.Id == senseId); - sense.ExampleSentences.Add(exampleSentence); - return Task.FromResult(exampleSentence); - } - - public Task CreateSense(Guid entryId, Sense sense) - { - if (sense.Id == default) sense.Id = Guid.NewGuid(); - var entry = _entries.Single(e => e.Id == entryId); - entry.Senses.Add(sense); - return Task.FromResult(sense); - } - - public async Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) - { - throw new NotImplementedException(); - } - - public async Task CreateSemanticDomain(SemanticDomain semanticDomain) - { - throw new NotImplementedException(); - } - - public Task DeleteEntry(Guid id) - { - _entries.RemoveAll(e => e.Id == id); - return Task.CompletedTask; - } - - public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) - { - var entry = _entries.Single(e => e.Id == entryId); - var sense = entry.Senses.Single(s => s.Id == senseId); - sense.ExampleSentences.RemoveAll(es => es.Id == exampleSentenceId); - return Task.CompletedTask; - } - - public Task DeleteSense(Guid entryId, Guid senseId) - { - var entry = _entries.Single(e => e.Id == entryId); - entry.Senses.RemoveAll(s => s.Id == senseId); - return Task.CompletedTask; - } - - public Task GetEntries(string exemplar, QueryOptions? options = null) - { - var entries = _entries.Where(e => e.LexemeForm.Values["en"].StartsWith(exemplar)).OfType().ToArray(); - return Task.FromResult(entries); - } - - public async IAsyncEnumerable GetEntries(QueryOptions? options = null) - { - foreach (var entry in _entries.OfType()) - { - yield return entry; - } - } - - public Task GetEntry(Guid id) - { - var entry = _entries.SingleOrDefault(e => e.Id == id); - return Task.FromResult(entry as Entry); - } - - public Task GetExemplars() - { - return Task.FromResult(_exemplars); - } - - public async IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null) - { - var entries = _entries.Where(e => e.LexemeForm.Values["en"].Contains(query)) - .OfType().ToArray(); - foreach (var entry in entries) - { - yield return entry; - } - } - - public Task UpdateEntry(Guid id, UpdateObjectInput update) - { - var entry = _entries.Single(e => e.Id == id); - update.Apply(entry); - return Task.FromResult(entry as Entry); - } - - public Task UpdateEntry(Entry before, Entry after) - { - throw new NotImplementedException(); - } - - public Task UpdateExampleSentence(Guid entryId, - Guid senseId, - Guid exampleSentenceId, - UpdateObjectInput update) - { - var entry = _entries.Single(e => e.Id == entryId); - var sense = entry.Senses.Single(s => s.Id == senseId); - var es = sense.ExampleSentences.Single(es => es.Id == exampleSentenceId); - update.Apply(es); - return Task.FromResult(es); - } - - public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) - { - var entry = _entries.Single(e => e.Id == entryId); - var s = entry.Senses.Single(s => s.Id == senseId); - update.Apply(s); - return Task.FromResult(s); - } - - public Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) - { - throw new NotImplementedException(); - } - - public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) - { - throw new NotImplementedException(); - } -} - -internal static class Helpers -{ - public static void RemoveAll(this IList list, Func predicate) - { - for (var i = list.Count - 1; i >= 0; i--) - { - if (predicate(list[i])) - { - list.RemoveAt(i); - } - } - } -} diff --git a/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs b/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs index 97dced8cc..514c9dcb6 100644 --- a/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs +++ b/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs @@ -3,7 +3,8 @@ public class PartOfSpeech : IObjectWithId { public Guid Id { get; set; } - public MultiString Name { get; set; } = new(); + public virtual MultiString Name { get; set; } = new(); + // TODO: Probably need Abbreviation in order to match LCM data model public DateTimeOffset? DeletedAt { get; set; } public string? Version { get; set; } diff --git a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs index 59dc1f506..50fe78892 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs @@ -1,4 +1,5 @@ -using MiniLcm.Models; +using MiniLcm.Exceptions; +using MiniLcm.Models; using SystemTextJsonPatch; namespace MiniLcm.SyncHelpers; @@ -70,7 +71,14 @@ static async (api, afterComponent) => //change id, since we're not using the id as the key for this collection //the id may be the same, which is not what we want here afterComponent.Id = Guid.NewGuid(); - await api.CreateComplexFormComponent(afterComponent); + try + { + await api.CreateComplexFormComponent(afterComponent); + } + catch (NotFoundException) + { + //this can happen if the entry was deleted, so we can just ignore it + } return 1; }, static async (api, beforeComponent) => @@ -78,16 +86,15 @@ static async (api, beforeComponent) => await api.DeleteComplexFormComponent(beforeComponent); return 1; }, - static async (api, beforeComponent, afterComponent) => + static (api, beforeComponent, afterComponent) => { if (beforeComponent.ComplexFormEntryId == afterComponent.ComplexFormEntryId && beforeComponent.ComponentEntryId == afterComponent.ComponentEntryId && beforeComponent.ComponentSenseId == afterComponent.ComponentSenseId) { - return 0; + return Task.FromResult(0); } - await api.ReplaceComplexFormComponent(beforeComponent, afterComponent); - return 1; + throw new InvalidOperationException($"changing complex form components is not supported, they should just be deleted and recreated"); } ); } diff --git a/backend/FwLite/MiniLcm/SyncHelpers/PartOfSpeechSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/PartOfSpeechSync.cs new file mode 100644 index 000000000..a37e88cc8 --- /dev/null +++ b/backend/FwLite/MiniLcm/SyncHelpers/PartOfSpeechSync.cs @@ -0,0 +1,47 @@ +using MiniLcm; +using MiniLcm.Models; +using MiniLcm.SyncHelpers; +using SystemTextJsonPatch; + +public static class PartOfSpeechSync +{ + public static async Task Sync(PartOfSpeech[] currentPartsOfSpeech, + PartOfSpeech[] previousPartsOfSpeech, + IMiniLcmApi api) + { + return await DiffCollection.Diff(api, + previousPartsOfSpeech, + currentPartsOfSpeech, + pos => pos.Id, + async (api, currentPos) => + { + await api.CreatePartOfSpeech(currentPos); + return 1; + }, + async (api, previousPos) => + { + await api.DeletePartOfSpeech(previousPos.Id); + return 1; + }, + async (api, previousPos, currentPos) => + { + var updateObjectInput = PartOfSpeechDiffToUpdate(previousPos, currentPos); + if (updateObjectInput is not null) await api.UpdatePartOfSpeech(currentPos.Id, updateObjectInput); + return updateObjectInput is null ? 0 : 1; + }); + } + + public static UpdateObjectInput? PartOfSpeechDiffToUpdate(PartOfSpeech previousPartOfSpeech, PartOfSpeech currentPartOfSpeech) + { + JsonPatchDocument patchDocument = new(); + patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff(nameof(PartOfSpeech.Name), + previousPartOfSpeech.Name, + currentPartOfSpeech.Name)); + // TODO: Once we add abbreviations to MiniLcm's PartOfSpeech objects, then: + // patchDocument.Operations.AddRange(GetMultiStringDiff(nameof(PartOfSpeech.Abbreviation), + // previousPartOfSpeech.Abbreviation, + // currentPartOfSpeech.Abbreviation)); + if (patchDocument.Operations.Count == 0) return null; + return new UpdateObjectInput(patchDocument); + } +} diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index 4cdd1193d..957c1a028 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -21,7 +21,7 @@ public static class AuthKernel { public const string DefaultScheme = "JwtOrCookie"; public const string JwtOverBasicAuthUsername = "bearer"; - public const string AuthCookieName = ".LexBoxAuth"; + public const string AuthCookieName = LexAuthConstants.AuthCookieName; public static void AddLexBoxAuth(IServiceCollection services, IConfigurationRoot configuration, diff --git a/backend/LexBoxApi/Config/HealthChecksConfig.cs b/backend/LexBoxApi/Config/HealthChecksConfig.cs new file mode 100644 index 000000000..5e6d6cfb6 --- /dev/null +++ b/backend/LexBoxApi/Config/HealthChecksConfig.cs @@ -0,0 +1,7 @@ +namespace LexBoxApi.Config; + +public class HealthChecksConfig +{ + public bool RequireFwHeadlessContainerVersionMatch { get; init; } = true; + public bool RequireHealthyFwHeadlessContainer { get; init; } = true; +} diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/ProjectMembersVisibilityMiddleware.cs b/backend/LexBoxApi/GraphQL/CustomTypes/ProjectMembersVisibilityMiddleware.cs index 2ce468a93..765e174a2 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/ProjectMembersVisibilityMiddleware.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/ProjectMembersVisibilityMiddleware.cs @@ -17,8 +17,9 @@ public async Task InvokeAsync(IMiddlewareContext context, IPermissionService per var projId = contextProject?.Id ?? throw new RequiredException("Must include project ID in query if querying users"); if (!await permissionService.CanViewProjectMembers(projId)) { + var userId = loggedInContext.User.Id; // Confidential project, and user doesn't have permission to see its users, so only show the current user's membership - context.Result = projectUsers.Where(pu => pu.User?.Id == loggedInContext.MaybeUser?.Id).ToList(); + context.Result = projectUsers.Where(pu => userId == pu.UserId || userId == pu.User?.Id).ToList(); } } } diff --git a/backend/LexBoxApi/GraphQL/ErrorLoggingDiagnosticsEventListener.cs b/backend/LexBoxApi/GraphQL/ErrorLoggingDiagnosticsEventListener.cs index 25605668a..2aea08cb0 100644 --- a/backend/LexBoxApi/GraphQL/ErrorLoggingDiagnosticsEventListener.cs +++ b/backend/LexBoxApi/GraphQL/ErrorLoggingDiagnosticsEventListener.cs @@ -7,16 +7,8 @@ namespace LexBoxApi.GraphQL; -public class ErrorLoggingDiagnosticsEventListener : ExecutionDiagnosticEventListener +public class ErrorLoggingDiagnosticsEventListener(ILogger log) : ExecutionDiagnosticEventListener { - private readonly ILogger log; - - public ErrorLoggingDiagnosticsEventListener( - ILogger log) - { - this.log = log; - } - public override void ResolverError( IMiddlewareContext context, IError error) @@ -67,13 +59,17 @@ public override void ValidationErrors(IRequestContext context, IReadOnlyList[] extraTags) { log.LogError(error.Exception, "{Source}: {Message}", source, error.Message); - TraceError(error, source); + TraceError(error, source, extraTags); } private void LogException(Exception exception, [CallerMemberName] string source = "") @@ -82,35 +78,45 @@ private void LogException(Exception exception, [CallerMemberName] string source TraceException(exception, source); } - private void TraceError(IError error, string source) + private void TraceError(IError error, string source, params KeyValuePair[] extraTags) { if (error.Exception != null) { - TraceException(error.Exception, source); + TraceException(error.Exception, source, extraTags); } else { - Activity.Current?.AddEvent(new(source, tags: new() + Activity.Current?.AddEvent(new(source, tags: AddTags(new() { ["error.message"] = error.Message, ["error.code"] = error.Code, - })); + }, extraTags))); } } - private void TraceException(Exception exception, string source) + private void TraceException(Exception exception, string source, params KeyValuePair[] extraTags) { Activity.Current? .SetStatus(ActivityStatusCode.Error) - .AddEvent(new(source, tags: new() + .AddEvent(new(source, tags: AddTags(new() { ["exception.message"] = exception.Message, ["exception.stacktrace"] = exception.StackTrace, ["exception.source"] = exception.Source, - })); + }, extraTags))); if (exception.InnerException != null) { TraceException(exception.InnerException, $"{source} - Inner"); } } + + private static ActivityTagsCollection AddTags(ActivityTagsCollection tags, KeyValuePair[] moreTags) + { + foreach (var kvp in moreTags) + { + tags.Add(kvp.Key, kvp.Value); + } + + return tags; + } } diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs index 442fcd8f3..062869d44 100644 --- a/backend/LexBoxApi/GraphQL/LexQueries.cs +++ b/backend/LexBoxApi/GraphQL/LexQueries.cs @@ -15,6 +15,7 @@ public class LexQueries { [UseProjection] [UseSorting] + [UseFiltering] public IQueryable MyProjects(LoggedInContext loggedInContext, LexBoxDbContext context) { var userId = loggedInContext.User.Id; diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs index 7b420421c..69bbed341 100644 --- a/backend/LexBoxApi/LexBoxKernel.cs +++ b/backend/LexBoxApi/LexBoxKernel.cs @@ -44,6 +44,10 @@ public static void AddLexBoxApi(this IServiceCollection services, .BindConfiguration("Tus") .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .BindConfiguration("HealthChecks") + .ValidateDataAnnotations() + .ValidateOnStart(); services.AddHttpClient(); services.AddHttpContextAccessor(); services.AddMemoryCache(); @@ -57,6 +61,7 @@ public static void AddLexBoxApi(this IServiceCollection services, services.AddScoped(); services.AddHostedService(); services.AddTransient(); + services.AddTransient(); services.AddScoped(); services.AddResiliencePipeline>(IsLanguageForgeProjectDataLoader.ResiliencePolicyName, (builder, context) => { @@ -69,7 +74,9 @@ public static void AddLexBoxApi(this IServiceCollection services, if (environment.IsDevelopment()) services.AddHostedService(); services.AddScheduledTasks(configuration); - services.AddHealthChecks().AddCheck("hgweb", HealthStatus.Unhealthy, ["hg"], TimeSpan.FromSeconds(5)); + services.AddHealthChecks() + .AddCheck("hgweb", HealthStatus.Unhealthy, ["hg"], TimeSpan.FromSeconds(5)) + .AddCheck("fw-headless", HealthStatus.Unhealthy, ["fw-headless"], TimeSpan.FromSeconds(5)); services.AddSyncProxy(); AuthKernel.AddLexBoxAuth(services, configuration, environment); services.AddLexGraphQL(environment); diff --git a/backend/LexBoxApi/Services/FwHeadlessHealthCheck.cs b/backend/LexBoxApi/Services/FwHeadlessHealthCheck.cs new file mode 100644 index 000000000..8ee03fb7b --- /dev/null +++ b/backend/LexBoxApi/Services/FwHeadlessHealthCheck.cs @@ -0,0 +1,37 @@ +using LexBoxApi.Config; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace LexBoxApi.Services; + +public class FwHeadlessHealthCheck(IHttpClientFactory clientFactory, IOptions healthCheckOptions) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, + CancellationToken cancellationToken = new()) + { + var http = clientFactory.CreateClient(); + var fwHeadlessResponse = await http.GetAsync("http://fw-headless/api/healthz"); + if (!fwHeadlessResponse.IsSuccessStatusCode) + { + if (healthCheckOptions.Value.RequireHealthyFwHeadlessContainer) + { + return HealthCheckResult.Unhealthy("fw-headless not repsonding to health check"); + } + else + { + return HealthCheckResult.Degraded("fw-headless not repsonding to health check"); + } + } + var fwHeadlessVersion = fwHeadlessResponse.Headers.GetValues("lexbox-version").FirstOrDefault(); + if (healthCheckOptions.Value.RequireFwHeadlessContainerVersionMatch && string.IsNullOrEmpty(fwHeadlessVersion)) + { + return HealthCheckResult.Degraded("fw-headless version check failed to return a value"); + } + if (healthCheckOptions.Value.RequireFwHeadlessContainerVersionMatch && fwHeadlessVersion != AppVersionService.Version) + { + return HealthCheckResult.Degraded( + $"api version: '{AppVersionService.Version}' fw-headless version: '{fwHeadlessVersion}' mismatch"); + } + return HealthCheckResult.Healthy(); + } +} diff --git a/backend/LexBoxApi/appsettings.Development.json b/backend/LexBoxApi/appsettings.Development.json index fe5d83396..995c9d35a 100644 --- a/backend/LexBoxApi/appsettings.Development.json +++ b/backend/LexBoxApi/appsettings.Development.json @@ -49,6 +49,10 @@ "LfMergeTrustToken": "lf-merge-dev-trust-token", "AutoUpdateLexEntryCountOnSendReceive": true }, + "HealthChecks": { + "RequireFwHeadlessContainerVersionMatch": false, + "RequireHealthyFwHeadlessContainer": false + }, "Authentication": { "Jwt": { "Secret": "d5cf1adc-16e6-4064-8041-4cfa00174210" diff --git a/backend/LexCore/Auth/LexAuthConstants.cs b/backend/LexCore/Auth/LexAuthConstants.cs index c5c4e398a..8150042e9 100644 --- a/backend/LexCore/Auth/LexAuthConstants.cs +++ b/backend/LexCore/Auth/LexAuthConstants.cs @@ -2,6 +2,7 @@ namespace LexCore.Auth; public static class LexAuthConstants { + public const string AuthCookieName = ".LexBoxAuth"; public const string RoleClaimType = "role"; public const string EmailClaimType = "email"; public const string UsernameClaimType = "user"; diff --git a/backend/LfClassicData/LfClassicMiniLcmApi.cs b/backend/LfClassicData/LfClassicMiniLcmApi.cs index fb25363fc..2bc264260 100644 --- a/backend/LfClassicData/LfClassicMiniLcmApi.cs +++ b/backend/LfClassicData/LfClassicMiniLcmApi.cs @@ -90,6 +90,11 @@ public async IAsyncEnumerable GetPartsOfSpeech() } } + public async Task GetPartOfSpeech(Guid id) + { + return await GetPartsOfSpeech().FirstOrDefaultAsync(pos => pos.Id == id); + } + public IAsyncEnumerable GetSemanticDomains() { return AsyncEnumerable.Empty(); diff --git a/backend/Testing/ApiTests/ApiTestBase.cs b/backend/Testing/ApiTests/ApiTestBase.cs index 2b2b317cb..bf5f8cb0b 100644 --- a/backend/Testing/ApiTests/ApiTestBase.cs +++ b/backend/Testing/ApiTests/ApiTestBase.cs @@ -14,6 +14,7 @@ public class ApiTestBase public string BaseUrl => TestingEnvironmentVariables.ServerBaseUrl; private readonly SocketsHttpHandler _httpClientHandler; public readonly HttpClient HttpClient; + public string? CurrJwt { get; private set; } public ApiTestBase() { @@ -48,7 +49,8 @@ public virtual async Task LoginAs(string user, string? password = null) { password ??= TestingEnvironmentVariables.DefaultPassword; var response = await JwtHelper.ExecuteLogin(new SendReceiveAuth(user, password), HttpClient); - return JwtHelper.GetJwtFromLoginResponse(response); + CurrJwt = JwtHelper.GetJwtFromLoginResponse(response); + return CurrJwt; } public void ClearCookies() diff --git a/backend/Testing/ApiTests/GqlMiddlewareTests.cs b/backend/Testing/ApiTests/GqlMiddlewareTests.cs new file mode 100644 index 000000000..7703d2da6 --- /dev/null +++ b/backend/Testing/ApiTests/GqlMiddlewareTests.cs @@ -0,0 +1,74 @@ +using System.Text.Json.Nodes; +using LexCore.Entities; +using Shouldly; +using Testing.Fixtures; +using static Testing.Services.Utils; + +namespace Testing.ApiTests; + +[Trait("Category", "Integration")] +public class GqlMiddlewareTests : IClassFixture +{ + private readonly IntegrationFixture _fixture; + private readonly ApiTestBase _adminApiTester; + + public GqlMiddlewareTests(IntegrationFixture fixture) + { + _fixture = fixture; + _adminApiTester = _fixture.AdminApiTester; + } + + private async Task QueryMyProjectsWithMembers() + { + var json = await _adminApiTester.ExecuteGql( + $$""" + query loadMyProjects { + myProjects(orderBy: [ { name: ASC } ]) { + code + id + name + users { + id + userId + role + } + } + } + """); + return json; + } + + [Fact] + public async Task CanTriggerMultipleInstancesOfMiddlewareThatAccessDbSimultaneously() + { + var config1 = GetNewProjectConfig(); + var config2 = GetNewProjectConfig(); + var config3 = GetNewProjectConfig(); + + var projects = await Task.WhenAll( + RegisterProjectInLexBox(config1, _adminApiTester), + RegisterProjectInLexBox(config2, _adminApiTester), + RegisterProjectInLexBox(config3, _adminApiTester)); + + await using var project1 = projects[0]; + await using var project2 = projects[1]; + await using var project3 = projects[2]; + + await Task.WhenAll( + AddMemberToProject(config1, _adminApiTester, "editor", ProjectRole.Editor), + AddMemberToProject(config2, _adminApiTester, "editor", ProjectRole.Editor), + AddMemberToProject(config3, _adminApiTester, "editor", ProjectRole.Editor)); + + await _adminApiTester.LoginAs("editor"); + // Because we assigned ProjectRole.Editor and these projects are new, + // our middlware will query the project confidentiality from the DB to determine + // if the user is allowed to view all members + var json = await QueryMyProjectsWithMembers(); + + json.ShouldNotBeNull(); + var myProjects = json["data"]!["myProjects"]!.AsArray(); + var ids = myProjects.Select(p => p!["id"]!.GetValue()); + + projects.Select(p => p.id).ShouldBeSubsetOf(ids); + } +} diff --git a/backend/Testing/ApiTests/ResetProjectRaceConditions.cs b/backend/Testing/ApiTests/ResetProjectRaceConditions.cs index 128dfbdc8..358d96790 100644 --- a/backend/Testing/ApiTests/ResetProjectRaceConditions.cs +++ b/backend/Testing/ApiTests/ResetProjectRaceConditions.cs @@ -28,9 +28,9 @@ public async Task SimultaneousResetsDontResultIn404s() var config3 = GetNewProjectConfig(); var projects = await Task.WhenAll( - RegisterProjectInLexBox(config1, _adminApiTester), - RegisterProjectInLexBox(config2, _adminApiTester), - RegisterProjectInLexBox(config3, _adminApiTester) + RegisterProjectInLexBox(config1, _adminApiTester, true), + RegisterProjectInLexBox(config2, _adminApiTester, true), + RegisterProjectInLexBox(config3, _adminApiTester, true) ); await using var project1 = projects[0]; diff --git a/backend/Testing/Services/Utils.cs b/backend/Testing/Services/Utils.cs index 811332365..3c8c46310 100644 --- a/backend/Testing/Services/Utils.cs +++ b/backend/Testing/Services/Utils.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text.RegularExpressions; +using LexCore.Entities; using Quartz.Util; using Shouldly; using Testing.ApiTests; @@ -33,7 +34,8 @@ public static ProjectConfig GetNewProjectConfig(HgProtocol? protocol = null, boo public static async Task RegisterProjectInLexBox( ProjectConfig config, - ApiTestBase apiTester + ApiTestBase apiTester, + bool waitForRepoReady = false ) { await apiTester.ExecuteGql($$""" @@ -62,10 +64,42 @@ ... on DbError { } } """); - await WaitForHgRefreshIntervalAsync(); + if (waitForRepoReady) await WaitForHgRefreshIntervalAsync(); return new LexboxProject(apiTester, config.Id); } + public static async Task AddMemberToProject( + ProjectConfig config, + ApiTestBase apiTester, + string usernameOrEmail, + ProjectRole role + ) + { + await apiTester.ExecuteGql($$""" + mutation { + addProjectMember(input: { + projectId: "{{config.Id}}", + usernameOrEmail: "{{usernameOrEmail}}" + role: {{role.ToString().ToUpper()}} + canInvite: false + }) { + project { + id + } + errors { + __typename + ... on Error { + message + } + ... on InvalidEmailError { + address + } + } + } + } + """); + } + public static void ValidateSendReceiveOutput(string srOutput) { srOutput.ShouldNotContain("abort"); @@ -101,18 +135,20 @@ private static string GetNewProjectDir(string projectCode, public record LexboxProject : IAsyncDisposable { + public readonly Guid id; private readonly ApiTestBase _apiTester; - private readonly Guid _id; + private readonly string _jwt; public LexboxProject(ApiTestBase apiTester, Guid id) { + this.id = id; _apiTester = apiTester; - _id = id; + _jwt = apiTester.CurrJwt ?? throw new InvalidOperationException("No JWT found"); } public async ValueTask DisposeAsync() { - var response = await _apiTester.HttpClient.DeleteAsync($"api/project/{_id}"); + var response = await _apiTester.HttpClient.DeleteAsync($"api/project/{id}?jwt={_jwt}"); response.EnsureSuccessStatusCode(); } } diff --git a/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs b/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs index 8f2363e9a..be5dd69d3 100644 --- a/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs +++ b/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs @@ -57,7 +57,7 @@ public async Task CloneConfidentialProjectAsOrgManager(HgProtocol protocol) { // Create a fresh project var projectConfig = _srFixture.InitLocalFlexProjectWithRepo(protocol, isConfidential: true, LexData.SeedingData.TestOrgId); - await using var project = await RegisterProjectInLexBox(projectConfig, _adminApiTester); + await using var project = await RegisterProjectInLexBox(projectConfig, _adminApiTester, true); // Push the project to the server var sendReceiveParams = new SendReceiveParams(protocol, projectConfig); @@ -97,7 +97,7 @@ public async Task ModifyProjectData(HgProtocol protocol) { // Create a fresh project var projectConfig = _srFixture.InitLocalFlexProjectWithRepo(); - await using var project = await RegisterProjectInLexBox(projectConfig, _adminApiTester); + await using var project = await RegisterProjectInLexBox(projectConfig, _adminApiTester, true); // Push the project to the server var sendReceiveParams = new SendReceiveParams(protocol, projectConfig); @@ -127,7 +127,7 @@ public async Task SendReceiveAfterProjectReset(HgProtocol protocol) { // Create a fresh project var projectConfig = _srFixture.InitLocalFlexProjectWithRepo(protocol, isConfidential: false, null, "SR_AfterReset"); - await using var project = await RegisterProjectInLexBox(projectConfig, _adminApiTester); + await using var project = await RegisterProjectInLexBox(projectConfig, _adminApiTester, true); var sendReceiveParams = new SendReceiveParams(protocol, projectConfig); var srResult = _sendReceiveService.SendReceiveProject(sendReceiveParams, AdminAuth); @@ -194,7 +194,7 @@ public async Task SendNewProject_Medium() private async Task SendNewProject(int totalSizeMb, int fileCount) { var projectConfig = _srFixture.InitLocalFlexProjectWithRepo(); - await using var project = await RegisterProjectInLexBox(projectConfig, _adminApiTester); + await using var project = await RegisterProjectInLexBox(projectConfig, _adminApiTester, true); await WaitForHgRefreshIntervalAsync(); diff --git a/deployment/base/fw-headless-deployment.yaml b/deployment/base/fw-headless-deployment.yaml index f61485e53..efe6175d8 100644 --- a/deployment/base/fw-headless-deployment.yaml +++ b/deployment/base/fw-headless-deployment.yaml @@ -31,10 +31,7 @@ spec: matchLabels: app: fw-headless strategy: - rollingUpdate: - maxSurge: 2 - maxUnavailable: 0 - type: RollingUpdate + type: Recreate template: # https://kubernetes.io/docs/concepts/workloads/pods/#pod-templates metadata: @@ -61,6 +58,7 @@ spec: path: /api/healthz failureThreshold: 30 periodSeconds: 10 + timeoutSeconds: 5 ports: - containerPort: 80 diff --git a/deployment/base/lexbox-deployment.yaml b/deployment/base/lexbox-deployment.yaml index e25403128..15f25a185 100644 --- a/deployment/base/lexbox-deployment.yaml +++ b/deployment/base/lexbox-deployment.yaml @@ -68,6 +68,7 @@ spec: path: /api/healthz failureThreshold: 30 periodSeconds: 10 + timeoutSeconds: 5 ports: - containerPort: 5158 diff --git a/deployment/develop/lexbox-deployment.patch.yaml b/deployment/develop/lexbox-deployment.patch.yaml index 19ce160dd..0e1db473d 100644 --- a/deployment/develop/lexbox-deployment.patch.yaml +++ b/deployment/develop/lexbox-deployment.patch.yaml @@ -26,3 +26,5 @@ spec: value: "https://develop.lexbox.org" - name: HgConfig__RequireContainerVersionMatch value: "false" + - name: HealthChecksConfig__RequireFwHeadlessContainerVersionMatch + value: "false" diff --git a/deployment/local-dev/lexbox-deployment.patch.yaml b/deployment/local-dev/lexbox-deployment.patch.yaml index f6a8fe034..9d2f83656 100644 --- a/deployment/local-dev/lexbox-deployment.patch.yaml +++ b/deployment/local-dev/lexbox-deployment.patch.yaml @@ -36,6 +36,10 @@ spec: valueFrom: - name: CloudFlare__AllowDomain value: "mailinator.com" + - name: HealthChecksConfig__RequireFwHeadlessContainerVersionMatch + value: "false" + - name: HealthChecksConfig__RequireHealthyFwHeadlessContainer + value: "false" - name: Email__SmtpUser value: 'maildev' valueFrom: diff --git a/frontend/package.json b/frontend/package.json index 08848abe8..94a988851 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,14 +46,14 @@ "@types/node": "^20.12.12", "@types/zxcvbn": "^4.4.4", "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", + "@typescript-eslint/parser": "catalog:", "@urql/core": "^5.0.4", "@urql/devtools": "^2.0.3", "@urql/exchange-graphcache": "^7.1.2", "@urql/svelte": "^4.2.1", "autoprefixer": "^10.4.19", "daisyui": "^4.11.1", - "eslint": "^8.57.0", + "eslint": "catalog:", "eslint-plugin-svelte": "^2.39.0", "globals": "^13.24.0", "graphql": "^16.8.1", @@ -73,7 +73,7 @@ "typescript": "catalog:", "vite": "catalog:", "vite-plugin-graphql-codegen": "^3.3.6", - "vitest": "^1.6.0", + "vitest": "catalog:", "zod": "^3.23.8", "zxcvbn": "^4.4.2" }, @@ -96,6 +96,7 @@ "@vitejs/plugin-basic-ssl": "^1.1.0", "css-tree": "^2.3.1", "js-cookie": "^3.0.5", + "just-order-by": "^1.0.0", "mjml": "^4.15.3", "set-cookie-parser": "^2.6.0", "svelte-exmarkdown": "^3.0.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 8b82e5c8b..2628def54 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,6 +6,15 @@ settings: catalogs: default: + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0 + '@vitest/ui': + specifier: ^2.1.4 + version: 2.1.4 + eslint: + specifier: ^8.57.0 + version: 8.57.1 postcss: specifier: ^8.4.47 version: 8.4.47 @@ -30,6 +39,9 @@ catalogs: vite: specifier: ^5.4.9 version: 5.4.9 + vitest: + specifier: ^2.1.4 + version: 2.1.4 importers: @@ -86,6 +98,9 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 + just-order-by: + specifier: ^1.0.0 + version: 1.0.0 mjml: specifier: ^4.15.3 version: 4.15.3 @@ -161,10 +176,10 @@ importers: version: 4.4.4 '@typescript-eslint/eslint-plugin': specifier: ^6.21.0 - version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3) + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3) '@typescript-eslint/parser': - specifier: ^6.21.0 - version: 6.21.0(eslint@8.57.0)(typescript@5.3.3) + specifier: 'catalog:' + version: 6.21.0(eslint@8.57.1)(typescript@5.3.3) '@urql/core': specifier: ^5.0.4 version: 5.0.4(graphql@16.8.1) @@ -184,11 +199,11 @@ importers: specifier: ^4.11.1 version: 4.11.1(postcss@8.4.47) eslint: - specifier: ^8.57.0 - version: 8.57.0 + specifier: 'catalog:' + version: 8.57.1 eslint-plugin-svelte: specifier: ^2.39.0 - version: 2.39.0(eslint@8.57.0)(svelte@4.2.19) + version: 2.39.0(eslint@8.57.1)(svelte@4.2.19) globals: specifier: ^13.24.0 version: 13.24.0 @@ -244,8 +259,8 @@ importers: specifier: ^3.3.6 version: 3.3.6(@graphql-codegen/cli@5.0.2(@types/node@20.12.12)(graphql@16.8.1)(typescript@5.3.3))(graphql@16.8.1)(vite@5.4.9(@types/node@20.12.12)) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@20.12.12) + specifier: 'catalog:' + version: 2.1.4(@types/node@20.12.12)(@vitest/ui@2.1.4)(happy-dom@15.7.4) zod: specifier: ^3.23.8 version: 3.23.8 @@ -313,9 +328,30 @@ importers: '@tailwindcss/typography': specifier: ^0.5.13 version: 0.5.13(tailwindcss@3.4.3) + '@testing-library/jest-dom': + specifier: ^6.6.2 + version: 6.6.2 + '@testing-library/svelte': + specifier: ^5.2.3 + version: 5.2.3(svelte@4.2.19)(vite@5.4.9(@types/node@22.7.3))(vitest@2.1.4(@types/node@22.7.3)(@vitest/ui@2.1.4)(happy-dom@15.7.4)) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@10.4.0) '@tsconfig/svelte': specifier: ^5.0.4 version: 5.0.4 + '@typescript-eslint/parser': + specifier: 'catalog:' + version: 6.21.0(eslint@8.57.1)(typescript@5.3.3) + '@vitest/ui': + specifier: 'catalog:' + version: 2.1.4(vitest@2.1.4) + eslint: + specifier: 'catalog:' + version: 8.57.1 + happy-dom: + specifier: ^15.7.4 + version: 15.7.4 svelte: specifier: 'catalog:' version: 4.2.19 @@ -337,6 +373,9 @@ importers: vite: specifier: 'catalog:' version: 5.4.9(@types/node@22.7.3) + vitest: + specifier: 'catalog:' + version: 2.1.4(@types/node@22.7.3)(@vitest/ui@2.1.4)(happy-dom@15.7.4) packages: @@ -352,6 +391,9 @@ packages: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} + '@adobe/css-tools@4.4.0': + resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -896,6 +938,10 @@ packages: resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint-community/regexpp@4.11.1': + resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint/eslintrc@2.1.4': resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -904,6 +950,10 @@ packages: resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} @@ -1165,8 +1215,8 @@ packages: engines: {node: '>=6'} hasBin: true - '@humanwhocodes/config-array@0.11.14': - resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} deprecated: Use @eslint/config-array instead @@ -1174,8 +1224,8 @@ packages: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/object-schema@2.0.2': - resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead '@iconify-json/mdi@1.1.66': @@ -1203,10 +1253,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -1222,6 +1268,9 @@ packages: '@jridgewell/sourcemap-codec@1.4.15': resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -2089,9 +2138,6 @@ packages: cpu: [x64] os: [win32] - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@sveltejs/adapter-node@4.0.1': resolution: {integrity: sha512-IviiTtKCDp+0QoTmmMlGGZBA1EoUNsjecU6XGV9k62S3f01SNsVhpqi2e4nbI62BLGKh/YKKfFii+Vz/b9XIxg==} peerDependencies: @@ -2131,12 +2177,42 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders' + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.6.2': + resolution: {integrity: sha512-P6GJD4yqc9jZLbe98j/EkyQDTPgqftohZF5FBkHY5BUERZmcf4HeO2k0XaefEg329ux2p21i1A1DmyQ1kKw2Jw==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/svelte@5.2.3': + resolution: {integrity: sha512-y5eaD2Vp3hb729dAv3dOYNoZ9uNM0bQ0rd5AfXDWPvI+HiGmjl8ZMOuKzBopveyAkci1Kplb6kS53uZhPGEK+w==} + engines: {node: '>= 10'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + vite: '*' + vitest: '*' + peerDependenciesMeta: + vite: + optional: true + vitest: + optional: true + + '@testing-library/user-event@14.5.2': + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tsconfig/svelte@5.0.4': resolution: {integrity: sha512-BV9NplVgLmSi4mwKzD8BD/NQ8erOY/nUE/GpgWe2ckx+wIQF5RyRirn/QsSSCPeulVpc3RA/iJt6DpfTIZps0Q==} '@types/accepts@1.3.7': resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/aws-lambda@8.10.122': resolution: {integrity: sha512-vBkIh9AY22kVOCEKo5CJlyCgmSWvasC+SWUxL/x/vOwRobMpI/HG1xp/Ae3AqmSiZeLUbOhW0FCD3ZjqqUxmXw==} @@ -2383,20 +2459,39 @@ packages: peerDependencies: vite: ^3.0.0 || ^4.0.0 || ^5.0.0 - '@vitest/expect@1.6.0': - resolution: {integrity: sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==} + '@vitest/expect@2.1.4': + resolution: {integrity: sha512-DOETT0Oh1avie/D/o2sgMHGrzYUFFo3zqESB2Hn70z6QB1HrS2IQ9z5DfyTqU8sg4Bpu13zZe9V4+UTNQlUeQA==} - '@vitest/runner@1.6.0': - resolution: {integrity: sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==} + '@vitest/mocker@2.1.4': + resolution: {integrity: sha512-Ky/O1Lc0QBbutJdW0rqLeFNbuLEyS+mIPiNdlVlp2/yhJ0SbyYqObS5IHdhferJud8MbbwMnexg4jordE5cCoQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.4': + resolution: {integrity: sha512-L95zIAkEuTDbUX1IsjRl+vyBSLh3PwLLgKpghl37aCK9Jvw0iP+wKwIFhfjdUtA2myLgjrG6VU6JCFLv8q/3Ww==} + + '@vitest/runner@2.1.4': + resolution: {integrity: sha512-sKRautINI9XICAMl2bjxQM8VfCMTB0EbsBc/EDFA57V6UQevEKY/TOPOF5nzcvCALltiLfXWbq4MaAwWx/YxIA==} - '@vitest/snapshot@1.6.0': - resolution: {integrity: sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==} + '@vitest/snapshot@2.1.4': + resolution: {integrity: sha512-3Kab14fn/5QZRog5BPj6Rs8dc4B+mim27XaKWFWHWA87R56AKjHTGcBFKpvZKDzC4u5Wd0w/qKsUIio3KzWW4Q==} - '@vitest/spy@1.6.0': - resolution: {integrity: sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==} + '@vitest/spy@2.1.4': + resolution: {integrity: sha512-4JOxa+UAizJgpZfaCPKK2smq9d8mmjZVPMt2kOsg/R8QkoRzydHH1qHxIYNvr1zlEaFj4SXiaaJWxq/LPLKaLg==} + + '@vitest/ui@2.1.4': + resolution: {integrity: sha512-Zd9e5oU063c+j9N9XzGJagCLNvG71x/2tOme3Js4JEZKX55zsgxhJwUgLI8hkN6NjMLpdJO8d7nVUUuPGAA58Q==} + peerDependencies: + vitest: 2.1.4 - '@vitest/utils@1.6.0': - resolution: {integrity: sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==} + '@vitest/utils@2.1.4': + resolution: {integrity: sha512-MXDnZn0Awl2S86PSNIim5PWXgIAx8CIkzu35mBdSApUip6RFOGXBCf3YFyeEu8n1IHk4bWD46DeYFu9mQlFIRg==} '@whatwg-node/events@0.0.3': resolution: {integrity: sha512-IqnKIDWfXBJkvy/k6tzskWTc2NK3LcqHlb+KHGCrjOCH4jfQckRX0NAiIcC/vIqQkzLYw2r2CTSwAxcrtcD6lA==} @@ -2437,10 +2532,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.2: - resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} - engines: {node: '>=0.4.0'} - acorn@8.11.3: resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} engines: {node: '>=0.4.0'} @@ -2535,8 +2626,9 @@ packages: resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==} engines: {node: '>=12.0.0'} - assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} @@ -2680,14 +2772,18 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@4.4.1: - resolution: {integrity: sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==} - engines: {node: '>=4'} + chai@5.1.2: + resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==} + engines: {node: '>=12'} chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2704,8 +2800,9 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -2890,6 +2987,9 @@ packages: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2963,15 +3063,6 @@ packages: supports-color: optional: true - debug@4.3.5: - resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -2988,8 +3079,8 @@ packages: decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} - deep-eql@4.1.3: - resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} deep-is@0.1.4: @@ -3037,10 +3128,6 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@5.2.0: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} @@ -3056,6 +3143,12 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} @@ -3199,8 +3292,8 @@ packages: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint@8.57.0: - resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true @@ -3249,9 +3342,9 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} + expect-type@1.1.0: + resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} + engines: {node: '>=12.0.0'} extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3318,6 +3411,9 @@ packages: fetch-cookie@2.2.0: resolution: {integrity: sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -3350,8 +3446,8 @@ packages: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true - flatted@3.2.9: - resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -3395,9 +3491,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} - get-intrinsic@1.2.2: resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==} @@ -3405,10 +3498,6 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3489,6 +3578,10 @@ packages: resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + happy-dom@15.7.4: + resolution: {integrity: sha512-r1vadDYGMtsHAAsqhDuk4IpPvr6N8MGKy5ntBo7tSdim+pWDxus2PNqOcOt8LuDZ4t3KJHE+gCuzupcx/GKnyQ==} + engines: {node: '>=18.0.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -3545,10 +3638,6 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -3691,10 +3780,6 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-unc-path@1.0.0: resolution: {integrity: sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==} engines: {node: '>=0.10.0'} @@ -3755,9 +3840,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-tokens@9.0.0: - resolution: {integrity: sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==} - js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -3817,6 +3899,9 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + just-order-by@1.0.0: + resolution: {integrity: sha512-m83kcBMoX43jRLDzR6J7NzIpEEpMmMmh0xwVSMKpXObIFh6ejxpQ02HXc9gCq5cFWHbL5gZ3yRHRGYgMGpoUnA==} + jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -3958,8 +4043,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.1.2: + resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} lower-case-first@2.0.2: resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==} @@ -3981,12 +4066,15 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.10: resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==} - magic-string@0.30.6: - resolution: {integrity: sha512-n62qCLbPjNjyo+owKtveQxZFZTBm+Ms6YoGD23Wew6Vw337PElFNifQpknPruVRQV57kVShPnLGo9vWxVhpPvA==} - engines: {node: '>=12'} + magic-string@0.30.12: + resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} map-cache@0.2.2: resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} @@ -4154,9 +4242,9 @@ packages: resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} engines: {node: '>=8'} - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -4366,10 +4454,6 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} - npm-run-path@5.2.0: - resolution: {integrity: sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -4402,10 +4486,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -4430,10 +4510,6 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-limit@5.0.0: - resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} - engines: {node: '>=18'} - p-locate@3.0.0: resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} engines: {node: '>=6'} @@ -4500,10 +4576,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -4530,8 +4602,9 @@ packages: pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} @@ -4706,9 +4779,9 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} prism-svelte@0.5.0: resolution: {integrity: sha512-db91Bf3pRGKDPz1lAqLFSJXeW13mulUJxhycysFpfXV5MIK7RgWWK2E5aPAa71s8TCzQUXxF5JOV42/iOs6QkA==} @@ -4760,8 +4833,8 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - react-is@18.2.0: - resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -4778,6 +4851,10 @@ packages: resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} engines: {node: '>= 14.16.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} @@ -4985,6 +5062,10 @@ packages: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} + sirv@3.0.0: + resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==} + engines: {node: '>=18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -5061,17 +5142,14 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-literal@2.1.0: - resolution: {integrity: sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==} - sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'} @@ -5230,19 +5308,26 @@ packages: tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} - tinybench@2.6.0: - resolution: {integrity: sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.1: + resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==} tinyglobby@0.2.9: resolution: {integrity: sha512-8or1+BGEdk1Zkkw2ii16qSS7uVrQJPre5A9o/XkWPATkk23FZh/15BKFxPnlTy6vkljZxLqYCzzBMj30ZrSvjw==} engines: {node: '>=12.0.0'} - tinypool@0.8.4: - resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + tinypool@1.0.1: + resolution: {integrity: sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} - tinyspy@2.2.0: - resolution: {integrity: sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} title-case@3.0.3: @@ -5287,6 +5372,12 @@ packages: peerDependencies: typescript: '>=4.2.0' + ts-api-utils@1.3.0: + resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -5315,10 +5406,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -5451,8 +5538,8 @@ packages: vfile@6.0.1: resolution: {integrity: sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==} - vite-node@1.6.0: - resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} + vite-node@2.1.4: + resolution: {integrity: sha512-kqa9v+oi4HwkG6g8ufRnb5AeplcRw8jUF6/7/Qz1qRQOXHImG8YnLbB+LLszENwFnoBl9xIf9nVdCFzNd7GQEg==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -5502,15 +5589,15 @@ packages: vite: optional: true - vitest@1.6.0: - resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} + vitest@2.1.4: + resolution: {integrity: sha512-eDjxbVAJw1UJJCHr5xr/xM86Zx+YxIEXGAR+bmnEID7z9qWfoxpHw0zdobz+TQAFOLT+nEXz3+gx6nUJ7RgmlQ==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.0 - '@vitest/ui': 1.6.0 + '@vitest/browser': 2.1.4 + '@vitest/ui': 2.1.4 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -5544,6 +5631,14 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -5560,8 +5655,8 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true - why-is-node-running@2.2.2: - resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true @@ -5666,10 +5761,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yocto-queue@1.0.0: - resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} - engines: {node: '>=12.20'} - zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -5690,6 +5781,8 @@ snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} + '@adobe/css-tools@4.4.0': {} + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -5743,7 +5836,6 @@ snapshots: dependencies: '@babel/highlight': 7.25.7 picocolors: 1.1.0 - optional: true '@babel/compat-data@7.23.5': {} @@ -5753,7 +5845,7 @@ snapshots: '@babel/core@7.24.4': dependencies: '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.24.2 + '@babel/code-frame': 7.25.7 '@babel/generator': 7.24.4 '@babel/helper-compilation-targets': 7.23.6 '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) @@ -5763,7 +5855,7 @@ snapshots: '@babel/traverse': 7.24.1 '@babel/types': 7.24.0 convert-source-map: 2.0.0 - debug: 4.3.5 + debug: 4.3.7 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5927,8 +6019,7 @@ snapshots: '@babel/helper-validator-identifier@7.22.20': {} - '@babel/helper-validator-identifier@7.25.7': - optional: true + '@babel/helper-validator-identifier@7.25.7': {} '@babel/helper-validator-option@7.23.5': {} @@ -5962,7 +6053,6 @@ snapshots: chalk: 2.4.2 js-tokens: 4.0.0 picocolors: 1.1.0 - optional: true '@babel/parser@7.24.4': dependencies: @@ -6152,7 +6242,7 @@ snapshots: '@babel/traverse@7.24.1': dependencies: - '@babel/code-frame': 7.24.2 + '@babel/code-frame': 7.25.7 '@babel/generator': 7.24.4 '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-function-name': 7.23.0 @@ -6160,7 +6250,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.24.4 '@babel/types': 7.24.0 - debug: 4.3.5 + debug: 4.3.7 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -6298,17 +6388,19 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': dependencies: - eslint: 8.57.0 + eslint: 8.57.1 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.10.0': {} + '@eslint-community/regexpp@4.11.1': {} + '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.4 + debug: 4.3.7 espree: 9.6.1 globals: 13.24.0 ignore: 5.3.1 @@ -6321,6 +6413,8 @@ snapshots: '@eslint/js@8.57.0': {} + '@eslint/js@8.57.1': {} + '@fastify/busboy@2.1.1': {} '@floating-ui/core@1.6.0': @@ -6817,17 +6911,17 @@ snapshots: protobufjs: 7.2.6 yargs: 17.7.2 - '@humanwhocodes/config-array@0.11.14': + '@humanwhocodes/config-array@0.13.0': dependencies: - '@humanwhocodes/object-schema': 2.0.2 - debug: 4.3.4 + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.3.7 minimatch: 3.1.2 transitivePeerDependencies: - supports-color '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/object-schema@2.0.2': {} + '@humanwhocodes/object-schema@2.0.3': {} '@iconify-json/mdi@1.1.66': dependencies: @@ -6881,10 +6975,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -6897,6 +6987,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.4.15': {} + '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -7960,8 +8052,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.9.6': optional: true - '@sinclair/typebox@0.27.8': {} - '@sveltejs/adapter-node@4.0.1(@sveltejs/kit@2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.9(@types/node@20.12.12)))(svelte@4.2.19)(vite@5.4.9(@types/node@20.12.12)))': dependencies: '@rollup/plugin-commonjs': 25.0.7(rollup@4.9.6) @@ -8046,12 +8136,47 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.3 + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.25.7 + '@babel/runtime': 7.24.1 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.6.2': + dependencies: + '@adobe/css-tools': 4.4.0 + aria-query: 5.3.0 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + + '@testing-library/svelte@5.2.3(svelte@4.2.19)(vite@5.4.9(@types/node@22.7.3))(vitest@2.1.4(@types/node@22.7.3)(@vitest/ui@2.1.4)(happy-dom@15.7.4))': + dependencies: + '@testing-library/dom': 10.4.0 + svelte: 4.2.19 + optionalDependencies: + vite: 5.4.9(@types/node@22.7.3) + vitest: 2.1.4(@types/node@22.7.3)(@vitest/ui@2.1.4)(happy-dom@15.7.4) + + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': + dependencies: + '@testing-library/dom': 10.4.0 + '@tsconfig/svelte@5.0.4': {} '@types/accepts@1.3.7': dependencies: '@types/node': 20.12.12 + '@types/aria-query@5.0.4': {} + '@types/aws-lambda@8.10.122': {} '@types/body-parser@1.19.5': @@ -8235,16 +8360,16 @@ snapshots: '@types/zxcvbn@4.4.4': {} - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.3.3) '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.3.4 - eslint: 8.57.0 + eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 @@ -8255,14 +8380,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3)': + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.4 - eslint: 8.57.0 + debug: 4.3.7 + eslint: 8.57.1 optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -8273,12 +8398,12 @@ snapshots: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - '@typescript-eslint/type-utils@6.21.0(eslint@8.57.0)(typescript@5.3.3)': + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.3.3) debug: 4.3.4 - eslint: 8.57.0 + eslint: 8.57.1 ts-api-utils: 1.0.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -8291,26 +8416,26 @@ snapshots: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.4 + debug: 4.3.7 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 - semver: 7.5.4 - ts-api-utils: 1.0.3(typescript@5.3.3) + semver: 7.6.2 + ts-api-utils: 1.3.0(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@6.21.0(eslint@8.57.0)(typescript@5.3.3)': + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@types/json-schema': 7.0.15 '@types/semver': 7.5.6 '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - eslint: 8.57.0 + eslint: 8.57.1 semver: 7.5.4 transitivePeerDependencies: - supports-color @@ -8358,34 +8483,64 @@ snapshots: dependencies: vite: 5.4.9(@types/node@22.7.3) - '@vitest/expect@1.6.0': + '@vitest/expect@2.1.4': dependencies: - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - chai: 4.4.1 + '@vitest/spy': 2.1.4 + '@vitest/utils': 2.1.4 + chai: 5.1.2 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.4(vite@5.4.9(@types/node@20.12.12))': + dependencies: + '@vitest/spy': 2.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.12 + optionalDependencies: + vite: 5.4.9(@types/node@20.12.12) + + '@vitest/mocker@2.1.4(vite@5.4.9(@types/node@22.7.3))': + dependencies: + '@vitest/spy': 2.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.12 + optionalDependencies: + vite: 5.4.9(@types/node@22.7.3) - '@vitest/runner@1.6.0': + '@vitest/pretty-format@2.1.4': dependencies: - '@vitest/utils': 1.6.0 - p-limit: 5.0.0 + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.4': + dependencies: + '@vitest/utils': 2.1.4 pathe: 1.1.2 - '@vitest/snapshot@1.6.0': + '@vitest/snapshot@2.1.4': dependencies: - magic-string: 0.30.10 + '@vitest/pretty-format': 2.1.4 + magic-string: 0.30.12 pathe: 1.1.2 - pretty-format: 29.7.0 - '@vitest/spy@1.6.0': + '@vitest/spy@2.1.4': dependencies: - tinyspy: 2.2.0 + tinyspy: 3.0.2 - '@vitest/utils@1.6.0': + '@vitest/ui@2.1.4(vitest@2.1.4)': dependencies: - diff-sequences: 29.6.3 - estree-walker: 3.0.3 - loupe: 2.3.7 - pretty-format: 29.7.0 + '@vitest/utils': 2.1.4 + fflate: 0.8.2 + flatted: 3.3.1 + pathe: 1.1.2 + sirv: 3.0.0 + tinyglobby: 0.2.9 + tinyrainbow: 1.2.0 + vitest: 2.1.4(@types/node@20.12.12)(@vitest/ui@2.1.4)(happy-dom@15.7.4) + + '@vitest/utils@2.1.4': + dependencies: + '@vitest/pretty-format': 2.1.4 + loupe: 3.1.2 + tinyrainbow: 1.2.0 '@whatwg-node/events@0.0.3': {} @@ -8430,19 +8585,21 @@ snapshots: dependencies: acorn: 8.11.3 + acorn-import-attributes@1.9.5(acorn@8.13.0): + dependencies: + acorn: 8.13.0 + acorn-jsx@5.3.2(acorn@8.11.3): dependencies: acorn: 8.11.3 - acorn-walk@8.3.2: {} - acorn@8.11.3: {} acorn@8.13.0: {} agent-base@7.1.0: dependencies: - debug: 4.3.5 + debug: 4.3.7 transitivePeerDependencies: - supports-color @@ -8518,7 +8675,7 @@ snapshots: pvutils: 1.1.3 tslib: 2.6.2 - assertion-error@1.1.0: {} + assertion-error@2.0.1: {} astral-regex@2.0.0: {} @@ -8690,15 +8847,13 @@ snapshots: ccount@2.0.1: {} - chai@4.4.1: + chai@5.1.2: dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.3 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.0.8 + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.2 + pathval: 2.0.0 chalk@2.4.2: dependencies: @@ -8706,6 +8861,11 @@ snapshots: escape-string-regexp: 1.0.5 supports-color: 5.5.0 + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -8743,9 +8903,7 @@ snapshots: chardet@0.7.0: {} - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 + check-error@2.1.1: {} cheerio-select@2.1.0: dependencies: @@ -8961,6 +9119,8 @@ snapshots: css-what@6.1.0: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} cuint@0.2.2: {} @@ -9022,14 +9182,9 @@ snapshots: dependencies: ms: 2.1.2 - debug@4.3.5: - dependencies: - ms: 2.1.2 - debug@4.3.7: dependencies: ms: 2.1.3 - optional: true decamelize@1.2.0: {} @@ -9037,9 +9192,7 @@ snapshots: dependencies: character-entities: 2.0.2 - deep-eql@4.1.3: - dependencies: - type-detect: 4.0.8 + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -9075,8 +9228,6 @@ snapshots: didyoumean@1.2.2: {} - diff-sequences@29.6.3: {} - diff@5.2.0: {} dir-glob@3.0.1: @@ -9089,6 +9240,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@1.4.1: dependencies: domelementtype: 2.3.0 @@ -9151,7 +9306,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.1 - semver: 7.5.4 + semver: 7.6.2 electron-to-chromium@1.4.772: {} @@ -9217,18 +9372,18 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.5.0(eslint@8.57.0): + eslint-compat-utils@0.5.0(eslint@8.57.1): dependencies: - eslint: 8.57.0 + eslint: 8.57.1 semver: 7.6.2 - eslint-plugin-svelte@2.39.0(eslint@8.57.0)(svelte@4.2.19): + eslint-plugin-svelte@2.39.0(eslint@8.57.1)(svelte@4.2.19): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@jridgewell/sourcemap-codec': 1.4.15 debug: 4.3.4 - eslint: 8.57.0 - eslint-compat-utils: 0.5.0(eslint@8.57.0) + eslint: 8.57.1 + eslint-compat-utils: 0.5.0(eslint@8.57.1) esutils: 2.0.3 known-css-properties: 0.31.0 postcss: 8.4.47 @@ -9250,20 +9405,20 @@ snapshots: eslint-visitor-keys@3.4.3: {} - eslint@8.57.0: + eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@eslint-community/regexpp': 4.10.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.11.1 '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.0 - '@humanwhocodes/config-array': 0.11.14 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.2.0 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4 + debug: 4.3.7 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -9337,17 +9492,7 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - execa@8.0.1: - dependencies: - cross-spawn: 7.0.3 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.2.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 + expect-type@1.1.0: {} extend@3.0.2: {} @@ -9420,6 +9565,8 @@ snapshots: set-cookie-parser: 2.6.0 tough-cookie: 4.1.4 + fflate@0.8.2: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -9448,13 +9595,13 @@ snapshots: flat-cache@3.2.0: dependencies: - flatted: 3.2.9 + flatted: 3.3.1 keyv: 4.5.4 rimraf: 3.0.2 flat@5.0.2: {} - flatted@3.2.9: {} + flatted@3.3.1: {} fn.name@1.1.0: {} @@ -9497,8 +9644,6 @@ snapshots: get-caller-file@2.0.5: {} - get-func-name@2.0.2: {} - get-intrinsic@1.2.2: dependencies: function-bind: 1.1.2 @@ -9508,8 +9653,6 @@ snapshots: get-stream@6.0.1: {} - get-stream@8.0.1: {} - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -9610,6 +9753,12 @@ snapshots: graphql@16.8.1: {} + happy-dom@15.7.4: + dependencies: + entities: 4.5.0 + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -9667,21 +9816,19 @@ snapshots: http-proxy-agent@7.0.0: dependencies: agent-base: 7.1.0 - debug: 4.3.5 + debug: 4.3.7 transitivePeerDependencies: - supports-color https-proxy-agent@7.0.2: dependencies: agent-base: 7.1.0 - debug: 4.3.5 + debug: 4.3.7 transitivePeerDependencies: - supports-color human-signals@2.1.0: {} - human-signals@5.0.0: {} - iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -9710,8 +9857,8 @@ snapshots: import-in-the-middle@1.8.0: dependencies: - acorn: 8.11.3 - acorn-import-attributes: 1.9.5(acorn@8.11.3) + acorn: 8.13.0 + acorn-import-attributes: 1.9.5(acorn@8.13.0) cjs-module-lexer: 1.2.3 module-details-from-path: 1.0.3 @@ -9815,8 +9962,6 @@ snapshots: is-stream@2.0.1: {} - is-stream@3.0.0: {} - is-unc-path@1.0.0: dependencies: unc-path-regex: 0.1.2 @@ -9866,8 +10011,6 @@ snapshots: js-tokens@4.0.0: {} - js-tokens@9.0.0: {} - js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -9921,6 +10064,8 @@ snapshots: transitivePeerDependencies: - encoding + just-order-by@1.0.0: {} + jwt-decode@4.0.0: {} keyv@4.5.4: @@ -10053,9 +10198,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@2.3.7: - dependencies: - get-func-name: 2.0.2 + loupe@3.1.2: {} lower-case-first@2.0.2: dependencies: @@ -10077,13 +10220,15 @@ snapshots: dependencies: yallist: 4.0.0 + lz-string@1.5.0: {} + magic-string@0.30.10: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 - magic-string@0.30.6: + magic-string@0.30.12: dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 map-cache@0.2.2: {} @@ -10386,7 +10531,7 @@ snapshots: micromark@4.0.0: dependencies: '@types/debug': 4.1.12 - debug: 4.3.5 + debug: 4.3.7 decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.0 @@ -10416,7 +10561,7 @@ snapshots: mimic-fn@3.1.0: {} - mimic-fn@4.0.0: {} + min-indent@1.0.1: {} minimatch@3.1.2: dependencies: @@ -10759,8 +10904,7 @@ snapshots: ms@2.1.2: {} - ms@2.1.3: - optional: true + ms@2.1.3: {} mute-stream@0.0.8: {} @@ -10812,10 +10956,6 @@ snapshots: dependencies: path-key: 3.1.1 - npm-run-path@5.2.0: - dependencies: - path-key: 4.0.0 - nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -10842,10 +10982,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - open@8.4.2: dependencies: define-lazy-prop: 2.0.0 @@ -10883,10 +11019,6 @@ snapshots: dependencies: yocto-queue: 0.1.0 - p-limit@5.0.0: - dependencies: - yocto-queue: 1.0.0 - p-locate@3.0.0: dependencies: p-limit: 2.3.0 @@ -10958,8 +11090,6 @@ snapshots: path-key@3.1.1: {} - path-key@4.0.0: {} - path-parse@1.0.7: {} path-root-regex@0.1.2: {} @@ -10979,7 +11109,7 @@ snapshots: pathe@1.1.2: {} - pathval@1.1.1: {} + pathval@2.0.0: {} periscopic@3.1.0: dependencies: @@ -11118,11 +11248,11 @@ snapshots: prettier@2.8.8: {} - pretty-format@29.7.0: + pretty-format@27.5.1: dependencies: - '@jest/schemas': 29.6.3 + ansi-regex: 5.0.1 ansi-styles: 5.2.0 - react-is: 18.2.0 + react-is: 17.0.2 prism-svelte@0.5.0: {} @@ -11188,7 +11318,7 @@ snapshots: queue-microtask@1.2.3: {} - react-is@18.2.0: {} + react-is@17.0.2: {} read-cache@1.0.0: dependencies: @@ -11206,6 +11336,11 @@ snapshots: readdirp@4.0.2: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + regenerator-runtime@0.14.1: {} relateurl@0.2.7: {} @@ -11264,7 +11399,7 @@ snapshots: require-in-the-middle@7.2.0: dependencies: - debug: 4.3.4 + debug: 4.3.7 module-details-from-path: 1.0.3 resolve: 1.22.8 transitivePeerDependencies: @@ -11437,6 +11572,12 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 + sirv@3.0.0: + dependencies: + '@polka/url': 1.0.0-next.24 + mrmime: 2.0.0 + totalist: 3.0.1 + slash@3.0.0: {} slice-ansi@3.0.0: @@ -11506,14 +11647,12 @@ snapshots: strip-final-newline@2.0.0: {} - strip-final-newline@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 strip-json-comments@3.1.1: {} - strip-literal@2.1.0: - dependencies: - js-tokens: 9.0.0 - sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -11765,16 +11904,20 @@ snapshots: globalyzer: 0.1.0 globrex: 0.1.2 - tinybench@2.6.0: {} + tinybench@2.9.0: {} + + tinyexec@0.3.1: {} tinyglobby@0.2.9: dependencies: fdir: 6.4.0(picomatch@4.0.2) picomatch: 4.0.2 - tinypool@0.8.4: {} + tinypool@1.0.1: {} + + tinyrainbow@1.2.0: {} - tinyspy@2.2.0: {} + tinyspy@3.0.2: {} title-case@3.0.3: dependencies: @@ -11811,6 +11954,10 @@ snapshots: dependencies: typescript: 5.3.3 + ts-api-utils@1.3.0(typescript@5.3.3): + dependencies: + typescript: 5.3.3 + ts-interface-checker@0.1.13: {} ts-log@2.2.5: {} @@ -11842,8 +11989,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-detect@4.0.8: {} - type-fest@0.20.2: {} type-fest@0.21.3: {} @@ -11971,12 +12116,11 @@ snapshots: unist-util-stringify-position: 4.0.0 vfile-message: 4.0.2 - vite-node@1.6.0(@types/node@20.12.12): + vite-node@2.1.4(@types/node@20.12.12): dependencies: cac: 6.7.14 - debug: 4.3.4 + debug: 4.3.7 pathe: 1.1.2 - picocolors: 1.1.0 vite: 5.4.9(@types/node@20.12.12) transitivePeerDependencies: - '@types/node' @@ -11989,6 +12133,23 @@ snapshots: - supports-color - terser + vite-node@2.1.4(@types/node@22.7.3): + dependencies: + cac: 6.7.14 + debug: 4.3.7 + pathe: 1.1.2 + vite: 5.4.9(@types/node@22.7.3) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-plugin-graphql-codegen@3.3.6(@graphql-codegen/cli@5.0.2(@types/node@20.12.12)(graphql@16.8.1)(typescript@5.3.3))(graphql@16.8.1)(vite@5.4.9(@types/node@20.12.12)): dependencies: '@graphql-codegen/cli': 5.0.2(@types/node@20.12.12)(graphql@16.8.1)(typescript@5.3.3) @@ -12022,33 +12183,73 @@ snapshots: optionalDependencies: vite: 5.4.9(@types/node@22.7.3) - vitest@1.6.0(@types/node@20.12.12): + vitest@2.1.4(@types/node@20.12.12)(@vitest/ui@2.1.4)(happy-dom@15.7.4): dependencies: - '@vitest/expect': 1.6.0 - '@vitest/runner': 1.6.0 - '@vitest/snapshot': 1.6.0 - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - acorn-walk: 8.3.2 - chai: 4.4.1 - debug: 4.3.4 - execa: 8.0.1 - local-pkg: 0.5.0 - magic-string: 0.30.6 + '@vitest/expect': 2.1.4 + '@vitest/mocker': 2.1.4(vite@5.4.9(@types/node@20.12.12)) + '@vitest/pretty-format': 2.1.4 + '@vitest/runner': 2.1.4 + '@vitest/snapshot': 2.1.4 + '@vitest/spy': 2.1.4 + '@vitest/utils': 2.1.4 + chai: 5.1.2 + debug: 4.3.7 + expect-type: 1.1.0 + magic-string: 0.30.12 pathe: 1.1.2 - picocolors: 1.0.0 std-env: 3.7.0 - strip-literal: 2.1.0 - tinybench: 2.6.0 - tinypool: 0.8.4 + tinybench: 2.9.0 + tinyexec: 0.3.1 + tinypool: 1.0.1 + tinyrainbow: 1.2.0 vite: 5.4.9(@types/node@20.12.12) - vite-node: 1.6.0(@types/node@20.12.12) - why-is-node-running: 2.2.2 + vite-node: 2.1.4(@types/node@20.12.12) + why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.12.12 + '@vitest/ui': 2.1.4(vitest@2.1.4) + happy-dom: 15.7.4 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vitest@2.1.4(@types/node@22.7.3)(@vitest/ui@2.1.4)(happy-dom@15.7.4): + dependencies: + '@vitest/expect': 2.1.4 + '@vitest/mocker': 2.1.4(vite@5.4.9(@types/node@22.7.3)) + '@vitest/pretty-format': 2.1.4 + '@vitest/runner': 2.1.4 + '@vitest/snapshot': 2.1.4 + '@vitest/spy': 2.1.4 + '@vitest/utils': 2.1.4 + chai: 5.1.2 + debug: 4.3.7 + expect-type: 1.1.0 + magic-string: 0.30.12 + pathe: 1.1.2 + std-env: 3.7.0 + tinybench: 2.9.0 + tinyexec: 0.3.1 + tinypool: 1.0.1 + tinyrainbow: 1.2.0 + vite: 5.4.9(@types/node@22.7.3) + vite-node: 2.1.4(@types/node@22.7.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.7.3 + '@vitest/ui': 2.1.4(vitest@2.1.4) + happy-dom: 15.7.4 transitivePeerDependencies: - less - lightningcss + - msw - sass - sass-embedded - stylus @@ -12083,6 +12284,10 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + + whatwg-mimetype@3.0.0: {} + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -12098,7 +12303,7 @@ snapshots: dependencies: isexe: 3.1.1 - why-is-node-running@2.2.2: + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 @@ -12202,8 +12407,6 @@ snapshots: yocto-queue@0.1.0: {} - yocto-queue@1.0.0: {} - zod@3.23.8: {} zone.js@0.11.8: diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 650ee2b9e..3f163708f 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -11,3 +11,7 @@ catalog: typescript: ^5.3.3 tslib: ^2.6.2 tailwindcss: ^3.4.3 + eslint: ^8.57.0 + vitest: ^2.1.4 + "@vitest/ui": ^2.1.4 + "@typescript-eslint/parser": ^6.21.0 diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 7624aaa32..06060b836 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -425,7 +425,7 @@ type ProjectWritingSystems { } type Query { - myProjects(orderBy: [ProjectSortInput!] @cost(weight: "10")): [Project!]! @cost(weight: "10") + myProjects(orderBy: [ProjectSortInput!] @cost(weight: "10") where: ProjectFilterInput @cost(weight: "10")): [Project!]! @cost(weight: "10") projects(withDeleted: Boolean! = false where: ProjectFilterInput @cost(weight: "10") orderBy: [ProjectSortInput!] @cost(weight: "10")): [Project!]! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") myDraftProjects(orderBy: [DraftProjectSortInput!] @cost(weight: "10")): [DraftProject!]! @cost(weight: "10") draftProjects(where: DraftProjectFilterInput @cost(weight: "10") orderBy: [DraftProjectSortInput!] @cost(weight: "10")): [DraftProject!]! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") diff --git a/frontend/src/lib/components/Users/UserFilter.svelte b/frontend/src/lib/components/Users/UserFilter.svelte index 1fc55a834..a013482a2 100644 --- a/frontend/src/lib/components/Users/UserFilter.svelte +++ b/frontend/src/lib/components/Users/UserFilter.svelte @@ -1,6 +1,4 @@ -
- - -
- dispatch('change', { value })} getDisplayName={(type) => firstVal(type.name)}> - - - - - -
-
+ pickBestAlternative(cft.name, 'analysis', $writingSystems)} + {readonly} + {id} + wsType="first-analysis" /> diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.svelte index c4c40e5b2..782d09021 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.svelte @@ -1,18 +1,102 @@ +
- +
diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.test.ts b/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.test.ts new file mode 100644 index 000000000..45f3f7930 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.test.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ +import {readable} from 'svelte/store' +import {beforeEach, describe, expect, expectTypeOf, test} from 'vitest' + +import {render, screen} from '@testing-library/svelte' +import userEvent, {type UserEvent} from '@testing-library/user-event' +import {getState} from '../../utils/test-utils' +import MultiOptionEditor from './MultiOptionEditor.svelte' +import type {ComponentProps} from 'svelte' + +type Option = {id: string}; + +const value = ['2', '3', '4']; +const options: Option[] = ['1', '2', '3', '4', '5'].map(id => ({id})); + +const context = new Map([ + ['writingSystems', readable({ + analysis: [], + vernacular: [{ + id: 'test', + }], + })], + ['currentView', readable({ + fields: {'test': {show: true}}, + })], +]); + +let user: UserEvent; +let component: MultiOptionEditor; + +const reusedProps: Pick, 'id' | 'wsType' | 'name' | 'readonly'> = { + id: 'test', + wsType: 'vernacular', + name: 'test', + readonly: false, +}; + +beforeEach(() => { + user = userEvent.setup(); + ({component} = render(MultiOptionEditor, { + context, + props: { + ...reusedProps, + valuesAreIds: true, + value, + options, + getOptionLabel: (option) => option.id, + } + })); +}); + +describe('MultiOptionEditor value sorting', () => { + test('appends new options to the end in the order they\'re selected', async () => { + await user.click(screen.getByRole('textbox')); + await user.click(screen.getByLabelText('1')); + await user.click(screen.getByLabelText('5')); + await user.click(screen.getByRole('button', {name: 'Apply'})); + expect(getState(component).value).toStrictEqual(['2', '3', '4', '1', '5']); + }); + + test('removes deselected options without changing the order', async () => { + await user.click(screen.getByRole('textbox')); + await user.click(screen.getByLabelText('3')); + await user.click(screen.getByRole('button', {name: 'Apply'})); + expect(getState(component).value).toStrictEqual(['2', '4']); + }); + + test('moves double-toggled options to the end', async () => { + await user.click(screen.getByRole('textbox')); + await user.click(screen.getByLabelText('3')); + await user.click(screen.getByLabelText('3')); + await user.click(screen.getByRole('button', {name: 'Apply'})); + expect(getState(component).value).toStrictEqual(['2', '4', '3']); + }); +}); + +describe('MultiOptionEditor displayed sorting', () => { + test('matches option sorting if `preserveOrder` option is NOT set', async () => { + await user.click(screen.getByRole('textbox')); + await user.click(screen.getByLabelText('1')); + await user.click(screen.getByLabelText('5')); + await user.click(screen.getByRole('button', {name: 'Apply'})); + expect(getState(component).value).toStrictEqual(['2', '3', '4', '1', '5']); + expect(screen.getByRole('textbox').value).toBe('1, 2, 3, 4, 5'); + }); + + test('matches values sorting if `preserveOrder` option is set', async () => { + component.$set({preserveOrder: true}); + await user.click(screen.getByRole('textbox')); + await user.click(screen.getByLabelText('1')); + await user.click(screen.getByLabelText('5')); + await user.click(screen.getByRole('button', {name: 'Apply'})); + expect(getState(component).value).toStrictEqual(['2', '3', '4', '1', '5']); + expect(screen.getByRole('textbox').value).toBe('2, 3, 4, 1, 5'); + }); +}); + +describe('MultiOptionEditor configurations', () => { + test('supports string or { id: string} values and { id: string } options out of the box', () => { + expectTypeOf({ + ...reusedProps, + value, + options, + getOptionLabel: (option: Option) => option.id, + valuesAreIds: true, + } as const).toMatchTypeOf>>(); + + expectTypeOf({ + ...reusedProps, + value: value.map(id => ({id})), + options, + getOptionLabel: (option: Option) => option.id, + } as const).toMatchTypeOf>>(); + }); + + test('requires valuesAreIds to be set to true for out of the box support for string values, because we need to know the type at runtime', () => { + type Props = ComponentProps>; + expectTypeOf({ + ...reusedProps, + value, + options, + getOptionLabel: (option: Option) => option.id, + valuesAreIds: true, + } as const).toMatchTypeOf(); + + expectTypeOf({ + ...reusedProps, + value, + options, + getOptionLabel: (option: Option) => option.id, + // valuesAreIds: true, + } as const).not.toMatchTypeOf(); + }); + + test('requires getValueId and getValueById for unsupported value types', () => { + type Props = ComponentProps>; + expectTypeOf({ + ...reusedProps, + value: value.map((v) => ({value: v})), + getValueId: (v: {value: string}) => v.value, + getValueById: (id: string) => ({value: id}), + options, + getOptionLabel: (option: Option) => option.id, + } as const).toMatchTypeOf(); + + expectTypeOf({ + ...reusedProps, + value: value.map((v) => ({value: v})), + // getValueId: (v: {value: string}) => v.value, + // getValueById: (id: string) => ({value: id}), + options, + getOptionLabel: (option: Option) => option.id, + } as const).not.toMatchTypeOf(); + }); + + test('requires getOptionId for unsupported option types', () => { + type Props = ComponentProps>; + expectTypeOf({ + ...reusedProps, + value, + valuesAreIds: true, + options: options.map((option: Option) => ({code: option.id})), + getOptionLabel: (option: {code: string}) => option.code, + getOptionId: (option: {code: string}) => option.code, + } as const).toMatchTypeOf(); + + expectTypeOf({ + ...reusedProps, + value, + valuesAreIds: true, + options: options.map((option: Option) => ({code: option.id})), + getOptionLabel: (option: {code: string}) => option.code, + // getOptionId: (option: {code: string}) => option.code, + } as const).not.toMatchTypeOf(); + }); +}); diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte index 684d7da21..0f44e9576 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte @@ -1,18 +1,91 @@ +
- +
diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.test.ts b/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.test.ts new file mode 100644 index 000000000..38a41544b --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.test.ts @@ -0,0 +1,144 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ +import {readable} from 'svelte/store' +import {beforeEach, describe, expect, expectTypeOf, test} from 'vitest' + +import {render, screen} from '@testing-library/svelte' +import userEvent, {type UserEvent} from '@testing-library/user-event' +import {getState} from '../../utils/test-utils' +import SingleOptionEditor from './SingleOptionEditor.svelte' +import type {ComponentProps} from 'svelte' + +type Option = { id: string }; + +const value = '2'; +const options: Option[] = ['1', '2', '3', '4', '5'].map(id => ({id})); + +const context = new Map([ + ['writingSystems', readable({ + analysis: [], + vernacular: [{ + id: 'test', + }], + })], + ['currentView', readable({ + fields: {'test': {show: true}}, + })], +]); + +const reusedProps: Pick>, 'id' | 'wsType' | 'name' | 'readonly'> = { + id: 'test', + wsType: 'vernacular', + name: 'test', + readonly: false, +}; + +describe('SingleOptionEditor', () => { + + let user: UserEvent; + let component: SingleOptionEditor; + + beforeEach(() => { + user = userEvent.setup(); + ({component} = render(SingleOptionEditor, { + context, + props: { + ...reusedProps, + valueIsId: true, + value, + options, + getOptionLabel: (option) => option.id, + } + })); + }); + + test('can change selection', async () => { + await user.click(screen.getByRole('textbox')); + await user.click(screen.getByRole('option', {name: '5'})); + expect(getState(component).value).toBe('5'); + + await user.click(screen.getByRole('textbox')); + await user.click(screen.getByRole('option', {name: '3'})); + expect(getState(component).value).toBe('3'); + }); +}); + +describe('SingleOptionEditor configurations', () => { + + test('supports string or { id: string} values and { id: string } options out of the box', () => { + expectTypeOf({ + ...reusedProps, + value, + options, + getOptionLabel: (option: Option) => option.id, + valueIsId: true, + } as const).toMatchTypeOf>>(); + + expectTypeOf({ + ...reusedProps, + value: {id: value}, + options, + getOptionLabel: (option: Option) => option.id, + } as const).toMatchTypeOf>>(); + }); + + test('requires valueIsId to be set to true for out of the box support for string values, because we need to know the type at runtime', () => { + type Props = ComponentProps>; + expectTypeOf({ + ...reusedProps, + value, + options, + getOptionLabel: (option: Option) => option.id, + valueIsId: true, + } as const).toMatchTypeOf(); + + expectTypeOf({ + ...reusedProps, + value, + options, + getOptionLabel: (option: Option) => option.id, + // valueIsId: true, + } as const).not.toMatchTypeOf(); + }); + + test('requires getValueId and getValueById for unsupported value types', () => { + type Props = ComponentProps>; + expectTypeOf({ + ...reusedProps, + value: {value}, + getValueId: (v: {value: string} | undefined) => v?.value, + getValueById: (id: string | undefined) => id ? {value: id} : undefined, + options, + getOptionLabel: (option: Option) => option.id, + } as const).toMatchTypeOf(); + + expectTypeOf({ + ...reusedProps, + value: {value}, + // getValueId: (v: {value: string} | undefined) => v?.value, + // getValueById: (id: string | undefined) => id ? {value: id} : undefined, + options, + getOptionLabel: (option: Option) => option.id, + } as const).not.toMatchTypeOf(); + }); + + test('requires getOptionId for unsupported option types', () => { + type Props = ComponentProps>; + expectTypeOf({ + ...reusedProps, + value, + valueIsId: true, + options: options.map((option) => ({code: option.id})), + getOptionLabel: (option: {code: string}) => option.code, + getOptionId: (option: {code: string}) => option.code, + } as const).toMatchTypeOf(); + + expectTypeOf({ + ...reusedProps, + value, + valueIsId: true, + options: options.map((option) => ({code: option.id})), + getOptionLabel: (option: {code: string}) => option.code, + // getOptionId: (option: {code: string}) => option.code, + } as const).not.toMatchTypeOf(); + }); +}); diff --git a/frontend/viewer/src/lib/entry-editor/inputs/CrdtField.svelte b/frontend/viewer/src/lib/entry-editor/inputs/CrdtField.svelte index d33a7d0f9..8a57c5dea 100644 --- a/frontend/viewer/src/lib/entry-editor/inputs/CrdtField.svelte +++ b/frontend/viewer/src/lib/entry-editor/inputs/CrdtField.svelte @@ -15,18 +15,18 @@ clearTimeout(timeout); if (unsavedChanges && $generateExternalChanges) { timeout = setTimeout(() => { - if (unsavedChanges && !unacceptedChanges) value = ((JSON.stringify(value ?? '')) + i++) as Value; + if (unsavedChanges && !unacceptedChanges) value = ((JSON.stringify(value ?? '')) + i++) as TValue; }, 1000); } } - type Value = $$Generic; + type TValue = $$Generic; const dispatch = createEventDispatcher<{ - change: { value: Value }; + change: { value: TValue }; }>(); - export let value: Value; + export let value: TValue; export let viewMergeButtonPortal: HTMLElement; let editorValue = value; @@ -63,7 +63,7 @@ unacceptedChanges = false; } - function onEditorValueChange(newValue: Value, save = false): void { + function onEditorValueChange(newValue: TValue, save = false): void { editorValue = newValue; unsavedChanges = editorValue !== value; if (save) { diff --git a/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte b/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte index ecc213dcc..37bc66da1 100644 --- a/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte +++ b/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte @@ -1,8 +1,8 @@ @@ -34,15 +31,17 @@ wsType="analysis" /> ({value: pos.id, label: pos.label}))} + valueIsId + options={$partsOfSpeech} + getOptionLabel={(pos) => pos.label} {readonly} id="partOfSpeechId" wsType="first-analysis" /> setSemanticDomains(e.detail.value)} on:change - value={sense.semanticDomains.map(sd => sd.id)} - options={$semanticDomains.map(sd => ({value: sd.id, label: `${sd.code} ${pickBestAlternative(sd.name, 'analysis', $writingSystems)}`}))} + bind:value={sense.semanticDomains} + options={$semanticDomains} + getOptionLabel={(sd) => `${sd.code} ${pickBestAlternative(sd.name, 'analysis', $writingSystems)}`} {readonly} id="semanticDomains" wsType="first-analysis" /> diff --git a/frontend/viewer/src/lib/entry-editor/view-data.ts b/frontend/viewer/src/lib/entry-editor/view-data.ts index d26a4b53c..6b6932408 100644 --- a/frontend/viewer/src/lib/entry-editor/view-data.ts +++ b/frontend/viewer/src/lib/entry-editor/view-data.ts @@ -1,5 +1,5 @@ -import type {FieldIds} from './field-data'; -import type {I18nType} from '../i18n'; +import type {I18nType} from '../i18n'; +import type {FieldIds} from './field-data'; interface FieldView { show: boolean; @@ -37,6 +37,7 @@ const viewDefinitions: ViewDefinition[] = [ citationForm: {show: false}, literalMeaning: {show: false}, note: {show: false}, + complexFormTypes: {show: false}, semanticDomains: {show: false}, definition: {show: false}, translation: {show: false}, @@ -51,6 +52,7 @@ const viewDefinitions: ViewDefinition[] = [ citationForm: {show: false}, literalMeaning: {show: false}, note: {show: false}, + complexFormTypes: {show: false}, //sense gloss: {order: 2}, diff --git a/frontend/viewer/src/lib/generated-signalr-client/TypedSignalR.Client/index.ts b/frontend/viewer/src/lib/generated-signalr-client/TypedSignalR.Client/index.ts index bda368cdb..7bc72a381 100644 --- a/frontend/viewer/src/lib/generated-signalr-client/TypedSignalR.Client/index.ts +++ b/frontend/viewer/src/lib/generated-signalr-client/TypedSignalR.Client/index.ts @@ -2,7 +2,7 @@ /* eslint-disable */ /* tslint:disable */ -import type {Entry, ExampleSentence, PartOfSpeech, QueryOptions, SemanticDomain, Sense, WritingSystem, WritingSystems} from '../../mini-lcm'; +import type {ComplexFormType, Entry, ExampleSentence, PartOfSpeech, QueryOptions, SemanticDomain, Sense, WritingSystem, WritingSystems} from '../../mini-lcm'; import type { ILexboxApiHub, ILexboxClient } from './Lexbox.ClientServer.Hubs'; import { HubConnection } from '@microsoft/signalr'; @@ -128,6 +128,23 @@ class ILexboxApiHub_HubProxy implements ILexboxApiHub { }); } + public readonly GetComplexFormTypes = async (): Promise => { + return new Promise((resolve, reject) => { + let complexFormTypes: ComplexFormType[] = []; + this.connection.stream('GetComplexFormTypes').subscribe({ + next(value: ComplexFormType) { + complexFormTypes.push(value); + }, + error(err: any) { + reject(err); + }, + complete() { + resolve(complexFormTypes); + } + }); + }); + } + public readonly GetEntries = async (options: QueryOptions): Promise => { return await this.connection.invoke("GetEntries", options); } diff --git a/frontend/viewer/src/lib/i18n.ts b/frontend/viewer/src/lib/i18n.ts index da6db1ed5..4a891edfe 100644 --- a/frontend/viewer/src/lib/i18n.ts +++ b/frontend/viewer/src/lib/i18n.ts @@ -1,4 +1,4 @@ -import type { WellKnownFieldId } from './config-types'; +import type {WellKnownFieldId} from './config-types'; import type {FieldIds} from './entry-editor/field-data'; @@ -12,14 +12,14 @@ export type I18nType = 'weSay' | 'languageForge' | ''; const defaultI18n: Record = { 'lexemeForm': 'Lexeme form', 'citationForm': 'Citation form', - 'complexForms': 'Complex Forms', - 'complexFormTypes': 'Complex Form Types', + 'complexForms': 'Complex forms', + 'complexFormTypes': 'Complex form types', 'components': 'Components', 'literalMeaning': 'Literal meaning', 'note': 'Note', 'definition': 'Definition', 'gloss': 'Gloss', - 'partOfSpeechId': 'Grammatical Info.', + 'partOfSpeechId': 'Grammatical info.', 'semanticDomains': 'Semantic domain', 'sentence': 'Sentence', 'translation': 'Translation', diff --git a/frontend/viewer/src/lib/in-memory-api-service.ts b/frontend/viewer/src/lib/in-memory-api-service.ts index 5a3062bf3..70878dbb5 100644 --- a/frontend/viewer/src/lib/in-memory-api-service.ts +++ b/frontend/viewer/src/lib/in-memory-api-service.ts @@ -1,22 +1,25 @@ -/* eslint-disable @typescript-eslint/naming-convention */ +import {entries, projectName, writingSystems} from './entry-data'; import type { - LexboxApiClient, IEntry, IExampleSentence, ISense, JsonPatch, + LexboxApiClient, LexboxApiFeatures, + PartOfSpeech, QueryOptions, + SemanticDomain, WritingSystemType, - WritingSystems, - PartOfSpeech, - SemanticDomain + WritingSystems } from './services/lexbox-api'; -import {entries, projectName, writingSystems} from './entry-data'; -import {pickWs, type WritingSystem} from './mini-lcm'; -import {headword} from './utils'; import {applyPatch} from 'fast-json-patch'; +import {pickWs, type ComplexFormType, type WritingSystem} from './mini-lcm'; +import {headword} from './utils'; + +const complexFormTypes = entries + .flatMap(entry => entry.complexFormTypes) + .filter((value, index, all) => all.findIndex(v2 => v2.id === value.id) === index); function filterEntries(entries: IEntry[], query: string): IEntry[] { return entries.filter(entry => @@ -30,16 +33,31 @@ function filterEntries(entries: IEntry[], query: string): IEntry[] { } export class InMemoryApiService implements LexboxApiClient { + GetComplexFormTypes(): Promise { + return Promise.resolve( + //* + complexFormTypes + /*/ + [ + {id: '13', name: {en: 'Compound'},}, + {id: '15', name: {en: 'Idiom'},} + ] + //*/ + ); + } + GetPartsOfSpeech(): Promise { - return Promise.resolve([ - {id: '86ff66f6-0774-407a-a0dc-3eeaf873daf7', name: {en: 'Verb'},}, - {id: 'a8e41fd3-e343-4c7c-aa05-01ea3dd5cfb5', name: {en: 'Noun'},} - ]); + return Promise.resolve( + [ + {id: '86ff66f6-0774-407a-a0dc-3eeaf873daf7', name: {en: 'Verb'},}, + {id: 'a8e41fd3-e343-4c7c-aa05-01ea3dd5cfb5', name: {en: 'Noun'},} + ] + ); } GetSemanticDomains(): Promise { return Promise.resolve([ - {id: 'Fruit', name: {en: 'Fruit'}, code: '1'}, + {id: '36e8f1df-1798-4ae6-904d-600ca6eb4145', name: {en: 'Fruit'}, code: '1'}, {id: 'Animal', name: {en: 'Animal'}, code: '2'}, ]); } diff --git a/frontend/viewer/src/lib/mini-lcm/complex-form-component.ts b/frontend/viewer/src/lib/mini-lcm/complex-form-component.ts index 659fc0706..e766b7892 100644 --- a/frontend/viewer/src/lib/mini-lcm/complex-form-component.ts +++ b/frontend/viewer/src/lib/mini-lcm/complex-form-component.ts @@ -3,14 +3,7 @@ * Any changes made to this file can be lost when this file is regenerated. */ -import type { IComplexFormComponent } from './i-complex-form-component'; - -export class ComplexFormComponent implements IComplexFormComponent { - constructor(id: string, complexFormEntryId: string, componentEntryId: string) { - this.id = id; - this.complexFormEntryId = complexFormEntryId; - this.componentEntryId = componentEntryId; - } +export interface IComplexFormComponent { id: string; complexFormEntryId: string; complexFormHeadword?: string; diff --git a/frontend/viewer/src/lib/mini-lcm/complex-form-type.ts b/frontend/viewer/src/lib/mini-lcm/complex-form-type.ts new file mode 100644 index 000000000..b014822aa --- /dev/null +++ b/frontend/viewer/src/lib/mini-lcm/complex-form-type.ts @@ -0,0 +1,6 @@ +import type {IMultiString} from './i-multi-string'; + +export interface ComplexFormType { + id: string; + name: IMultiString; +} diff --git a/frontend/viewer/src/lib/mini-lcm/entry.ts b/frontend/viewer/src/lib/mini-lcm/entry.ts index c55ac0d8c..69b8251f7 100644 --- a/frontend/viewer/src/lib/mini-lcm/entry.ts +++ b/frontend/viewer/src/lib/mini-lcm/entry.ts @@ -3,8 +3,8 @@ * Any changes made to this file can be lost when this file is regenerated. */ -import type { IComplexFormComponent } from './i-complex-form-component'; -import type { IComplexFormType } from './i-complex-form-type'; +import type { IComplexFormComponent } from './complex-form-component'; +import type { ComplexFormType } from './complex-form-type'; import {type IEntry} from './i-entry'; import {type IMultiString} from './i-multi-string'; import {type ISense} from './i-sense'; @@ -18,7 +18,7 @@ export class Entry implements IEntry { lexemeForm: IMultiString = {}; citationForm: IMultiString = {}; complexForms: IComplexFormComponent[] = []; - complexFormTypes: IComplexFormType[] = []; + complexFormTypes: ComplexFormType[] = []; components: IComplexFormComponent[] = []; literalMeaning: IMultiString = {}; senses: ISense[] = []; diff --git a/frontend/viewer/src/lib/mini-lcm/i-complex-form-type.ts b/frontend/viewer/src/lib/mini-lcm/i-complex-form-type.ts deleted file mode 100644 index a03cc5c2a..000000000 --- a/frontend/viewer/src/lib/mini-lcm/i-complex-form-type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { IMultiString } from './i-multi-string'; - -export interface IComplexFormType { - id: string; - name: IMultiString; -} diff --git a/frontend/viewer/src/lib/mini-lcm/i-entry.ts b/frontend/viewer/src/lib/mini-lcm/i-entry.ts index fc9ef513f..bbc61ffcf 100644 --- a/frontend/viewer/src/lib/mini-lcm/i-entry.ts +++ b/frontend/viewer/src/lib/mini-lcm/i-entry.ts @@ -3,8 +3,8 @@ * Any changes made to this file can be lost when this file is regenerated. */ -import type { IComplexFormComponent } from './i-complex-form-component'; -import type { IComplexFormType } from './i-complex-form-type'; +import type { IComplexFormComponent } from './complex-form-component'; +import type { ComplexFormType } from './complex-form-type'; import { type IMultiString } from './i-multi-string'; import { type ISense } from './i-sense'; @@ -13,7 +13,7 @@ export interface IEntry { lexemeForm: IMultiString; citationForm: IMultiString; complexForms: IComplexFormComponent[]; - complexFormTypes: IComplexFormType[]; + complexFormTypes: ComplexFormType[]; components: IComplexFormComponent[]; literalMeaning: IMultiString; senses: ISense[]; diff --git a/frontend/viewer/src/lib/mini-lcm/index.ts b/frontend/viewer/src/lib/mini-lcm/index.ts index a3d3f72e2..6583ee2ef 100644 --- a/frontend/viewer/src/lib/mini-lcm/index.ts +++ b/frontend/viewer/src/lib/mini-lcm/index.ts @@ -17,5 +17,4 @@ export * from './writing-systems'; export * from './part-of-speech'; export * from './semantic-domain'; export * from './complex-form-component'; -export * from './i-complex-form-component'; -export * from './i-complex-form-type'; +export * from './complex-form-type'; diff --git a/frontend/viewer/src/lib/sandbox/Sandbox.svelte b/frontend/viewer/src/lib/sandbox/Sandbox.svelte index 36ebe5cc9..490a9308c 100644 --- a/frontend/viewer/src/lib/sandbox/Sandbox.svelte +++ b/frontend/viewer/src/lib/sandbox/Sandbox.svelte @@ -1,15 +1,53 @@ -
- (value = e.detail.value ?? [])}/> -

selected: {value.join('|')}

- -
-
- -

selected: {crdtValue.join('|')}

- + value.map(v => ({id: v}))} unmap={(value => value.map(v => v.id))} /> + value.map(v => findOption(v.id))} unmap={(value => value)} /> + +
+
+ MultiOptionEditor configurations +
+ o.label} wsType="analysis" readonly={false} {options} /> + o.label} wsType="analysis" readonly={false} {options} /> + o.label} wsType="analysis" readonly={false} {options} /> +
+
+

selected: {idValue.join('|')}

+ +
+
+
+
+ Lower level editor +
+ String values and MenuOptions + +
+
+
+

selected: {crdtValue.join('|')}

+ +
+
diff --git a/frontend/viewer/src/lib/services/lexbox-api.ts b/frontend/viewer/src/lib/services/lexbox-api.ts index 925b55f76..e968a01ad 100644 --- a/frontend/viewer/src/lib/services/lexbox-api.ts +++ b/frontend/viewer/src/lib/services/lexbox-api.ts @@ -1,11 +1,11 @@ /* eslint-disable */ -import type { IEntry, IExampleSentence, ISense, PartOfSpeech, QueryOptions, SemanticDomain, WritingSystem, WritingSystems } from '../mini-lcm'; +import type {ComplexFormType, IEntry, IExampleSentence, ISense, PartOfSpeech, QueryOptions, SemanticDomain, WritingSystem, WritingSystems} from '../mini-lcm'; -import type { Operation } from 'fast-json-patch'; -import type { Readable } from 'svelte/store'; +import type {Operation} from 'fast-json-patch'; +import type {Readable} from 'svelte/store'; -export type {IEntry, IExampleSentence, ISense, QueryOptions, WritingSystem, WritingSystems, PartOfSpeech, SemanticDomain} from '../mini-lcm'; +export type {ComplexFormType, IEntry, IExampleSentence, ISense, PartOfSpeech, QueryOptions, SemanticDomain, WritingSystem, WritingSystems} from '../mini-lcm'; export type JsonPatch = Operation[]; @@ -30,6 +30,7 @@ export interface LexboxApi { GetPartsOfSpeech(): Promise; GetSemanticDomains(): Promise; + GetComplexFormTypes(): Promise; GetEntries(options: QueryOptions | undefined): Promise; SearchEntries(query: string, options: QueryOptions | undefined): Promise; diff --git a/frontend/viewer/src/lib/utils/MapBind.svelte b/frontend/viewer/src/lib/utils/MapBind.svelte new file mode 100644 index 000000000..06e7ca426 --- /dev/null +++ b/frontend/viewer/src/lib/utils/MapBind.svelte @@ -0,0 +1,36 @@ + diff --git a/frontend/viewer/src/lib/utils/MapBind.test.svelte b/frontend/viewer/src/lib/utils/MapBind.test.svelte new file mode 100644 index 000000000..b382a5af3 --- /dev/null +++ b/frontend/viewer/src/lib/utils/MapBind.test.svelte @@ -0,0 +1,14 @@ + + + (_a ? { id: _a } : undefined)} + unmap={(_b) => (_b ? _b.id : undefined)} +/> diff --git a/frontend/viewer/src/lib/utils/MapBind.test.ts b/frontend/viewer/src/lib/utils/MapBind.test.ts new file mode 100644 index 000000000..836551970 --- /dev/null +++ b/frontend/viewer/src/lib/utils/MapBind.test.ts @@ -0,0 +1,49 @@ +import {expect, test, beforeEach} from 'vitest' +import { get, writable, type Writable } from 'svelte/store' + +import MapBindTest from './MapBind.test.svelte' +import {render} from '@testing-library/svelte' +import { tick } from 'svelte' + +let inStore: Writable; +let outStore: Writable<{ id: string } | undefined>; + +beforeEach(() => { + inStore = writable('World'); + outStore = writable<{ id: string } | undefined>(undefined); + render(MapBindTest, { + a: inStore, + b: outStore, + }); +}); + +test('syncs initial value from in->out', () => { + expect(get(inStore)).toBe('World'); + expect(get(outStore)).toStrictEqual({ id: 'World' }); +}); + + +test('syncs future values from in->out and out->in', async () => { + inStore.set('Hello'); + expect(get(inStore)).toBe('Hello'); + await tick(); + expect(get(outStore)).toStrictEqual({ id: 'Hello' }); + + outStore.set({ id: 'Goodbye' }); + expect(get(outStore)).toStrictEqual({ id: 'Goodbye' }); + await tick(); + expect(get(inStore)).toBe('Goodbye'); + + outStore.set(undefined); + await tick(); + expect(get(outStore)).toBe(undefined); + expect(get(inStore)).toBe(undefined); +}); + +test('in wins conflicts', async () => { + inStore.set('Nope'); + outStore.set({ id: 'Yes' }); + await tick(); + expect(get(outStore)).toStrictEqual({ id: 'Nope' }); + expect(get(inStore)).toBe('Nope'); +}); diff --git a/frontend/viewer/src/lib/utils/test-utils.ts b/frontend/viewer/src/lib/utils/test-utils.ts new file mode 100644 index 000000000..76b32265c --- /dev/null +++ b/frontend/viewer/src/lib/utils/test-utils.ts @@ -0,0 +1,6 @@ +import type {ComponentProps, SvelteComponent} from 'svelte'; + +export function getState(component: T): ComponentProps { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return (component.$capture_state() as unknown as ComponentProps); +} diff --git a/frontend/viewer/vite.config.ts b/frontend/viewer/vite.config.ts index fe280fd2c..19aa7e97d 100644 --- a/frontend/viewer/vite.config.ts +++ b/frontend/viewer/vite.config.ts @@ -1,5 +1,6 @@ import {defineConfig} from 'vite'; import {svelte} from '@sveltejs/vite-plugin-svelte'; +import {svelteTesting} from '@testing-library/svelte/vite'; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { @@ -33,7 +34,7 @@ export default defineConfig(({ mode }) => { if (warning.filename?.includes('node_modules/svelte-ux')) return; handler(warning); }, - })], + }), svelteTesting()], ...(!webComponent ? { server: { open: 'http://localhost:5173/testing/project-view', @@ -46,5 +47,9 @@ export default defineConfig(({ mode }) => { } } } : {}), + test: { + environment: 'happy-dom', + setupFiles: ['./vitest-setup.js'], + }, } }); diff --git a/frontend/viewer/vitest-setup.js b/frontend/viewer/vitest-setup.js new file mode 100644 index 000000000..a9d0dd31a --- /dev/null +++ b/frontend/viewer/vitest-setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'