diff --git a/.github/workflows/integration-test-gha.yaml b/.github/workflows/integration-test-gha.yaml index 1e2315e21..f7763756f 100644 --- a/.github/workflows/integration-test-gha.yaml +++ b/.github/workflows/integration-test-gha.yaml @@ -77,7 +77,7 @@ jobs: kubectl wait --for=condition=Ready --timeout=90s pod -l 'app in (cert-manager, webhook)' -n cert-manager kubectl apply -k ./deployment/gha kubectl wait --for=condition=Ready --timeout=120s pod -l 'app.kubernetes.io/component=controller' -n languagedepot - kubectl wait --for=condition=Ready --timeout=120s pod -l 'app in (lexbox, ui, hg, db)' -n languagedepot + kubectl wait --for=condition=Ready --timeout=120s pod -l 'app in (lexbox, ui, hg, db, fw-headless)' -n languagedepot - name: forward ingress run: | kubectl port-forward service/ingress-nginx-controller 6579:80 -n languagedepot & @@ -97,7 +97,6 @@ jobs: ##playwright tests - name: Setup and run playwright tests - if: ${{ !cancelled() }} uses: ./.github/actions/playwright-tests with: lexbox-hostname: 'localhost:6579' @@ -108,7 +107,7 @@ jobs: if: failure() run: | mkdir -p k8s-logs - for app in lexbox ui hg db; do + for app in lexbox ui hg db fw-headless; do kubectl describe pods -l "app=${app}" -n languagedepot > k8s-logs/describe-${app}.txt kubectl logs -l "app=${app}" -n languagedepot --prefix --all-containers --tail=-1 > k8s-logs/logs-${app}.txt done diff --git a/backend/FwHeadless/FwHeadlessKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs index 3b7f64a74..d567ab6e8 100644 --- a/backend/FwHeadless/FwHeadlessKernel.cs +++ b/backend/FwHeadless/FwHeadlessKernel.cs @@ -26,6 +26,10 @@ public static void AddFwHeadless(this IServiceCollection services) .AddLcmCrdtClient() .AddFwDataBridge() .AddFwLiteProjectSync(); + + services.AddSingleton<SyncHostedService>(); + services.AddHostedService(s => s.GetRequiredService<SyncHostedService>()); + services.AddScoped<CrdtSyncService>(); services.AddScoped<ProjectContextFromIdService>(); services.AddTransient<HttpClientAuthHandler>(); diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs index 213c14382..f8e9b2116 100644 --- a/backend/FwHeadless/Program.cs +++ b/backend/FwHeadless/Program.cs @@ -62,71 +62,34 @@ app.MapPost("/api/crdt-sync", ExecuteMergeRequest); app.MapGet("/api/crdt-sync-status", GetMergeStatus); +app.MapGet("/api/await-sync-finished", AwaitSyncFinished); app.Run(); -static async Task<Results<Ok<SyncResult>, NotFound, ProblemHttpResult>> ExecuteMergeRequest( - ILogger<Program> logger, - IServiceProvider services, - SendReceiveService srService, - IOptions<FwHeadlessConfig> config, - FwDataFactory fwDataFactory, - CrdtProjectsService projectsService, +static async Task<Results<Ok, NotFound, ProblemHttpResult>> ExecuteMergeRequest( + SyncHostedService syncHostedService, ProjectLookupService projectLookupService, - SyncJobStatusService syncStatusService, - CrdtFwdataProjectSyncService syncService, + ILogger<Program> logger, CrdtHttpSyncService crdtHttpSyncService, IHttpClientFactory httpClientFactory, - Guid projectId, - bool dryRun = false) + Guid projectId) { - logger.LogInformation("About to execute sync request for {projectId}", projectId); - if (dryRun) - { - logger.LogInformation("Dry run, not actually syncing"); - return TypedResults.Ok(new SyncResult(0, 0)); - } - - syncStatusService.StartSyncing(projectId); - using var stopSyncing = Defer.Action(() => syncStatusService.StopSyncing(projectId)); - var projectCode = await projectLookupService.GetProjectCode(projectId); if (projectCode is null) { logger.LogError("Project ID {projectId} not found", projectId); 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))) { + logger.LogError("Unable to authenticate with Lexbox"); return TypedResults.Problem("Unable to authenticate with Lexbox"); } - - var projectFolder = Path.Join(config.Value.ProjectStorageRoot, $"{projectCode}-{projectId}"); - if (!Directory.Exists(projectFolder)) Directory.CreateDirectory(projectFolder); - - var crdtFile = Path.Join(projectFolder, "crdt.sqlite"); - - var fwDataProject = new FwDataProject("fw", projectFolder); - 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<CrdtSyncService>(); - 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); + syncHostedService.QueueJob(projectId); + return TypedResults.Ok(); } static async Task<Results<Ok<ProjectSyncStatus>, NotFound>> GetMergeStatus( @@ -137,10 +100,12 @@ static async Task<Results<Ok<ProjectSyncStatus>, NotFound>> GetMergeStatus( SyncJobStatusService syncJobStatusService, IServiceProvider services, LexBoxDbContext lexBoxDb, + SyncHostedService syncHostedService, Guid projectId) { var jobStatus = syncJobStatusService.SyncStatus(projectId); if (jobStatus == SyncJobStatus.Running) return TypedResults.Ok(ProjectSyncStatus.Syncing); + if (syncHostedService.IsJobQueuedOrRunning(projectId)) return TypedResults.Ok(ProjectSyncStatus.QueuedToSync); var project = projectContext.MaybeProject; if (project is null) { @@ -170,53 +135,21 @@ static async Task<Results<Ok<ProjectSyncStatus>, NotFound>> GetMergeStatus( return TypedResults.Ok(ProjectSyncStatus.ReadyToSync(pendingCrdtCommits, await pendingHgCommits, lastCrdtCommitDate, lastHgCommitDate)); } -static async Task<FwDataMiniLcmApi> SetupFwData(FwDataProject fwDataProject, - SendReceiveService srService, - string projectCode, - ILogger<Program> logger, - FwDataFactory fwDataFactory) -{ - if (File.Exists(fwDataProject.FilePath)) - { - var srResult = await srService.SendReceive(fwDataProject, projectCode); - logger.LogInformation("Send/Receive result: {srResult}", srResult.Output); - } - else - { - var srResult = await srService.Clone(fwDataProject, projectCode); - logger.LogInformation("Send/Receive result: {srResult}", srResult.Output); - } - - var fwdataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, true); - return fwdataApi; -} - -static async Task<CrdtProject> SetupCrdtProject(string crdtFile, - ProjectLookupService projectLookupService, - Guid projectId, - CrdtProjectsService projectsService, - string projectFolder, - Guid fwProjectId, - string lexboxUrl) +static async Task<Results<Ok<SyncJobResult>, NotFound, StatusCodeHttpResult>> AwaitSyncFinished( + SyncHostedService syncHostedService, + SyncJobStatusService syncJobStatusService, + CancellationToken cancellationToken, + Guid projectId) { - if (File.Exists(crdtFile)) + if (!syncHostedService.IsJobQueuedOrRunning(projectId)) return TypedResults.NotFound(); + try { - return new CrdtProject("crdt", crdtFile); + var result = await syncHostedService.AwaitSyncFinished(projectId, cancellationToken); + if (result is null) return TypedResults.NotFound(); + return TypedResults.Ok(result); } - else + catch (OperationCanceledException) { - 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))); + return TypedResults.StatusCode(StatusCodes.Status408RequestTimeout); } - } - diff --git a/backend/FwHeadless/Services/ProjectSyncStatusService.cs b/backend/FwHeadless/Services/ProjectSyncStatusService.cs index 5edefe115..b856bbd1b 100644 --- a/backend/FwHeadless/Services/ProjectSyncStatusService.cs +++ b/backend/FwHeadless/Services/ProjectSyncStatusService.cs @@ -8,12 +8,12 @@ public class SyncJobStatusService() public void StartSyncing(Guid projectId) { - Status.AddOrUpdate(projectId, (_) => SyncJobStatus.Running, (_, _) => SyncJobStatus.Running); + Status.AddOrUpdate(projectId, SyncJobStatus.Running, (_, _) => SyncJobStatus.Running); } public void StopSyncing(Guid projectId) { - Status.Remove(projectId, out var _); + Status.Remove(projectId, out _); } public SyncJobStatus SyncStatus(Guid projectId) diff --git a/backend/FwHeadless/Services/SyncHostedService.cs b/backend/FwHeadless/Services/SyncHostedService.cs new file mode 100644 index 000000000..ff5ce9bf6 --- /dev/null +++ b/backend/FwHeadless/Services/SyncHostedService.cs @@ -0,0 +1,199 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using FwDataMiniLcmBridge; +using FwDataMiniLcmBridge.Api; +using FwLiteProjectSync; +using LcmCrdt; +using LcmCrdt.RemoteSync; +using LexCore.Sync; +using LexCore.Utils; +using Microsoft.Extensions.Options; + +namespace FwHeadless.Services; + +public class SyncHostedService(IServiceProvider services, ILogger<SyncHostedService> logger) : BackgroundService +{ + private readonly Channel<Guid> _projectsToSync = Channel.CreateUnbounded<Guid>(); + private readonly ConcurrentDictionary<Guid, TaskCompletionSource<SyncJobResult>> _projectsQueuedOrRunning = new(); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var projectId in _projectsToSync.Reader.ReadAllAsync(stoppingToken)) + { + await using var scope = services.CreateAsyncScope(); + var syncWorker = ActivatorUtilities.CreateInstance<SyncWorker>(scope.ServiceProvider, projectId); + SyncJobResult result; + try + { + result = await syncWorker.Execute(stoppingToken); + logger.LogInformation("Sync job result: {Result}", result); + } + catch (Exception e) + { + logger.LogError(e, "Sync job failed"); + result = new SyncJobResult(SyncJobResultEnum.UnknownError, e.Message); + } + _projectsQueuedOrRunning.TryRemove(projectId, out var tcs); + tcs?.TrySetResult(result); + } + } + + public bool IsJobQueuedOrRunning(Guid projectId) + { + return _projectsQueuedOrRunning.ContainsKey(projectId); + } + + public async Task<SyncJobResult?> AwaitSyncFinished(Guid projectId, CancellationToken cancellationToken) + { + if (_projectsQueuedOrRunning.TryGetValue(projectId, out var tcs)) + return await tcs.Task.WaitAsync(cancellationToken); + + return null; + } + + public bool QueueJob(Guid projectId) + { + //will only queue job if it's not already queued + var addedToQueue = _projectsQueuedOrRunning.TryAdd(projectId, new()); + if (addedToQueue) + { + if (!_projectsToSync.Writer.TryWrite(projectId)) + { + logger.LogError("Failed to queue sync job for project {ProjectId}, the channel is full", projectId); + _projectsQueuedOrRunning.TryRemove(projectId, out _); + return false; + } + + logger.LogInformation("Queued sync job for project {ProjectId}", projectId); + } + else + { + logger.LogInformation("Project {ProjectId} is already queued", projectId); + } + + return addedToQueue; + } +} + +public class SyncWorker( + Guid projectId, + ILogger<SyncWorker> logger, + IServiceProvider services, + SendReceiveService srService, + IOptions<FwHeadlessConfig> config, + FwDataFactory fwDataFactory, + CrdtProjectsService projectsService, + ProjectLookupService projectLookupService, + SyncJobStatusService syncStatusService, + CrdtFwdataProjectSyncService syncService, + CrdtHttpSyncService crdtHttpSyncService, + IHttpClientFactory httpClientFactory +) +{ + public async Task<SyncJobResult> Execute(CancellationToken stoppingToken) + { + logger.LogInformation("About to execute sync request for {projectId}", projectId); + + syncStatusService.StartSyncing(projectId); + using var stopSyncing = Defer.Action(() => syncStatusService.StopSyncing(projectId)); + + var projectCode = await projectLookupService.GetProjectCode(projectId); + if (projectCode is null) + { + logger.LogError("Project ID {projectId} not found", projectId); + return new SyncJobResult(SyncJobResultEnum.ProjectNotFound, $"Project {projectId} not found"); + } + + logger.LogInformation("Project code is {projectCode}", projectCode); + //if we can't sync with lexbox fail fast + if (!await crdtHttpSyncService.TestAuth(httpClientFactory.CreateClient(FwHeadlessKernel.LexboxHttpClientName))) + { + logger.LogError("Unable to authenticate with Lexbox"); + return new SyncJobResult(SyncJobResultEnum.UnableToAuthenticate, "Unable to authenticate with Lexbox"); + } + + var projectFolder = Path.Join(config.Value.ProjectStorageRoot, $"{projectCode}-{projectId}"); + if (!Directory.Exists(projectFolder)) Directory.CreateDirectory(projectFolder); + + var crdtFile = Path.Join(projectFolder, "crdt.sqlite"); + + var fwDataProject = new FwDataProject("fw", projectFolder); + 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<CrdtSyncService>(); + await crdtSyncService.Sync(); + + var result = await syncService.Sync(miniLcmApi, fwdataApi); + 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 new SyncJobResult(SyncJobResultEnum.Success, null, result); + } + + static async Task<FwDataMiniLcmApi> SetupFwData(FwDataProject fwDataProject, + SendReceiveService srService, + string projectCode, + ILogger logger, + FwDataFactory fwDataFactory) + { + if (File.Exists(fwDataProject.FilePath)) + { + var srResult = await srService.SendReceive(fwDataProject, projectCode); + logger.LogInformation("Send/Receive result: {srResult}", srResult.Output); + } + else + { + var srResult = await srService.Clone(fwDataProject, projectCode); + logger.LogInformation("Send/Receive result: {srResult}", srResult.Output); + } + + var fwdataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, true); + return fwdataApi; + } + + static async Task<CrdtProject> SetupCrdtProject(string crdtFile, + ProjectLookupService projectLookupService, + Guid projectId, + CrdtProjectsService 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/appsettings.json b/backend/FwHeadless/appsettings.json index 07fe8e9d0..784d06fbf 100644 --- a/backend/FwHeadless/appsettings.json +++ b/backend/FwHeadless/appsettings.json @@ -6,7 +6,7 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Information", - "SendReceive": "Debug" + "SendReceive": "Information" } }, "AllowedHosts": "*" diff --git a/backend/LexBoxApi/Auth/Attributes/FeatureFlagRequiredAttribute.cs b/backend/LexBoxApi/Auth/Attributes/FeatureFlagRequiredAttribute.cs new file mode 100644 index 000000000..4907052a0 --- /dev/null +++ b/backend/LexBoxApi/Auth/Attributes/FeatureFlagRequiredAttribute.cs @@ -0,0 +1,17 @@ +using LexCore; +using Microsoft.AspNetCore.Authorization; + +namespace LexBoxApi.Auth.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class FeatureFlagRequiredAttribute(FeatureFlag flag): LexboxAuthAttribute(PolicyName), IAuthorizationRequirement, IAuthorizationRequirementData +{ + public const string PolicyName = "FeatureFlagRequired"; + public bool AllowAdmin { get; set; } = false; + public FeatureFlag Flag => flag; + + public IEnumerable<IAuthorizationRequirement> GetRequirements() + { + yield return this; + } +} diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index 038da232b..380ed9256 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -41,6 +41,7 @@ public static void AddLexBoxAuth(IServiceCollection services, services.AddScoped<LexAuthService>(); services.AddSingleton<IAuthorizationHandler, AudienceRequirementHandler>(); services.AddSingleton<IAuthorizationHandler, ValidateUserUpdatedHandler>(); + services.AddSingleton<IAuthorizationHandler, FeatureFlagRequirementHandler>(); services.AddAuthorization(options => { //fallback policy is used when there's no auth attribute. @@ -54,6 +55,7 @@ public static void AddLexBoxAuth(IServiceCollection services, options.AddPolicy(AllowAnyAudienceAttribute.PolicyName, builder => builder.RequireAuthenticatedUser()); //we still need this policy, without it the default policy is used which requires the default audience options.AddPolicy(RequireAudienceAttribute.PolicyName, builder => builder.RequireAuthenticatedUser()); + options.AddPolicy(FeatureFlagRequiredAttribute.PolicyName, builder => builder.RequireAuthenticatedUser()); options.AddPolicy(AdminRequiredAttribute.PolicyName, builder => builder.RequireDefaultLexboxAuth() diff --git a/backend/LexBoxApi/Auth/Requirements/FeatureFlagRequirementHandler.cs b/backend/LexBoxApi/Auth/Requirements/FeatureFlagRequirementHandler.cs new file mode 100644 index 000000000..26ce438e1 --- /dev/null +++ b/backend/LexBoxApi/Auth/Requirements/FeatureFlagRequirementHandler.cs @@ -0,0 +1,42 @@ +using System.Security.Claims; +using LexBoxApi.Auth.Attributes; +using LexCore; +using LexCore.Auth; +using Microsoft.AspNetCore.Authorization; + +namespace LexBoxApi.Auth.Requirements; + +public class FeatureFlagRequirementHandler(ILogger<FeatureFlagRequirementHandler> logger): AuthorizationHandler<FeatureFlagRequiredAttribute> +{ + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FeatureFlagRequiredAttribute requirement) + { + bool success = false; + var isAdmin = context.User.IsInRole(UserRole.admin.ToString()); + if (isAdmin && requirement.AllowAdmin) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + foreach (var userFlag in context.User.FindAll(LexAuthConstants.FeatureFlagsClaimType) + .Where(c => Enum.IsDefined(typeof(FeatureFlag), c.Value)) + .Select(c => Enum.Parse<FeatureFlag>(c.Value))) + { + if (requirement.Flag == userFlag) + { + success = true; + break; + } + } + + if (success) + { + context.Succeed(requirement); + } + else + { + context.Fail(new AuthorizationFailureReason(this, "User does not have the feature flag " + requirement.Flag)); + logger.LogError("User does not have the feature flag " + requirement.Flag); + } + return Task.CompletedTask; + } +} diff --git a/backend/LexBoxApi/Controllers/AuthTestingController.cs b/backend/LexBoxApi/Controllers/AuthTestingController.cs index 908ab6289..28e471563 100644 --- a/backend/LexBoxApi/Controllers/AuthTestingController.cs +++ b/backend/LexBoxApi/Controllers/AuthTestingController.cs @@ -1,5 +1,6 @@ using LexBoxApi.Auth; using LexBoxApi.Auth.Attributes; +using LexCore; using LexCore.Auth; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -36,4 +37,11 @@ public ForbidResult Forbidden() { return Forbid(); } + + [HttpGet("requiresFwBetaFeatureFlag")] + [FeatureFlagRequired(FeatureFlag.FwLiteBeta)] + public ActionResult RequiresFwBetaFeatureFlag() + { + return Ok(); + } } diff --git a/backend/LexBoxApi/Controllers/CrdtController.cs b/backend/LexBoxApi/Controllers/CrdtController.cs index 4051d2b52..5e47d3eef 100644 --- a/backend/LexBoxApi/Controllers/CrdtController.cs +++ b/backend/LexBoxApi/Controllers/CrdtController.cs @@ -4,6 +4,7 @@ using LexBoxApi.Auth.Attributes; using LexBoxApi.Hub; using LexBoxApi.Services; +using LexCore; using LexCore.Entities; using LexCore.ServiceInterfaces; using LexCore.Sync; @@ -94,14 +95,4 @@ public async Task<ActionResult<Guid>> GetProjectId(string code) return Ok(projectId); } - - [HttpPost("sync/{projectId}")] - [AdminRequired] - [RequestTimeout(300_000)]//5 minutes - public async Task<ActionResult<SyncResult?>> ExecuteMerge(Guid projectId) - { - var result = await fwHeadlessClient.CrdtSync(projectId); - if (result is null) return Problem("Failed to sync CRDT"); - return result; - } } diff --git a/backend/LexBoxApi/Controllers/SyncController.cs b/backend/LexBoxApi/Controllers/SyncController.cs index a36585f6c..77fb4d039 100644 --- a/backend/LexBoxApi/Controllers/SyncController.cs +++ b/backend/LexBoxApi/Controllers/SyncController.cs @@ -1,4 +1,6 @@ +using LexBoxApi.Auth.Attributes; using LexBoxApi.Services; +using LexCore; using LexCore.ServiceInterfaces; using LexCore.Sync; using Microsoft.AspNetCore.Mvc; @@ -7,6 +9,7 @@ namespace LexBoxApi.Controllers; [ApiController] [Route("/api/fw-lite/sync")] +[FeatureFlagRequired(FeatureFlag.FwLiteBeta, AllowAdmin = true)] [ApiExplorerSettings(GroupName = LexBoxKernel.OpenApiPublicDocumentName)] public class SyncController( IPermissionService permissionService, @@ -22,11 +25,20 @@ public async Task<ActionResult<ProjectSyncStatus>> GetSyncStatus(Guid projectId) } [HttpPost("trigger/{projectId}")] - public async Task<ActionResult<SyncResult>> TriggerSync(Guid projectId) + public async Task<ActionResult> TriggerSync(Guid projectId) { if (!await permissionService.CanSyncProject(projectId)) return Forbid(); - var result = await fwHeadlessClient.CrdtSync(projectId); - if (result is null) return StatusCode(500); // Apparently there's no InternalServerError()? Weird. - return Ok(result); + var started = await fwHeadlessClient.CrdtSync(projectId); + if (!started) return Problem("Failed to sync CRDT"); + return Ok(); + } + + [HttpGet("await-sync-finished/{projectId}")] + public async Task<ActionResult<SyncResult>> AwaitSyncFinished(Guid projectId) + { + await permissionService.AssertCanSyncProject(projectId); + var result = await fwHeadlessClient.AwaitStatus(projectId); + if (result is null) return Problem("Failed to get sync status"); + return result; } } diff --git a/backend/LexBoxApi/Controllers/TestingController.cs b/backend/LexBoxApi/Controllers/TestingController.cs index 93d91bfa5..4e888f7b2 100644 --- a/backend/LexBoxApi/Controllers/TestingController.cs +++ b/backend/LexBoxApi/Controllers/TestingController.cs @@ -2,6 +2,7 @@ using LexBoxApi.Auth.Attributes; using LexBoxApi.Services; using LexCore.Auth; +using LexCore.Entities; using LexCore.Exceptions; using LexCore.ServiceInterfaces; using LexData; @@ -18,7 +19,9 @@ public class TestingController( LexAuthService lexAuthService, LexBoxDbContext lexBoxDbContext, IHgService hgService, - SeedingData seedingData) + SeedingData seedingData, + ProjectService projectService, + LoggedInContext loggedInContext) : ControllerBase { #if DEBUG @@ -67,6 +70,24 @@ public ActionResult DebugConfiguration() #endif + [HttpPost("copyToNewProject")] + [AdminRequired] + public async Task<ActionResult<Guid>> CopyToNewProject(string newProjectCode, string existingProjectCode) + { + var id = Guid.NewGuid(); + await projectService.CreateProject(new(id, + newProjectCode, + "Copy of " + existingProjectCode, + newProjectCode, + ProjectType.FLEx, + RetentionPolicy.Dev, + true, + loggedInContext.User.Id, + null)); + await hgService.CopyRepo(existingProjectCode, newProjectCode); + return Ok(id); + } + [HttpPost("seedDatabase")] [AdminRequired] public async Task<ActionResult> SeedDatabase() diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/IsHarmonyProjectDataLoader.cs b/backend/LexBoxApi/GraphQL/CustomTypes/IsHarmonyProjectDataLoader.cs new file mode 100644 index 000000000..b4a67bdbe --- /dev/null +++ b/backend/LexBoxApi/GraphQL/CustomTypes/IsHarmonyProjectDataLoader.cs @@ -0,0 +1,23 @@ +using LexCore.ServiceInterfaces; +using LexData; +using Microsoft.EntityFrameworkCore; +using SIL.Harmony.Core; + +namespace LexBoxApi.GraphQL.CustomTypes; + +public class IsHarmonyProjectDataLoader( + LexBoxDbContext dbContext, + IBatchScheduler batchScheduler, + DataLoaderOptions options) + : BatchDataLoader<Guid, bool>(batchScheduler, options), IIsHarmonyProjectDataLoader +{ + protected override async Task<IReadOnlyDictionary<Guid, bool>> LoadBatchAsync(IReadOnlyList<Guid> keys, CancellationToken cancellationToken) + { + var isHarmonyProject = await dbContext.Set<ServerCommit>() + .Where(c => keys.Contains(c.ProjectId)) + .Select(c => c.ProjectId) + .Distinct() + .ToArrayAsync(cancellationToken); + return keys.ToDictionary(k => k, k => isHarmonyProject.Contains(k)); + } +} diff --git a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs index 97d7585a0..44a5fd8af 100644 --- a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs +++ b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs @@ -1,8 +1,13 @@ using DataAnnotatedModelValidations; using HotChocolate.Diagnostics; using LexBoxApi.GraphQL.CustomFilters; +using LexBoxApi.GraphQL.CustomTypes; using LexBoxApi.Services; +using LexCore.ServiceInterfaces; using LexData; +using LfClassicData; +using Microsoft.Extensions.Options; +using Polly; namespace LexBoxApi.GraphQL; @@ -15,6 +20,18 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm if (forceGenerateSchema || env.IsDevelopment()) services.AddHostedService<DevGqlSchemaWriterService>(); + services.AddScoped<IIsHarmonyProjectDataLoader, IsHarmonyProjectDataLoader>(); + services.AddScoped<IIsLanguageForgeProjectDataLoader, IsLanguageForgeProjectDataLoader>(); + services.AddResiliencePipeline<string, IReadOnlyDictionary<string, bool>>( + IsLanguageForgeProjectDataLoader.ResiliencePolicyName, + (builder, context) => + { + builder.ConfigureTelemetry(context.ServiceProvider.GetRequiredService<ILoggerFactory>()); + IsLanguageForgeProjectDataLoader.ConfigureResiliencePipeline(builder, + context.ServiceProvider.GetRequiredService<IOptions<LfClassicConfig>>().Value + .IsLfProjectConnectionRetryTimeout); + }); + services .AddGraphQLServer() .ModifyCostOptions(options => diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs index ebfedd429..00eecd6f3 100644 --- a/backend/LexBoxApi/LexBoxKernel.cs +++ b/backend/LexBoxApi/LexBoxKernel.cs @@ -71,12 +71,6 @@ public static void AddLexBoxApi(this IServiceCollection services, services.AddHostedService<HgService>(); services.AddTransient<HgWebHealthCheck>(); services.AddTransient<FwHeadlessHealthCheck>(); - services.AddScoped<IIsLanguageForgeProjectDataLoader, IsLanguageForgeProjectDataLoader>(); - services.AddResiliencePipeline<string, IReadOnlyDictionary<string, bool>>(IsLanguageForgeProjectDataLoader.ResiliencePolicyName, (builder, context) => - { - builder.ConfigureTelemetry(context.ServiceProvider.GetRequiredService<ILoggerFactory>()); - IsLanguageForgeProjectDataLoader.ConfigureResiliencePipeline(builder, context.ServiceProvider.GetRequiredService<IOptions<LfClassicConfig>>().Value.IsLfProjectConnectionRetryTimeout); - }); services.AddScoped<ILexProxyService, LexProxyService>(); services.AddSingleton<ISendReceiveService, SendReceiveService>(); services.AddSingleton<LexboxLinkGenerator>(); diff --git a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs index c0b77997e..fad0a0d33 100644 --- a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs +++ b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs @@ -28,7 +28,6 @@ public static async Task GenerateGqlSchema(string[] args) .AddSingleton<IHostLifetime, ConsoleLifetime>() .AddScoped<IEmailService, EmailService>() .AddScoped<IHgService, HgService>() - .AddScoped<IIsLanguageForgeProjectDataLoader, IsLanguageForgeProjectDataLoader>() .AddScoped<LoggedInContext>() .AddScoped<LexBoxDbContext>() .AddScoped<IPermissionService, PermissionService>() diff --git a/backend/LexBoxApi/Services/FwHeadlessClient.cs b/backend/LexBoxApi/Services/FwHeadlessClient.cs index 06a82e257..50b5322e6 100644 --- a/backend/LexBoxApi/Services/FwHeadlessClient.cs +++ b/backend/LexBoxApi/Services/FwHeadlessClient.cs @@ -4,16 +4,42 @@ namespace LexBoxApi.Services; public class FwHeadlessClient(HttpClient httpClient, ILogger<FwHeadlessClient> logger) { - public async Task<SyncResult?> CrdtSync(Guid projectId) + public async Task<bool> CrdtSync(Guid projectId) { var response = await httpClient.PostAsync($"/api/crdt-sync?projectId={projectId}", null); if (response.IsSuccessStatusCode) - return await response.Content.ReadFromJsonAsync<SyncResult>(); - logger.LogError("Failed to sync CRDT: {StatusCode} {StatusDescription}, projectId: {ProjectId}, response: {Response}", + return true; + logger.LogError("Failed to sync CRDT: {StatusCode} {StatusDescription}, projectId: {ProjectId}", response.StatusCode, response.ReasonPhrase, - projectId, - await response.Content.ReadAsStringAsync()); + projectId); + return false; + } + + public async Task<SyncResult?> AwaitStatus(Guid projectId) + { + var response = await httpClient.GetAsync($"/api/await-sync-finished?projectId={projectId}"); + if (!response.IsSuccessStatusCode) + { + logger.LogError("Failed to get sync status: {StatusCode} {StatusDescription}, projectId: {ProjectId}, response: {Response}", + response.StatusCode, + response.ReasonPhrase, + projectId, + await response.Content.ReadAsStringAsync()); + return null; + } + var jobResult = await response.Content.ReadFromJsonAsync<SyncJobResult>(); + if (jobResult is null) + { + logger.LogError("Failed to get sync status"); + return null; + } + + if (jobResult.Result == SyncJobResultEnum.Success) + { + return jobResult.SyncResult; + } + logger.LogError("Sync failed: {JobResult}", jobResult); return null; } diff --git a/backend/LexBoxApi/Services/HgService.cs b/backend/LexBoxApi/Services/HgService.cs index 2c9b45b6a..f5b125b36 100644 --- a/backend/LexBoxApi/Services/HgService.cs +++ b/backend/LexBoxApi/Services/HgService.cs @@ -78,6 +78,36 @@ private void InitRepoAt(DirectoryInfo repoDirectory) ); } + /// <summary> + /// danger, this will replace the repo at the destination with the source + /// </summary> + public async Task CopyRepo(ProjectCode sourceCode, ProjectCode destCode) + { + var sourceFolder = new DirectoryInfo(PrefixRepoFilePath(sourceCode)); + if (!sourceFolder.Exists) + { + throw new DirectoryNotFoundException($"Source repo {sourceCode} not found"); + } + var repoDirectory = new DirectoryInfo(PrefixRepoFilePath(destCode)); + if (repoDirectory.Exists && (await GetTipHash(destCode) != AllZeroHash)) + { + throw new InvalidOperationException($"Destination repo {destCode} already exists and is not empty"); + } + + await Task.Run(() => + { + if (repoDirectory.Exists) repoDirectory.Delete(true); + repoDirectory.Create(); + FileUtils.CopyFilesRecursively( + sourceFolder, + repoDirectory, + Permissions + ); + } + ); + await InvalidateDirCache(destCode); + } + public async Task DeleteRepo(ProjectCode code) { await Task.Run(() => Directory.Delete(PrefixRepoFilePath(code), true)); diff --git a/backend/LexBoxApi/appsettings.json b/backend/LexBoxApi/appsettings.json index 1f224fced..99513b61f 100644 --- a/backend/LexBoxApi/appsettings.json +++ b/backend/LexBoxApi/appsettings.json @@ -73,7 +73,7 @@ }, "Services": { "fwHeadless": { - "http": ["fw-headless"] + "http": ["fw-headless:8081"] } }, "FwLiteRelease": { diff --git a/backend/LexCore/Entities/Project.cs b/backend/LexCore/Entities/Project.cs index f3b56e713..d8597ce17 100644 --- a/backend/LexCore/Entities/Project.cs +++ b/backend/LexCore/Entities/Project.cs @@ -61,6 +61,15 @@ public Task<bool> GetIsLanguageForgeProject(IIsLanguageForgeProjectDataLoader lo } return Task.FromResult(false); } + + public Task<bool> GetHasHarmonyCommits(IIsHarmonyProjectDataLoader loader) + { + if (Type is ProjectType.Unknown or ProjectType.FLEx) + { + return loader.LoadAsync(Id); + } + return Task.FromResult(false); + } } public enum ProjectMigrationStatus diff --git a/backend/LexCore/ServiceInterfaces/IHgService.cs b/backend/LexCore/ServiceInterfaces/IHgService.cs index 28cc78723..cf427655b 100644 --- a/backend/LexCore/ServiceInterfaces/IHgService.cs +++ b/backend/LexCore/ServiceInterfaces/IHgService.cs @@ -7,6 +7,7 @@ public record BackupExecutor(Func<Stream, CancellationToken, Task> ExecuteBackup public interface IHgService { Task InitRepo(ProjectCode code); + Task CopyRepo(ProjectCode sourceCode, ProjectCode destCode); Task<DateTimeOffset?> GetLastCommitTimeFromHg(ProjectCode projectCode); Task<Changeset[]> GetChangesets(ProjectCode projectCode); Task<ProjectType> DetermineProjectType(ProjectCode projectCode); diff --git a/backend/LexCore/ServiceInterfaces/IIsHarmonyProjectDataLoader.cs b/backend/LexCore/ServiceInterfaces/IIsHarmonyProjectDataLoader.cs new file mode 100644 index 000000000..5c6fa2bbd --- /dev/null +++ b/backend/LexCore/ServiceInterfaces/IIsHarmonyProjectDataLoader.cs @@ -0,0 +1,6 @@ +namespace LexCore.ServiceInterfaces; + +public interface IIsHarmonyProjectDataLoader +{ + Task<bool> LoadAsync(Guid projectId, CancellationToken cancellationToken = default); +} diff --git a/backend/LexCore/Sync/SyncJobResult.cs b/backend/LexCore/Sync/SyncJobResult.cs new file mode 100644 index 000000000..a46294e87 --- /dev/null +++ b/backend/LexCore/Sync/SyncJobResult.cs @@ -0,0 +1,14 @@ +namespace LexCore.Sync; + +public record SyncJobResult(SyncJobResultEnum Result, string? Error, SyncResult? SyncResult = null); + +public enum SyncJobResultEnum +{ + Success, + ProjectNotFound, + UnableToAuthenticate, + UnableToSync, + CrdtSyncFailed, + SendReceiveFailed, + UnknownError, +} diff --git a/backend/LexCore/Sync/SyncStatus.cs b/backend/LexCore/Sync/SyncStatus.cs index 5ecd2a865..9b3c76ff9 100644 --- a/backend/LexCore/Sync/SyncStatus.cs +++ b/backend/LexCore/Sync/SyncStatus.cs @@ -5,17 +5,19 @@ public enum ProjectSyncStatusEnum NeverSynced, ReadyToSync, Syncing, + QueuedToSync } public record ProjectSyncStatus( ProjectSyncStatusEnum status, - int PendingCrdtChanges, - int PendingMercurialChanges, // Will be -1 if there is no clone yet; this means "all the commits, but we don't know how many there will be" - DateTimeOffset? LastCrdtCommitDate, - DateTimeOffset? LastMercurialCommitDate) + int PendingCrdtChanges = 0, + int PendingMercurialChanges = 0, // Will be -1 if there is no clone yet; this means "all the commits, but we don't know how many there will be" + DateTimeOffset? LastCrdtCommitDate = null, + DateTimeOffset? LastMercurialCommitDate = null) { - public static ProjectSyncStatus NeverSynced => new(ProjectSyncStatusEnum.NeverSynced, 0, 0, null, null); - public static ProjectSyncStatus Syncing => new(ProjectSyncStatusEnum.Syncing, 0, 0, null, null); + public static ProjectSyncStatus NeverSynced => new(ProjectSyncStatusEnum.NeverSynced); + public static ProjectSyncStatus Syncing => new(ProjectSyncStatusEnum.Syncing); + public static ProjectSyncStatus QueuedToSync => new(ProjectSyncStatusEnum.QueuedToSync); public static ProjectSyncStatus ReadyToSync( int pendingCrdtChanges, int pendingMercurialChanges, diff --git a/backend/LexData/Entities/CommitEntityConfiguration.cs b/backend/LexData/Entities/CommitEntityConfiguration.cs index 953395476..b8795aa9e 100644 --- a/backend/LexData/Entities/CommitEntityConfiguration.cs +++ b/backend/LexData/Entities/CommitEntityConfiguration.cs @@ -16,8 +16,8 @@ public void Configure(EntityTypeBuilder<ServerCommit> builder) builder.ToTable("CrdtCommits"); builder.HasKey(c => c.Id); builder.ComplexProperty(c => c.HybridDateTime); - builder.HasOne<FlexProjectMetadata>().WithMany() - .HasPrincipalKey(f => f.ProjectId) + builder.HasOne<Project>().WithMany() + .HasPrincipalKey(project => project.Id) .HasForeignKey(c => c.ProjectId); builder.Property(c => c.Metadata).HasConversion( m => JsonSerializer.Serialize(m, (JsonSerializerOptions?)null), diff --git a/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.Designer.cs b/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.Designer.cs new file mode 100644 index 000000000..f6550f4e4 --- /dev/null +++ b/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.Designer.cs @@ -0,0 +1,1406 @@ +// <auto-generated /> +using System; +using System.Collections.Generic; +using LexData; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LexData.Migrations +{ + [DbContext(typeof(LexBoxDbContext))] + [Migration("20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject")] + partial class ChangeFkRelationOnCrdtCommitsToPointToProject + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<byte[]>("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property<byte[]>("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<string>("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property<string>("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property<long>("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property<string>("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property<bool>("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property<string>("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property<string>("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property<int>("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property<bool?>("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property<long>("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property<string>("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property<string>("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<string>("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property<string>("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<bool>("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property<bool>("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property<bool>("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property<string>("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property<byte[]>("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property<bool>("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property<long>("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property<long>("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<bool?>("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property<bool?>("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property<decimal?>("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property<decimal?>("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property<int?>("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property<int?>("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property<long?>("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property<long?>("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property<string>("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property<string>("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property<string>("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property<string>("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<long>("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property<long>("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property<long>("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<string>("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<long?>("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property<byte[]>("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property<string>("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property<string>("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property<short?>("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property<long?>("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property<long?>("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property<int?>("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property<long>("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property<string>("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property<string>("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", "quartz"); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<string>("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool?>("IsConfidential") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<Guid?>("OrgId") + .HasColumnType("uuid"); + + b.Property<Guid?>("ProjectManagerId") + .HasColumnType("uuid"); + + b.Property<int>("RetentionPolicy") + .HasColumnType("integer"); + + b.Property<int>("Type") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProjectManagerId"); + + b.ToTable("DraftProjects"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.Property<Guid>("ProjectId") + .HasColumnType("uuid"); + + b.Property<int?>("FlexModelVersion") + .HasColumnType("integer"); + + b.Property<Guid?>("LangProjectId") + .HasColumnType("uuid"); + + b.Property<int?>("LexEntryCount") + .HasColumnType("integer"); + + b.HasKey("ProjectId"); + + b.ToTable("FlexProjectMetadata"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<Guid>("OrgId") + .HasColumnType("uuid"); + + b.Property<int>("Role") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrgId"); + + b.HasIndex("UserId", "OrgId") + .IsUnique(); + + b.ToTable("OrgMembers", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.OrgProjects", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<Guid>("OrgId") + .HasColumnType("uuid"); + + b.Property<Guid>("ProjectId") + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("OrgId", "ProjectId") + .IsUnique(); + + b.ToTable("OrgProjects"); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.Property<DateTimeOffset>("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Orgs", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<DateTimeOffset?>("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Description") + .HasColumnType("text"); + + b.Property<bool?>("IsConfidential") + .HasColumnType("boolean"); + + b.Property<DateTimeOffset?>("LastCommit") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset?>("MigratedDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<Guid?>("ParentId") + .HasColumnType("uuid"); + + b.Property<int>("ProjectOrigin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property<int?>("RepoSizeInKb") + .HasColumnType("integer"); + + b.Property<int>("ResetStatus") + .HasColumnType("integer"); + + b.Property<int>("RetentionPolicy") + .HasColumnType("integer"); + + b.Property<int>("Type") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<Guid>("ProjectId") + .HasColumnType("uuid"); + + b.Property<int>("Role") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserId", "ProjectId") + .IsUnique(); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<bool>("CanCreateProjects") + .HasColumnType("boolean"); + + b.Property<Guid?>("CreatedById") + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<string>("Email") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.Property<bool>("EmailVerified") + .HasColumnType("boolean"); + + b.Property<string>("GoogleId") + .HasColumnType("text"); + + b.Property<bool>("IsAdmin") + .HasColumnType("boolean"); + + b.Property<DateTimeOffset>("LastActive") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("LocalizationCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("en"); + + b.Property<bool>("Locked") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property<int?>("PasswordStrength") + .HasColumnType("integer"); + + b.Property<string>("Salt") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<string>("Username") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property<string>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property<string>("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("ClientSecret") + .HasColumnType("text"); + + b.Property<string>("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("DisplayName") + .HasColumnType("text"); + + b.Property<string>("DisplayNames") + .HasColumnType("text"); + + b.Property<string>("JsonWebKeySet") + .HasColumnType("text"); + + b.Property<string>("Permissions") + .HasColumnType("text"); + + b.Property<string>("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property<string>("Properties") + .HasColumnType("text"); + + b.Property<string>("RedirectUris") + .HasColumnType("text"); + + b.Property<string>("Requirements") + .HasColumnType("text"); + + b.Property<string>("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property<string>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property<string>("ApplicationId") + .HasColumnType("text"); + + b.Property<string>("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<DateTime?>("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Properties") + .HasColumnType("text"); + + b.Property<string>("Scopes") + .HasColumnType("text"); + + b.Property<string>("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property<string>("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property<string>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property<string>("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("Description") + .HasColumnType("text"); + + b.Property<string>("Descriptions") + .HasColumnType("text"); + + b.Property<string>("DisplayName") + .HasColumnType("text"); + + b.Property<string>("DisplayNames") + .HasColumnType("text"); + + b.Property<string>("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<string>("Properties") + .HasColumnType("text"); + + b.Property<string>("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property<string>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property<string>("ApplicationId") + .HasColumnType("text"); + + b.Property<string>("AuthorizationId") + .HasColumnType("text"); + + b.Property<string>("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<DateTime?>("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime?>("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Payload") + .HasColumnType("text"); + + b.Property<string>("Properties") + .HasColumnType("text"); + + b.Property<DateTime?>("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property<string>("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ServerCommit", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<Guid>("ClientId") + .HasColumnType("uuid"); + + b.Property<string>("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property<Guid>("ProjectId") + .HasColumnType("uuid"); + + b.ComplexProperty<Dictionary<string, object>>("HybridDateTime", "SIL.Harmony.Core.ServerCommit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property<long>("Counter") + .HasColumnType("bigint"); + + b1.Property<DateTimeOffset>("DateTime") + .HasColumnType("timestamp with time zone"); + }); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("CrdtCommits", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.HasOne("LexCore.Entities.User", "ProjectManager") + .WithMany() + .HasForeignKey("ProjectManagerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ProjectManager"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithOne("FlexProjectMetadata") + .HasForeignKey("LexCore.Entities.FlexProjectMetadata", "ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("LexCore.Entities.ProjectWritingSystems", "WritingSystems", b1 => + { + b1.Property<Guid>("FlexProjectMetadataProjectId") + .HasColumnType("uuid"); + + b1.HasKey("FlexProjectMetadataProjectId"); + + b1.ToTable("FlexProjectMetadata"); + + b1.ToJson("WritingSystems"); + + b1.WithOwner() + .HasForeignKey("FlexProjectMetadataProjectId"); + + b1.OwnsMany("LexCore.Entities.FLExWsId", "AnalysisWss", b2 => + { + b2.Property<Guid>("ProjectWritingSystemsFlexProjectMetadataProjectId") + .HasColumnType("uuid"); + + b2.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b2.Property<bool>("IsActive") + .HasColumnType("boolean"); + + b2.Property<bool>("IsDefault") + .HasColumnType("boolean"); + + b2.Property<string>("Tag") + .IsRequired() + .HasColumnType("text"); + + b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "Id"); + + b2.ToTable("FlexProjectMetadata"); + + b2.WithOwner() + .HasForeignKey("ProjectWritingSystemsFlexProjectMetadataProjectId"); + }); + + b1.OwnsMany("LexCore.Entities.FLExWsId", "VernacularWss", b2 => + { + b2.Property<Guid>("ProjectWritingSystemsFlexProjectMetadataProjectId") + .HasColumnType("uuid"); + + b2.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b2.Property<bool>("IsActive") + .HasColumnType("boolean"); + + b2.Property<bool>("IsDefault") + .HasColumnType("boolean"); + + b2.Property<string>("Tag") + .IsRequired() + .HasColumnType("text"); + + b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "Id"); + + b2.ToTable("FlexProjectMetadata"); + + b2.WithOwner() + .HasForeignKey("ProjectWritingSystemsFlexProjectMetadataProjectId"); + }); + + b1.Navigation("AnalysisWss"); + + b1.Navigation("VernacularWss"); + }); + + b.Navigation("WritingSystems"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.HasOne("LexCore.Entities.Organization", "Organization") + .WithMany("Members") + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Organizations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgProjects", b => + { + b.HasOne("LexCore.Entities.Organization", "Org") + .WithMany() + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Org"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany("Users") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Projects") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.HasOne("LexCore.Entities.User", "CreatedBy") + .WithMany("UsersICreated") + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ServerCommit", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("SIL.Harmony.Core.ChangeEntity<SIL.Harmony.Core.ServerJsonChange>", "ChangeEntities", b1 => + { + b1.Property<Guid>("ServerCommitId") + .HasColumnType("uuid"); + + b1.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b1.Property<string>("Change") + .HasColumnType("text"); + + b1.Property<Guid>("CommitId") + .HasColumnType("uuid"); + + b1.Property<Guid>("EntityId") + .HasColumnType("uuid"); + + b1.Property<int>("Index") + .HasColumnType("integer"); + + b1.HasKey("ServerCommitId", "Id"); + + b1.ToTable("CrdtCommits"); + + b1.ToJson("ChangeEntities"); + + b1.WithOwner() + .HasForeignKey("ServerCommitId"); + }); + + b.Navigation("ChangeEntities"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Navigation("FlexProjectMetadata"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Navigation("Organizations"); + + b.Navigation("Projects"); + + b.Navigation("UsersICreated"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.cs b/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.cs new file mode 100644 index 000000000..65f93b1e3 --- /dev/null +++ b/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LexData.Migrations +{ + /// <inheritdoc /> + public partial class ChangeFkRelationOnCrdtCommitsToPointToProject : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_CrdtCommits_FlexProjectMetadata_ProjectId", + table: "CrdtCommits"); + + migrationBuilder.AddForeignKey( + name: "FK_CrdtCommits_Projects_ProjectId", + table: "CrdtCommits", + column: "ProjectId", + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_CrdtCommits_Projects_ProjectId", + table: "CrdtCommits"); + + migrationBuilder.AddForeignKey( + name: "FK_CrdtCommits_FlexProjectMetadata_ProjectId", + table: "CrdtCommits", + column: "ProjectId", + principalTable: "FlexProjectMetadata", + principalColumn: "ProjectId", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs index 349d2c0da..1a4f9a84e 100644 --- a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs +++ b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs @@ -1312,7 +1312,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("SIL.Harmony.Core.ServerCommit", b => { - b.HasOne("LexCore.Entities.FlexProjectMetadata", null) + b.HasOne("LexCore.Entities.Project", null) .WithMany() .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Cascade) diff --git a/backend/Testing/FwHeadless/MergeFwDataWithHarmonyTests.cs b/backend/Testing/FwHeadless/MergeFwDataWithHarmonyTests.cs new file mode 100644 index 000000000..2f88f429c --- /dev/null +++ b/backend/Testing/FwHeadless/MergeFwDataWithHarmonyTests.cs @@ -0,0 +1,115 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using LexCore.Sync; +using SIL.Harmony.Core; +using Testing.ApiTests; +using Testing.Services; + +namespace Testing.FwHeadless; + +[Trait("Category", "Integration")] +public class MergeFwDataWithHarmonyTests : ApiTestBase, IAsyncLifetime +{ + private async Task<Guid> CopyProjectToNewProject(string newProjectCode, string existingProjectCode) + { + var result = await HttpClient.PostAsync( + $"api/Testing/copyToNewProject?newProjectCode={newProjectCode}&existingProjectCode={existingProjectCode}", + null); + result.EnsureSuccessStatusCode(); + return await result.Content.ReadFromJsonAsync<Guid>(); + } + + private async Task TriggerSync(Guid projectId) + { + var result = await HttpClient.PostAsync($"api/fw-lite/sync/trigger/{projectId}", null); + if (result.IsSuccessStatusCode) return; + var responseString = await result.Content.ReadAsStringAsync(); + Assert.Fail($"trigger failed with error {result.ReasonPhrase}, body: {responseString}" ); + } + + private async Task<SyncResult?> AwaitSyncFinished(Guid projectId) + { + var result = await HttpClient.GetAsync($"api/fw-lite/sync/await-sync-finished/{projectId}"); + result.EnsureSuccessStatusCode(); + return await result.Content.ReadFromJsonAsync<SyncResult>(); + } + + private async Task AddTestCommit(Guid projectId) + { + var entryId = Guid.NewGuid(); + ServerCommit[] serverCommits = + [ + new ServerCommit(Guid.NewGuid()) + { + ChangeEntities = + [ + new ChangeEntity<ServerJsonChange>() + { + Change = JsonSerializer.Deserialize<ServerJsonChange>( + $$""" + { + "$type": "CreateEntryChange", + "LexemeForm": { + "en": "Apple" + }, + "CitationForm": { + "en": "Apple" + }, + "Note": {}, + "EntityId": "{{entryId}}" + } + """ + ) ?? throw new JsonException("unable to deserialize"), + Index = 0, + CommitId = Guid.NewGuid(), + EntityId = entryId + } + ], + ClientId = Guid.NewGuid(), + ProjectId = projectId, + HybridDateTime = new HybridDateTime(DateTime.UtcNow, 0) + } + ]; + var result = await HttpClient.PostAsJsonAsync($"api/crdt/{projectId}/add", serverCommits); + result.EnsureSuccessStatusCode(); + } + + private Guid _projectId; + + public async Task InitializeAsync() + { + await LoginAs("admin"); + var projectCode = Utils.NewProjectCode(); + _projectId = await CopyProjectToNewProject(projectCode, "sena-3"); + } + + public async Task DisposeAsync() + { + await HttpClient.DeleteAsync($"api/project/{_projectId}"); + } + + [Fact] + public async Task TriggerSync_WorksTheFirstTime() + { + await TriggerSync(_projectId); + var result = await AwaitSyncFinished(_projectId); + result.Should().NotBeNull(); + result.CrdtChanges.Should().BeGreaterThan(100); + result.FwdataChanges.Should().Be(0); + } + + [Fact] + public async Task TriggerSync_WorksWithSomeCommits() + { + await TriggerSync(_projectId); + await AwaitSyncFinished(_projectId); + + await AddTestCommit(_projectId); + await TriggerSync(_projectId); + var result = await AwaitSyncFinished(_projectId); + result.Should().NotBeNull(); + result.CrdtChanges.Should().Be(0); + result.FwdataChanges.Should().BeGreaterThan(0); + } +} diff --git a/backend/Testing/Services/Utils.cs b/backend/Testing/Services/Utils.cs index 48117e172..fc896d1e6 100644 --- a/backend/Testing/Services/Utils.cs +++ b/backend/Testing/Services/Utils.cs @@ -27,12 +27,19 @@ public static ProjectConfig GetNewProjectConfig(HgProtocol? protocol = null, boo projectName = projectName[..Math.Min(projectName.Length, 40)]; // make sure the path isn't too long if (protocol.HasValue) projectName += $" ({protocol.Value.ToString()[..5]})"; var id = Guid.NewGuid(); - var shortId = id.ToString().Split("-")[0]; - var projectCode = $"{ToProjectCodeFriendlyString(projectName)}-{shortId}-dev-flex"; + var projectCode = NewProjectCode(projectName, id); var dir = GetNewProjectDir(projectCode, ""); return new ProjectConfig(id, projectName, projectCode, dir, isConfidential, owningOrgId); } + public static string NewProjectCode([CallerMemberName] string projectName = "", Guid? id = null) + { + id ??= Guid.NewGuid(); + var shortId = id.Value.ToString().Split("-")[0]; + var projectCode = $"{ToProjectCodeFriendlyString(projectName)}-{shortId}-dev-flex"; + return projectCode; + } + public static async Task<LexboxProject> RegisterProjectInLexBox( this ApiTestBase apiTester, ProjectConfig config, diff --git a/deployment/base/fw-headless-deployment.yaml b/deployment/base/fw-headless-deployment.yaml index efe6175d8..1f036609f 100644 --- a/deployment/base/fw-headless-deployment.yaml +++ b/deployment/base/fw-headless-deployment.yaml @@ -14,7 +14,7 @@ spec: ports: - name: http protocol: TCP - port: 80 + port: 8081 --- @@ -54,13 +54,13 @@ spec: memory: 2400Mi startupProbe: httpGet: - port: 80 + port: 8081 path: /api/healthz failureThreshold: 30 periodSeconds: 10 timeoutSeconds: 5 ports: - - containerPort: 80 + - containerPort: 8081 volumeMounts: - name: fw-headless @@ -68,7 +68,7 @@ spec: env: - name: DOTNET_URLS - value: http://0.0.0.0:80 + value: http://0.0.0.0:8081 - name: ASPNETCORE_ENVIRONMENT valueFrom: configMapKeyRef: diff --git a/deployment/base/lexbox-deployment.yaml b/deployment/base/lexbox-deployment.yaml index a7c4da81a..7519e385e 100644 --- a/deployment/base/lexbox-deployment.yaml +++ b/deployment/base/lexbox-deployment.yaml @@ -206,7 +206,7 @@ spec: - name: Tus__ResetUploadPath value: /tmp/tus-reset-upload - name: Services__fwHeadless__http__0 - value: fw-headless + value: fw-headless:8081 - name: otel-collector diff --git a/deployment/local-dev/lexbox-deployment.patch.yaml b/deployment/local-dev/lexbox-deployment.patch.yaml index 08ebb18ef..443da3d9c 100644 --- a/deployment/local-dev/lexbox-deployment.patch.yaml +++ b/deployment/local-dev/lexbox-deployment.patch.yaml @@ -18,7 +18,7 @@ spec: $patch: delete resources: requests: - memory: 2Gi + memory: 1Gi limits: memory: 2Gi env: diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 60ab64c05..78b9fdb9f 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -393,6 +393,7 @@ type Project { changesets: [Changeset!]! @cost(weight: "10") hasAbandonedTransactions: Boolean! isLanguageForgeProject: Boolean! @cost(weight: "10") + hasHarmonyCommits: Boolean! @cost(weight: "10") parentId: UUID name: String! description: String diff --git a/frontend/src/hooks.shared.ts b/frontend/src/hooks.shared.ts index f27a00c78..9db709891 100644 --- a/frontend/src/hooks.shared.ts +++ b/frontend/src/hooks.shared.ts @@ -1,23 +1,10 @@ -import { redirect } from '@sveltejs/kit'; +import {redirect} from '@sveltejs/kit'; +import {tryGetErrorMessage} from '$lib/error/utils'; const sayWuuuuuuut = 'We\'re not sure what happened.'; export function getErrorMessage(error: unknown): string { - if (error === null || error === undefined) { - return sayWuuuuuuut; - } else if (typeof error === 'string') { - return error; - } - - const _error = (error ?? {}) as Record<string, string>; - return ( - _error.message ?? - _error.reason ?? - _error.cause ?? - _error.error ?? - _error.code ?? - sayWuuuuuuut - ); + return tryGetErrorMessage(error) ?? sayWuuuuuuut; } export function validateFetchResponse( diff --git a/frontend/src/lib/app.postcss b/frontend/src/lib/app.postcss index eee688940..fd282bccc 100644 --- a/frontend/src/lib/app.postcss +++ b/frontend/src/lib/app.postcss @@ -156,7 +156,7 @@ input[readonly]:focus { the microscopic size they have by default is not what we're looking for. If we ever want to use badges of different sizes, we can revisit this. */ -.badge { +.badge:not(.badge-xs, .badge-sm, .badge-md, .badge-lg) { @apply btn-sm; } @@ -224,3 +224,25 @@ img[src*="onestory-editor-logo"] { /* Ensures dangling letters are not clipped (e.g. "g") */ line-height: 1.3em; } + + +.details { + display: grid; + column-gap: 2rem; + grid-template-columns: 1fr; + @media screen(md) { + grid-template-columns: minmax(0, 1fr) 1fr; + + .detail-item { + grid-column: auto; + } + + > * { + grid-column: span 2; + } + } +} + +.underline-links a { + @apply link hover:contrast-200; +} diff --git a/frontend/src/lib/components/Markdown/NewTabLinkRenderer.svelte b/frontend/src/lib/components/Markdown/NewTabLinkRenderer.svelte index e1b72a91d..f7af88533 100644 --- a/frontend/src/lib/components/Markdown/NewTabLinkRenderer.svelte +++ b/frontend/src/lib/components/Markdown/NewTabLinkRenderer.svelte @@ -10,7 +10,7 @@ <style> .external-link-icon { - margin-bottom: -0.7%; + margin-bottom: -0.4%; font-size: 0.9em; } </style> diff --git a/frontend/src/lib/components/Projects/FlexModelVersionText.svelte b/frontend/src/lib/components/Projects/FlexModelVersionText.svelte index 5adde58e2..c76300237 100644 --- a/frontend/src/lib/components/Projects/FlexModelVersionText.svelte +++ b/frontend/src/lib/components/Projects/FlexModelVersionText.svelte @@ -24,8 +24,7 @@ <script lang="ts"> export let modelVersion: number; - export let extraClass = ''; $: fwModel = versionLookupTable[modelVersion] ?? 'Unknown FieldWorks version'; </script> -<span title="FLEx data model {modelVersion}" class={extraClass}>{fwModel}</span> +<span title="FLEx data model {modelVersion}" class="text-secondary">{fwModel}</span> diff --git a/frontend/src/lib/components/Projects/WritingSystemList.svelte b/frontend/src/lib/components/Projects/WritingSystemList.svelte index cda69106d..b824a26b4 100644 --- a/frontend/src/lib/components/Projects/WritingSystemList.svelte +++ b/frontend/src/lib/components/Projects/WritingSystemList.svelte @@ -6,8 +6,12 @@ export let writingSystems: FlExWsId[] = []; </script> -<BadgeList> - {#each writingSystems as ws} - <WritingSystemBadge tag={ws.tag} isActive={ws.isActive} isDefault={ws.isDefault} /> - {/each} -</BadgeList> +<div class="w-full"> + {#if writingSystems.length > 0} + <BadgeList> + {#each writingSystems as ws} + <WritingSystemBadge tag={ws.tag} isActive={ws.isActive} isDefault={ws.isDefault} /> + {/each} + </BadgeList> + {/if} +</div> diff --git a/frontend/src/lib/components/modals/ConfirmModal.svelte b/frontend/src/lib/components/modals/ConfirmModal.svelte index 4c451a686..4550aae89 100644 --- a/frontend/src/lib/components/modals/ConfirmModal.svelte +++ b/frontend/src/lib/components/modals/ConfirmModal.svelte @@ -2,6 +2,7 @@ import {type IconString, Icon} from '$lib/icons'; import Modal, {DialogResponse} from './Modal.svelte'; import {Button, type ErrorMessage, FormError} from '$lib/forms'; + import t from '$lib/i18n'; export let title: string; export let submitText: string; @@ -9,8 +10,15 @@ export let submitVariant: 'btn-primary' | 'btn-error' = 'btn-primary'; export let cancelText: string; + export let hideActions: boolean = false; + + export let doneText = $t('common.close'); + export let showDoneState = false; + + let done = false; export async function open(onSubmit: () => Promise<ErrorMessage>): Promise<boolean> { + done = false; if ((await modal.openModal()) === DialogResponse.Cancel) { error = undefined; return false; @@ -20,7 +28,8 @@ if (error) { return open(onSubmit); } - modal.close(); + done = true; + if (!showDoneState) modal.close(); error = undefined; return true; } @@ -30,21 +39,27 @@ </script> -<Modal bind:this={modal} showCloseButton={false}> +<Modal bind:this={modal} showCloseButton={false} {hideActions}> <h2 class="text-xl mb-2"> {title} </h2> - <slot/> + <slot {done} {error} /> <FormError {error} right/> - <svelte:fragment slot="actions" let:submitting> - <Button variant={submitVariant} loading={submitting} on:click={() => modal.submitModal()}> - {submitText} - {#if submitIcon} - <Icon icon={submitIcon}/> - {/if} - </Button> - <Button disabled={submitting} on:click={() => modal.cancelModal()}> - {cancelText} - </Button> + <svelte:fragment slot="actions" let:submitting let:close> + {#if !done} + <Button variant={submitVariant} loading={submitting} on:click={() => modal.submitModal()}> + {submitText} + {#if submitIcon} + <Icon icon={submitIcon}/> + {/if} + </Button> + <Button disabled={submitting} on:click={() => modal.cancelModal()}> + {cancelText} + </Button> + {:else} + <Button variant="btn-primary" on:click={close}> + {doneText} + </Button> + {/if} </svelte:fragment> </Modal> diff --git a/frontend/src/lib/components/modals/FormModal.svelte b/frontend/src/lib/components/modals/FormModal.svelte index 3372e7db9..c4956060b 100644 --- a/frontend/src/lib/components/modals/FormModal.svelte +++ b/frontend/src/lib/components/modals/FormModal.svelte @@ -22,13 +22,11 @@ type Schema = $$Generic<ZodObject>; type FormType = z.infer<Schema>; type SubmitCallback = FormSubmitCallback<Schema>; - type FormModalOptions = { - keepOpenOnSubmit?: boolean; - }; export let schema: Schema; export let submitVariant: SubmitVariant = 'btn-primary'; export let hideActions: boolean = false; + export let showDoneState: boolean = false; const superForm = lexSuperForm(schema, () => modal.submitModal()); const { form: _form, errors, reset, message, enhance, formState, tainted } = superForm; @@ -38,13 +36,11 @@ export async function open( value: Partial<FormType> | undefined, //eslint-disable-line @typescript-eslint/no-redundant-type-constituents onSubmit: SubmitCallback, - options?: FormModalOptions, ): Promise<FormModalResult<Schema>>; export async function open(onSubmit: SubmitCallback): Promise<FormModalResult<Schema>>; export async function open( valueOrOnSubmit: Partial<FormType> | SubmitCallback | undefined, //eslint-disable-line @typescript-eslint/no-redundant-type-constituents _onSubmit?: SubmitCallback, - options?: FormModalOptions, ): Promise<FormModalResult<Schema>> { done = false; const onSubmit = _onSubmit ?? (valueOrOnSubmit as SubmitCallback); @@ -57,15 +53,11 @@ const response = await openModal(onSubmit); const _formState = $formState; // we need to read the form state before the modal closes or it will be reset - if (response !== DialogResponse.Submit || !options?.keepOpenOnSubmit) + if (response !== DialogResponse.Submit || !showDoneState) modal?.close(); return { response, formState: _formState }; } - export function close(): void { - modal?.close(); - } - export function form(): Readable<FormType> { return superForm.form; } @@ -88,7 +80,7 @@ } </script> -<Modal bind:this={modal} on:close={() => reset()} bottom closeOnClickOutside={!$tainted}> +<Modal bind:this={modal} on:close={() => reset()} bottom closeOnClickOutside={!$tainted} {hideActions}> <Form id="modalForm" {enhance}> <p class="mb-4 text-lg font-bold"><slot name="title" /></p> <slot errors={$errors} /> @@ -97,17 +89,15 @@ <svelte:fragment slot="extraActions"> <slot name="extraActions" /> </svelte:fragment> - <svelte:fragment slot="actions" let:submitting> - {#if !hideActions} - {#if !done} - <SubmitButton form="modalForm" variant={submitVariant} loading={submitting}> - <slot name="submitText" /> - </SubmitButton> - {:else} - <Button variant="btn-primary" on:click={close}> - <slot name="closeText" /> - </Button> - {/if} + <svelte:fragment slot="actions" let:submitting let:close> + {#if !done} + <SubmitButton form="modalForm" variant={submitVariant} loading={submitting}> + <slot name="submitText" /> + </SubmitButton> + {:else} + <Button variant="btn-primary" on:click={close}> + <slot name="doneText" /> + </Button> {/if} </svelte:fragment> </Modal> diff --git a/frontend/src/lib/components/modals/Modal.svelte b/frontend/src/lib/components/modals/Modal.svelte index 95acf1995..8c3ab2647 100644 --- a/frontend/src/lib/components/modals/Modal.svelte +++ b/frontend/src/lib/components/modals/Modal.svelte @@ -25,6 +25,7 @@ export let bottom = false; export let showCloseButton = true; export let closeOnClickOutside = true; + export let hideActions: boolean = false; export async function openModal(autoCloseOnCancel = true, autoCloseOnSubmit = false): Promise<DialogResponse> { $dialogResponse = null; @@ -114,13 +115,13 @@ </button> {/if} <slot {closing} {submitting} /> - {#if $$slots.actions} + {#if $$slots.actions && !hideActions} <div class="modal-action"> <div class="flex gap-4"> <slot name="extraActions" /> </div> <div class="flex gap-4"> - <slot name="actions" {closing} {submitting} /> + <slot name="actions" {closing} {submitting} {close} /> </div> </div> {/if} diff --git a/frontend/src/lib/error/utils.ts b/frontend/src/lib/error/utils.ts new file mode 100644 index 000000000..1c8a754e1 --- /dev/null +++ b/frontend/src/lib/error/utils.ts @@ -0,0 +1,16 @@ +export function tryGetErrorMessage(error: unknown): string | undefined { + if (error === null || error === undefined) { + return undefined; + } else if (typeof error === 'string') { + return error; + } + + const _error = (error ?? {}) as Record<string, string>; + return ( + _error.message ?? + _error.reason ?? + _error.cause ?? + _error.error ?? + _error.code + ); +} diff --git a/frontend/src/lib/forms/Button.svelte b/frontend/src/lib/forms/Button.svelte index 595e951ba..f0a49227a 100644 --- a/frontend/src/lib/forms/Button.svelte +++ b/frontend/src/lib/forms/Button.svelte @@ -2,6 +2,7 @@ import Loader from '$lib/components/Loader.svelte'; export let loading = false; + export let active = false; export let variant: 'btn-primary' | 'btn-success' | 'btn-error' | 'btn-ghost' | 'btn-warning' | 'btn-accent' | undefined = undefined; export let outline = false; export let type: undefined | 'submit' = undefined; @@ -13,6 +14,7 @@ <!-- https://daisyui.com/components/button --> <button on:click {...$$restProps} class="btn whitespace-nowrap {variant ?? ''} {$$restProps.class ?? ''} {size ?? ''}" {type} class:btn-outline={outline} + class:btn-active={active} disabled={disabled && !loading} class:pointer-events-none={loading || $$restProps.class?.includes('pointer-events-none')}> {#if !customLoader} diff --git a/frontend/src/lib/forms/FormError.svelte b/frontend/src/lib/forms/FormError.svelte index f025ec02f..8613c6e8e 100644 --- a/frontend/src/lib/forms/FormError.svelte +++ b/frontend/src/lib/forms/FormError.svelte @@ -9,7 +9,7 @@ </script> {#if error} - <span class="label text-lg text-error" class:justify-end={right}> + <span class="label text-lg text-error pl-0" class:justify-end={right}> {#if markdown} <Markdown md={error} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} /> {:else} diff --git a/frontend/src/lib/forms/FormField.svelte b/frontend/src/lib/forms/FormField.svelte index 2f3819ea0..bc83b1107 100644 --- a/frontend/src/lib/forms/FormField.svelte +++ b/frontend/src/lib/forms/FormField.svelte @@ -38,7 +38,7 @@ </label> <slot /> {#if description} - <label for={id} class="label pb-0"> + <label for={id} class="label pb-0 underline-links"> <span class="label-text-alt description"> <Markdown md={description} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} /> </span> @@ -46,9 +46,3 @@ {/if} <FormFieldError {id} {error} /> </div> - -<style lang="postcss"> - :global(.form-control .label a) { - @apply link; - } -</style> diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 61096d4b7..b6ff1a77f 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -262,7 +262,36 @@ Lexbox is free and [open source](https://github.com/sillsdev/languageforge-lexbo "project_now_not_confidential": "Project is now not confidential.", } }, - "awaiting_approval": "Awaiting approval" + "awaiting_approval": "Awaiting approval", + "crdt": { + "sync_fwlite": "Sync FieldWorks Lite", + "sync_result": "{fwdataChanges} changes synced to FieldWorks. {crdtChanges} changes synced to FieldWorks Lite.", + "try_fw_lite": "Try FieldWorks Lite?", + "try_info": "This will make your project available in [FieldWorks Lite](https://lexbox.org/fw-lite). \ +This is still experimental and it will affect your FieldWorks project data. \ +However, **all your data is safe and backed up** here on Lexbox. \ +If you run into problems with your project please tell support that you are using FieldWorks Lite. \n\n\ +Are you ready to try FieldWorks Lite?", + "submit": "Yes, please!", + "cancel": "Not yet", + "finish": "Close", + "making_available": "Making your project available in FieldWorks Lite...", + "while_you_wait": "While you're waiting you can: \n\ +1. Install [FieldWorks Lite](https://lexbox.org/fw-lite). \n\ +1. Login to Lexbox from FieldWorks Lite. \n\ +", + "now_available": "Your project is now available in FieldWorks Lite!", + "to_start_using": "To get started: \n\ +1. Install [FieldWorks Lite](https://lexbox.org/fw-lite). \n\ +1. Login to Lexbox from FieldWorks Lite. \n\ +1. Download this project under the Lexbox server section (you may need to refresh the list of projects). \n\ +1. After working in FieldWorks Lite, come back here and click 'Sync FieldWorks Lite'. \n\ +1. Then you can download those changes into FieldWorks by doing a Send/Receive.", + "reach_out_for_help": "Please reach out to us at \ +[lexbox_support@groups.sil.org](mailto:lexbox_support@groups.sil.org?subject={subject}), \ +so we can get this fixed!", + "email_subject": "FieldWorks Lite setup error ({projectCode})", + }, }, "org_page": { "organization": "Organization", @@ -552,6 +581,7 @@ If you don't see a dialog or already closed it, click the button below:", "leave_success": "You have left project {projectName}.", "leave_project": "Leave Project" }, + "using_fw_lite": "Using FieldWorks Lite" }, "org_role": { "label": "Role", @@ -759,6 +789,7 @@ If you don't see a dialog or already closed it, click the button below:", "any": "Any", "yes_no": "{value, select, true {Yes} false {No} other {Unknown}}", "did_you_know": "Did you know?", + "close": "Close", }, "viewer": { "about": "## What is this?\n\ diff --git a/frontend/src/lib/icons/Icon.svelte b/frontend/src/lib/icons/Icon.svelte index 874b49d13..c86e10f0e 100644 --- a/frontend/src/lib/icons/Icon.svelte +++ b/frontend/src/lib/icons/Icon.svelte @@ -19,5 +19,5 @@ </script> {#if icon} - <span class="{icon} {size} {color ?? ''} shrink-0 {spinReverse ? 'transform rotate-180' : ''}" class:pale style:transform class:animate-spin={spin} /> + <span class="{icon} {size} {color ?? ''} shrink-0" class:rotate-180={spinReverse} class:pale style:transform class:animate-spin={spin}></span> {/if} diff --git a/frontend/src/lib/layout/DetailItem.svelte b/frontend/src/lib/layout/DetailItem.svelte index 185a29c22..2c86855fc 100644 --- a/frontend/src/lib/layout/DetailItem.svelte +++ b/frontend/src/lib/layout/DetailItem.svelte @@ -6,14 +6,15 @@ export let text: string | null | undefined = undefined; export let copyToClipboard = false; export let loading = false; + export let wrap = false; </script> -<div class="text-lg flex items-center gap-2"> +<div class="text-lg flex items-center gap-2 detail-item whitespace-nowrap" class:flex-wrap={wrap}> {title}: {#if loading} <Loader loading size="loading-xs" /> {:else if text} - <span class="text-secondary">{text}</span> + <span class="text-secondary x-ellipsis">{text}</span> {:else} <slot/> {/if} diff --git a/frontend/src/lib/layout/DetailsPage.svelte b/frontend/src/lib/layout/DetailsPage.svelte index cab2eca6f..fd554c2fa 100644 --- a/frontend/src/lib/layout/DetailsPage.svelte +++ b/frontend/src/lib/layout/DetailsPage.svelte @@ -21,7 +21,7 @@ <slot name="headerContent" /> </svelte:fragment> {#if $$slots.details} - <div class="my-4 space-y-2"> + <div class="my-4 space-y-2 details"> <p class="text-2xl mb-4">{$t('project_page.summary')}</p> <slot name="details" /> </div> diff --git a/frontend/src/lib/layout/FeatureFlagContent.svelte b/frontend/src/lib/layout/FeatureFlagContent.svelte index 468803d3e..18fa5a86e 100644 --- a/frontend/src/lib/layout/FeatureFlagContent.svelte +++ b/frontend/src/lib/layout/FeatureFlagContent.svelte @@ -3,7 +3,7 @@ import type {FeatureFlag} from '$lib/gql/types'; import {hasFeatureFlag} from '$lib/user'; - export let flag: FeatureFlag; + export let flag: FeatureFlag | keyof typeof FeatureFlag; </script> <!-- eslint-disable-next-line @typescript-eslint/no-unsafe-argument --> {#if hasFeatureFlag($page.data.user, flag)} diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index 9dacafd44..676e90256 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -26,7 +26,7 @@ type RegisterResponseErrors = { type ApiLexboxAudience = 'LexboxApi' | 'Unknown'; -export const allPossibleFlags = Object.values(FeatureFlag) as FeatureFlag[]; +export const allPossibleFlags = Object.values(FeatureFlag); type JwtTokenUser = { sub: string @@ -215,7 +215,7 @@ export function jwtToUser(user: JwtTokenUser): LexAuthUser { } } -export function hasFeatureFlag(user: LexAuthUser, flag: FeatureFlag): boolean { +export function hasFeatureFlag(user: LexAuthUser, flag: FeatureFlag | keyof typeof FeatureFlag): boolean { const searchTerm = flag.replaceAll('_', '').toLowerCase(); return !!user.featureFlags.find(f => f.toLowerCase() === searchTerm); } diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte index 2fb829259..76376d0f6 100644 --- a/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte +++ b/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte @@ -69,7 +69,7 @@ notFoundMembers = data?.bulkAddOrgMembers.bulkAddOrgMembersResult?.notFoundMembers ?? []; existingMembers = data?.bulkAddOrgMembers.bulkAddOrgMembersResult?.existingMembers ?? []; return error?.message; - }, { keepOpenOnSubmit: true }); + }); if (response === DialogResponse.Submit) { await invalidate(`org:${orgId}`); @@ -78,7 +78,7 @@ } </script> -<FormModal bind:this={formModal} {schema} let:errors> +<FormModal bind:this={formModal} {schema} let:errors showDoneState> <span slot="title"> {$t('org_page.bulk_add_members.modal_title')} <SupHelp helpLink={helpLinks.bulkAddCreate} /> @@ -142,7 +142,7 @@ <p>Internal error: unknown step {currentStep}</p> {/if} <span slot="submitText">{$t('org_page.bulk_add_members.submit_button')}</span> - <span slot="closeText">{$t('org_page.bulk_add_members.finish_button')}</span> + <span slot="doneText">{$t('org_page.bulk_add_members.finish_button')}</span> </FormModal> <style lang="postcss"> diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte index f990c18ec..a2ef8c0a3 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte @@ -32,7 +32,7 @@ import {_deleteProject} from '$lib/gql/mutations'; import { goto } from '$app/navigation'; import MoreSettings from '$lib/components/MoreSettings.svelte'; - import { AdminContent, PageBreadcrumb } from '$lib/layout'; + import {AdminContent, FeatureFlagContent, PageBreadcrumb} from '$lib/layout'; import Markdown from 'svelte-exmarkdown'; import { OrgRole, ProjectRole, ProjectType, ResetStatus, RetentionPolicy } from '$lib/gql/generated/graphql'; import Icon from '$lib/icons/Icon.svelte'; @@ -332,8 +332,12 @@ <span class="i-mdi-dictionary text-2xl"/> </a> {/if} + {#if project.type === ProjectType.FlEx} + <FeatureFlagContent flag="FwLiteBeta"> + <CrdtSyncButton {project} hasHarmonyCommits={project.hasHarmonyCommits} /> + </FeatureFlagContent> + {/if} {#if project.type === ProjectType.FlEx && $isDev} - <CrdtSyncButton projectId={project.id} /> <OpenInFlexModal bind:this={openInFlexModal} {project}/> <OpenInFlexButton projectId={project.id} on:click={openInFlexModal.open}/> {:else if canAskToJoinProject} @@ -440,6 +444,11 @@ </Badge> </button> {/if} + {#if project.hasHarmonyCommits} + <Badge> + {$t('project_page.using_fw_lite')} + </Badge> + {/if} </BadgeList> <ProjectConfidentialityModal bind:this={projectConfidentialityModal} projectId={project.id} isConfidential={project.isConfidential ?? undefined} /> </svelte:fragment> @@ -478,32 +487,34 @@ </DetailItem> {/if} {#if project.type === ProjectType.FlEx} - <DetailItem title={$t('project_page.vernacular_langs')}> - <WritingSystemList writingSystems={vernacularLangTags} /> - <AdminContent> - <IconButton - loading={loadingLanguageList} - icon="i-mdi-refresh" - size="btn-sm" - variant="btn-ghost" - outline={false} - on:click={updateLanguageList} - /> - </AdminContent> - </DetailItem> - <DetailItem title={$t('project_page.analysis_langs')}> - <WritingSystemList writingSystems={analysisLangTags} /> - <AdminContent> - <IconButton - loading={loadingLanguageList} - icon="i-mdi-refresh" - size="btn-sm" - variant="btn-ghost" - outline={false} - on:click={updateLanguageList} - /> - </AdminContent> - </DetailItem> + <div class="space-y-2"> + <DetailItem title={$t('project_page.vernacular_langs')} wrap> + <AdminContent> + <IconButton + loading={loadingLanguageList} + icon="i-mdi-refresh" + size="btn-sm" + variant="btn-ghost" + outline={false} + on:click={updateLanguageList} + /> + </AdminContent> + <WritingSystemList writingSystems={vernacularLangTags} /> + </DetailItem> + <DetailItem title={$t('project_page.analysis_langs')} wrap> + <AdminContent> + <IconButton + loading={loadingLanguageList} + icon="i-mdi-refresh" + size="btn-sm" + variant="btn-ghost" + outline={false} + on:click={updateLanguageList} + /> + </AdminContent> + <WritingSystemList writingSystems={analysisLangTags} /> + </DetailItem> + </div> {/if} <div> <EditableDetailItem diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts index 463ab4b2b..1fbb5a833 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts @@ -59,6 +59,7 @@ export async function load(event: PageLoadEvent) { retentionPolicy isConfidential isLanguageForgeProject + hasHarmonyCommits organizations { id } diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte index 3355eb3ac..c32fe3daa 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte @@ -80,7 +80,7 @@ createdMembers = data?.bulkAddProjectMembers.bulkAddProjectMembersResult?.createdMembers ?? []; existingMembers = data?.bulkAddProjectMembers.bulkAddProjectMembersResult?.existingMembers ?? []; return error?.message; - }, { keepOpenOnSubmit: true }); + }); if (response === DialogResponse.Submit) { currentStep = BulkAddSteps.Results; @@ -93,7 +93,7 @@ {$t('project_page.bulk_add_members.add_button')} </BadgeButton> - <FormModal bind:this={formModal} {schema} let:errors> + <FormModal bind:this={formModal} {schema} let:errors showDoneState> <span slot="title"> {$t('project_page.bulk_add_members.modal_title')} <SupHelp helpLink={helpLinks.bulkAddCreate} /> @@ -171,7 +171,7 @@ <p>Internal error: unknown step {currentStep}</p> {/if} <span slot="submitText">{$t('project_page.bulk_add_members.submit_button')}</span> - <span slot="closeText">{$t('project_page.bulk_add_members.finish_button')}</span> + <span slot="doneText">{$t('project_page.bulk_add_members.finish_button')}</span> </FormModal> </AdminContent> diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte index 324c40126..d69d31562 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte @@ -1,43 +1,157 @@ <script lang="ts"> - import {Button} from '$lib/forms'; + import {NewTabLinkRenderer} from '$lib/components/Markdown'; + import {tryGetErrorMessage} from '$lib/error/utils'; + import {Button, FormError} from '$lib/forms'; + import t from '$lib/i18n'; import {Icon} from '$lib/icons'; import {useNotifications} from '$lib/notify'; + import Markdown from 'svelte-exmarkdown'; + import {bounceIn} from 'svelte/easing'; + import {scale} from 'svelte/transition'; + import type {Project} from './+page'; + import {Modal} from '$lib/components/modals'; - export let projectId: string; + export let project: Project; + export let hasHarmonyCommits: boolean; + type SyncResult = {crdtChanges: number, fwdataChanges: number}; - const {notifySuccess, notifyWarning} = useNotifications(); + const { notifySuccess, notifyWarning } = useNotifications(); let syncing = false; + let done = false; + $: state = done ? 'done' : syncing ? 'syncing' : 'idle'; + let error: string | undefined = undefined; - async function triggerSync(): Promise<void> { + async function triggerSync(): Promise<string | undefined> { syncing = true; try { - const response = await fetch(`/api/crdt/sync/${projectId}`, { + const response = await fetch(`/api/fw-lite/sync/trigger/${project.id}`, { method: 'POST', }); if (response.ok) { - const { crdtChanges, fwdataChanges } = await response.json(); - notifySuccess(`Synced successfully (${fwdataChanges} FwData changes. ${crdtChanges} CRDT changes)`); - } else { - const error = `Failed to sync: ${response.statusText} (${response.status})`; - notifyWarning(error); - console.error(error, await response.text()); + const syncResults = await awaitSyncFinished(); + if (typeof syncResults === 'string') { + return syncResults; + } + notifySuccess($t('project.crdt.sync_result', { fwdataChanges: syncResults.fwdataChanges, crdtChanges: syncResults.crdtChanges })); + done = true; + return; } + const error = `Failed to sync: ${response.statusText} (${response.status})`; + notifyWarning(error); + console.error(error, await response.text()); + return error; + } catch (error) { + return tryGetErrorMessage(error); } finally { syncing = false; } } + + async function awaitSyncFinished(): Promise<SyncResult | string> { + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const response = await fetch(`/api/fw-lite/sync/await-sync-finished/${project.id}`, {signal: AbortSignal.timeout(30_000)}); + if (response.status === 500) { + return 'Sync failed, please contact support'; + } + if (response.status === 200) { + const result = await response.json() as SyncResult; + return result; + } + } catch (error) { + if (error instanceof DOMException && (error.name === 'AbortError' || error.name === 'TimeoutError')) { + continue; + } + return tryGetErrorMessage(error) ?? 'Unknown error'; + } + + } + } + + async function onSubmit(): Promise<void> { + error = await triggerSync(); + } + + async function syncProject(): Promise<void> { + let error = await triggerSync(); + if (error) notifyWarning(error); + } + + async function useInFwLite(): Promise<void> { + await modal.openModal(); + } + let modal: Modal; </script> -<Button - variant="btn-primary" - class="gap-1" - on:click={triggerSync} - loading={syncing} - customLoader -> - FwData - <Icon icon="i-mdi-sync" spin={syncing} spinReverse /> - CRDT -</Button> +{#if hasHarmonyCommits} + <Button variant="btn-primary" class="gap-1 indicator" on:click={syncProject} loading={state === 'syncing'} active={state === 'syncing'} customLoader> + <span class="indicator-item badge badge-sm badge-accent translate-x-[calc(50%-16px)] shadow">Beta</span> + {$t('project.crdt.sync_fwlite')} + <span style="transform: rotateY(180deg)"> + <Icon icon="i-mdi-sync" spin={state === 'syncing'} spinReverse/> + </span> + </Button> +{:else} + <Button variant="btn-primary" class="indicator" on:click={useInFwLite}> + <span class="indicator-item badge badge-sm badge-accent translate-x-[calc(50%-16px)] shadow">Beta</span> + <span> + {$t('project.crdt.try_fw_lite')} + </span> + </Button> + <Modal bind:this={modal} showCloseButton={false} hideActions={state === 'syncing'} closeOnClickOutside={false}> + <h2 class="text-xl mb-6"> + {#if state === 'syncing'} + {$t('project.crdt.making_available')} + {:else if state === 'done'} + {$t('project.crdt.now_available')} + {:else} + {$t('project.crdt.try_fw_lite')} + {/if} + </h2> + {#if state === 'syncing'} + <div class="mb-6 prose max-w-none underline-links"> + <Markdown md={$t('project.crdt.while_you_wait')} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} /> + </div> + <p class="text-center"> + <span class="loading loading-lg"></span> + </p> + {:else if state === 'done'} + <div class="prose max-w-none underline-links"> + <Markdown md={$t('project.crdt.to_start_using')} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} /> + <p class="text-center"> + <span + class="i-mdi-check-circle-outline text-7xl text-center text-success" + transition:scale={{ duration: 600, start: 0.7, easing: bounceIn }} + ></span> + </p> + </div> + {:else} + <div class="prose max-w-none underline-links"> + <Markdown md={$t('project.crdt.try_info')} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} /> + {#if error} + <Markdown + md={`${$t('errors.apology')} ${$t('project.crdt.reach_out_for_help', { subject: encodeURIComponent($t('project.crdt.email_subject', { projectCode: project.code }))})}`} + plugins={[{ renderer: { a: NewTabLinkRenderer } }]} /> + {/if} + </div> + <FormError {error} right/> + {/if} + <svelte:fragment slot="actions" let:close> + {#if state === 'idle'} + <Button variant="btn-primary" on:click={onSubmit}> + {$t('project.crdt.submit')} + </Button> + <Button on:click={close}> + {$t('project.crdt.cancel')} + </Button> + {:else if state === 'done'} + <Button variant="btn-primary" on:click={close}> + {$t('project.crdt.finish')} + </Button> + {/if} + </svelte:fragment> + </Modal> +{/if}