diff --git a/backend/FwHeadless/FwHeadlessKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs index 4a7981381..3b7f64a74 100644 --- a/backend/FwHeadless/FwHeadlessKernel.cs +++ b/backend/FwHeadless/FwHeadlessKernel.cs @@ -17,6 +17,7 @@ public static void AddFwHeadless(this IServiceCollection services) .BindConfiguration("FwHeadlessConfig") .ValidateDataAnnotations() .ValidateOnStart(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -26,6 +27,7 @@ public static void AddFwHeadless(this IServiceCollection services) .AddFwDataBridge() .AddFwLiteProjectSync(); services.AddScoped(); + services.AddScoped(); services.AddTransient(); services.AddHttpClient(LexboxHttpClientName, (provider, client) => diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs index 620635504..7f737b755 100644 --- a/backend/FwHeadless/Program.cs +++ b/backend/FwHeadless/Program.cs @@ -1,4 +1,5 @@ using FwHeadless; +using FwHeadless.Services; using FwDataMiniLcmBridge; using FwDataMiniLcmBridge.Api; using FwLiteProjectSync; @@ -10,6 +11,10 @@ using Microsoft.Extensions.Options; using MiniLcm; using Scalar.AspNetCore; +using LexCore.Utils; +using LinqToDB; +using SIL.Harmony.Core; +using SIL.Harmony; var builder = WebApplication.CreateBuilder(args); @@ -36,6 +41,13 @@ await next(); }); +// Load project ID from request +app.Use((context, next) => +{ + var renameThisService = context.RequestServices.GetRequiredService(); + return renameThisService.PopulateProjectContext(context, next); +}); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { @@ -49,6 +61,7 @@ app.MapHealthChecks("/api/healthz"); app.MapPost("/api/crdt-sync", ExecuteMergeRequest); +app.MapGet("/api/crdt-sync-status", GetMergeStatus); app.Run(); @@ -60,6 +73,7 @@ static async Task, NotFound, ProblemHttpResult>> ExecuteM FwDataFactory fwDataFactory, CrdtProjectsService projectsService, ProjectLookupService projectLookupService, + SyncJobStatusService syncStatusService, CrdtFwdataProjectSyncService syncService, CrdtHttpSyncService crdtHttpSyncService, IHttpClientFactory httpClientFactory, @@ -73,6 +87,9 @@ static async Task, NotFound, ProblemHttpResult>> ExecuteM 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) { @@ -103,7 +120,6 @@ static async Task, NotFound, ProblemHttpResult>> ExecuteM var crdtSyncService = services.GetRequiredService(); await crdtSyncService.Sync(); - var result = await syncService.Sync(miniLcmApi, fwdataApi, dryRun); logger.LogInformation("Sync result, CrdtChanges: {CrdtChanges}, FwdataChanges: {FwdataChanges}", result.CrdtChanges, result.FwdataChanges); @@ -113,6 +129,29 @@ static async Task, NotFound, ProblemHttpResult>> ExecuteM return TypedResults.Ok(result); } +static async Task, NotFound>> GetMergeStatus( + ProjectContext projectContext, + ProjectLookupService projectLookupService, + SyncJobStatusService syncJobStatusService, + IServiceProvider services, + LexBoxDbContext lexBoxDb, + Guid projectId) +{ + var jobStatus = syncJobStatusService.SyncStatus(projectId); + if (jobStatus == SyncJobStatus.Running) return TypedResults.Ok(ProjectSyncStatus.Syncing); + var project = projectContext.Project; + if (project is null) + { + // 404 only means "project doesn't exist"; if we don't know the status, then it hasn't synced before and is therefore ready to sync + if (await projectLookupService.ProjectExists(projectId)) return TypedResults.Ok(ProjectSyncStatus.NeverSynced); + else return TypedResults.NotFound(); + } + var commitsOnServer = await lexBoxDb.Set().CountAsync(c => c.ProjectId == projectId); + var lcmCrdtDbContext = services.GetRequiredService(); + var localCommits = await lcmCrdtDbContext.Set().CountAsync(); + return TypedResults.Ok(ProjectSyncStatus.ReadyToSync(commitsOnServer - localCommits)); +} + static async Task SetupFwData(FwDataProject fwDataProject, SendReceiveService srService, string projectCode, diff --git a/backend/FwHeadless/Services/AppVersionService.cs b/backend/FwHeadless/Services/AppVersionService.cs index 4bc753899..0bc49a26a 100644 --- a/backend/FwHeadless/Services/AppVersionService.cs +++ b/backend/FwHeadless/Services/AppVersionService.cs @@ -1,6 +1,6 @@ using System.Reflection; -namespace FwHeadless; +namespace FwHeadless.Services; public static class AppVersionService { diff --git a/backend/FwHeadless/CrdtSyncService.cs b/backend/FwHeadless/Services/CrdtSyncService.cs similarity index 96% rename from backend/FwHeadless/CrdtSyncService.cs rename to backend/FwHeadless/Services/CrdtSyncService.cs index 473227e11..748d45dff 100644 --- a/backend/FwHeadless/CrdtSyncService.cs +++ b/backend/FwHeadless/Services/CrdtSyncService.cs @@ -2,7 +2,7 @@ using LcmCrdt.RemoteSync; using SIL.Harmony; -namespace FwHeadless; +namespace FwHeadless.Services; public class CrdtSyncService( CrdtHttpSyncService httpSyncService, diff --git a/backend/FwHeadless/Services/HttpHelpers.cs b/backend/FwHeadless/Services/HttpHelpers.cs new file mode 100644 index 000000000..663b8fc40 --- /dev/null +++ b/backend/FwHeadless/Services/HttpHelpers.cs @@ -0,0 +1,14 @@ +namespace FwHeadless.Services; + +public static class HttpHelpers +{ + public static Guid? GetProjectId(this HttpContext? context) + { + if (context is null) return null; + if (context.Request.Query.TryGetValue("projectId", out var projectIds) && projectIds.FirstOrDefault() is string idStr) + { + if (Guid.TryParse(idStr, out var projectId)) return projectId; + } + return null; + } +} diff --git a/backend/FwHeadless/Services/ProjectContextFromIdService.cs b/backend/FwHeadless/Services/ProjectContextFromIdService.cs new file mode 100644 index 000000000..56c3ebc40 --- /dev/null +++ b/backend/FwHeadless/Services/ProjectContextFromIdService.cs @@ -0,0 +1,28 @@ +using LcmCrdt; +using Microsoft.Extensions.Options; + +namespace FwHeadless.Services; + +// TODO: Pick better name +public class ProjectContextFromIdService(IOptions config, CrdtProjectsService projectsService, ProjectLookupService projectLookupService) +{ + public async Task PopulateProjectContext(HttpContext context, Func next) + { + if (context.GetProjectId() is Guid projectId) + { + var projectCode = await projectLookupService.GetProjectCode(projectId); + if (projectCode is not null) + { + var projectFolder = Path.Join(config.Value.ProjectStorageRoot, $"{projectCode}-{projectId}"); + var crdtFile = Path.Join(projectFolder, "crdt.sqlite"); + if (File.Exists(crdtFile)) + { + var project = new CrdtProject("crdt", crdtFile); + projectsService.SetProjectScope(project); + await context.RequestServices.GetRequiredService().PopulateProjectDataCache(); + } + } + } + await next(); + } +} diff --git a/backend/FwHeadless/ProjectLookupService.cs b/backend/FwHeadless/Services/ProjectLookupService.cs similarity index 76% rename from backend/FwHeadless/ProjectLookupService.cs rename to backend/FwHeadless/Services/ProjectLookupService.cs index ad3c6fa20..95fd9792f 100644 --- a/backend/FwHeadless/ProjectLookupService.cs +++ b/backend/FwHeadless/Services/ProjectLookupService.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore; using SIL.Harmony.Core; -namespace FwHeadless; +namespace FwHeadless.Services; public class ProjectLookupService(LexBoxDbContext dbContext) { @@ -15,6 +15,11 @@ public class ProjectLookupService(LexBoxDbContext dbContext) return projectCode; } + public async Task ProjectExists(Guid projectId) + { + return await dbContext.Projects.AnyAsync(p => p.Id == projectId); + } + public async Task IsCrdtProject(Guid projectId) { return await dbContext.Set().AnyAsync(c => c.ProjectId == projectId); diff --git a/backend/FwHeadless/Services/ProjectSyncStatusService.cs b/backend/FwHeadless/Services/ProjectSyncStatusService.cs new file mode 100644 index 000000000..77d13ca85 --- /dev/null +++ b/backend/FwHeadless/Services/ProjectSyncStatusService.cs @@ -0,0 +1,49 @@ +using System.Collections.Concurrent; + +namespace FwHeadless.Services; + +public class SyncJobStatusService() +{ + private ConcurrentDictionary Status { get; init; } = new(); + + public void StartSyncing(Guid projectId) + { + Status.AddOrUpdate(projectId, (_) => SyncJobStatus.Running, (_, _) => SyncJobStatus.Running); + } + + public void StopSyncing(Guid projectId) + { + Status.Remove(projectId, out var _); + } + + public SyncJobStatus SyncStatus(Guid projectId) + { + return Status.TryGetValue(projectId, out var status) ? status : SyncJobStatus.NotRunning; + } +} + +public enum SyncJobStatus +{ + NotRunning, + Running, +} + +public enum ProjectSyncStatusEnum +{ + NeverSynced, + ReadyToSync, + Syncing, +} + +// TODO: Bikeshed this name +public record ProjectSyncStatus( + ProjectSyncStatusEnum status, + int ChangesAvailable) +{ + public static ProjectSyncStatus NeverSynced => new(ProjectSyncStatusEnum.NeverSynced, 0); + public static ProjectSyncStatus Syncing => new(ProjectSyncStatusEnum.Syncing, 0); + public static ProjectSyncStatus ReadyToSync(int changes) + { + return new(ProjectSyncStatusEnum.ReadyToSync, changes); + } +} diff --git a/backend/FwHeadless/SendReceiveHelpers.cs b/backend/FwHeadless/Services/SendReceiveHelpers.cs similarity index 99% rename from backend/FwHeadless/SendReceiveHelpers.cs rename to backend/FwHeadless/Services/SendReceiveHelpers.cs index 68cdeddfa..a253445f3 100644 --- a/backend/FwHeadless/SendReceiveHelpers.cs +++ b/backend/FwHeadless/Services/SendReceiveHelpers.cs @@ -1,7 +1,7 @@ using FwDataMiniLcmBridge; using SIL.Progress; -namespace FwHeadless; +namespace FwHeadless.Services; public static class SendReceiveHelpers { diff --git a/backend/FwHeadless/SendReceiveService.cs b/backend/FwHeadless/Services/SendReceiveService.cs similarity index 96% rename from backend/FwHeadless/SendReceiveService.cs rename to backend/FwHeadless/Services/SendReceiveService.cs index a0508ddc3..44d00d274 100644 --- a/backend/FwHeadless/SendReceiveService.cs +++ b/backend/FwHeadless/Services/SendReceiveService.cs @@ -1,8 +1,7 @@ using FwDataMiniLcmBridge; -using FwHeadless.Services; using Microsoft.Extensions.Options; -namespace FwHeadless; +namespace FwHeadless.Services; public class SendReceiveService(IOptions config, SafeLoggingProgress progress) { diff --git a/backend/LfClassicData/LfClassicMiniLcmApi.cs b/backend/LfClassicData/LfClassicMiniLcmApi.cs index cc7945e01..0d38b75cc 100644 --- a/backend/LfClassicData/LfClassicMiniLcmApi.cs +++ b/backend/LfClassicData/LfClassicMiniLcmApi.cs @@ -20,6 +20,11 @@ public IAsyncEnumerable GetComplexFormTypes() return AsyncEnumerable.Empty(); } + public Task GetComplexFormType(Guid id) + { + return Task.FromResult(null); + } + public async Task GetWritingSystems() { var inputSystems = await systemDbContext.Projects.AsQueryable()