diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 6b7095305..4f487990c 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,10 +3,11 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "8.0.10",
+ "version": "9.0.0",
"commands": [
"dotnet-ef"
- ]
+ ],
+ "rollForward": false
}
}
-}
+}
\ No newline at end of file
diff --git a/.github/workflows/develop-api.yaml b/.github/workflows/develop-api.yaml
index 04ddb8c84..17253952f 100644
--- a/.github/workflows/develop-api.yaml
+++ b/.github/workflows/develop-api.yaml
@@ -5,6 +5,7 @@ on:
paths:
- 'backend/**'
- '!backend/FwLite/**'
+ - 'frontend/**'
- '.github/workflows/lexbox-api.yaml'
- '.github/workflows/deploy.yaml'
- 'deployment/lexbox-deployment.yaml'
@@ -14,6 +15,7 @@ on:
paths:
- 'backend/**'
- '!backend/FwLite/**'
+ - 'frontend/**'
- '.github/workflows/lexbox-api.yaml'
- '.github/workflows/deploy.yaml'
- 'deployment/lexbox-deployment.yaml'
diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml
index 37043dfda..f36d825e6 100644
--- a/.github/workflows/fw-lite.yaml
+++ b/.github/workflows/fw-lite.yaml
@@ -35,10 +35,14 @@ jobs:
submodules: true
- uses: actions/setup-dotnet@v4
with:
- dotnet-version: '8.x'
+ dotnet-version: '9.x'
- uses: actions/setup-node@v4
with:
node-version-file: './frontend/package.json'
+
+ - name: Setup Maui
+ run: dotnet workload install maui-windows
+
- name: Set Version
id: setVersion
shell: bash
@@ -86,7 +90,7 @@ jobs:
path: frontend/viewer/dist
- uses: actions/setup-dotnet@v4
with:
- dotnet-version: '8.x'
+ dotnet-version: '9.x'
- name: Dotnet build
working-directory: backend/FwLite/LocalWebApp
@@ -123,7 +127,7 @@ jobs:
path: frontend/viewer/dist
- uses: actions/setup-dotnet@v4
with:
- dotnet-version: '8.x'
+ dotnet-version: '9.x'
- name: Dotnet build
working-directory: backend/FwLite/LocalWebApp
@@ -156,7 +160,7 @@ jobs:
path: frontend/viewer/dist
- uses: actions/setup-dotnet@v4
with:
- dotnet-version: '8.x'
+ dotnet-version: '9.x'
- name: Setup Maui
run: dotnet workload install maui-windows
diff --git a/.github/workflows/integration-test-gha.yaml b/.github/workflows/integration-test-gha.yaml
index a604eea41..1e2315e21 100644
--- a/.github/workflows/integration-test-gha.yaml
+++ b/.github/workflows/integration-test-gha.yaml
@@ -22,6 +22,9 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: true
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.x'
- name: Install Task
uses: arduino/setup-task@v2
with:
diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml
index d35311121..e39d17716 100644
--- a/.github/workflows/integration-test.yaml
+++ b/.github/workflows/integration-test.yaml
@@ -85,7 +85,7 @@ jobs:
env:
DOTNET_INSTALL_DIR: ${{ inputs.runs-on == 'self-hosted' && '/opt/hostedtoolcache/dotnet' || '' }} #poor man's conditional
with:
- dotnet-version: '8.x'
+ dotnet-version: '9.x'
- uses: MatteoH2O1999/setup-python@429b7dee8a48c31eb72ce0b420ea938ff51c2f11 # v3.2.1
id: python
if: ${{ inputs.runs-on != 'windows-latest' && !env.act && inputs.hg-version == '3' }}
diff --git a/.github/workflows/lexbox-api.yaml b/.github/workflows/lexbox-api.yaml
index d0774bcd0..135e5ae95 100644
--- a/.github/workflows/lexbox-api.yaml
+++ b/.github/workflows/lexbox-api.yaml
@@ -49,7 +49,7 @@ jobs:
submodules: true
- uses: actions/setup-dotnet@v4
with:
- dotnet-version: '8.x'
+ dotnet-version: '9.x'
- name: Install Task
uses: arduino/setup-task@v2
with:
diff --git a/LexBox.sln b/LexBox.sln
index 612c0e95e..cfa667dcd 100644
--- a/LexBox.sln
+++ b/LexBox.sln
@@ -21,7 +21,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testing", "backend\Testing\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FixFwData", "backend\FixFwData\FixFwData.csproj", "{D7FC8B93-15A1-4D0B-9EAB-45596DB147F4}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LfNext", "LfNext", "{7B6E21C4-5AF4-4505-B7D9-59A3886C5090}"
+ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FwLite", "FwLite", "{7B6E21C4-5AF4-4505-B7D9-59A3886C5090}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LfClassicData", "backend\LfClassicData\LfClassicData.csproj", "{E8BB768B-C3DC-4BE6-9B9F-82319E05AF86}"
EndProject
@@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiniLcm.Tests", "backend\Fw
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwHeadless", "backend\FwHeadless\FwHeadless.csproj", "{ECBA46AB-AF87-4D4D-9716-FD77264B817F}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwLiteShared", "backend\FwLite\FwLiteShared\FwLiteShared.csproj", "{73DC604C-C501-410D-B56B-0544AD6EF1C2}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -145,6 +147,10 @@ Global
{ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -165,6 +171,7 @@ Global
{5A9011D8-6EC1-4550-BDD7-AFF00DB2B921} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
{00AE5440-0E36-4488-935B-5B11301BA57D} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
{ECBA46AB-AF87-4D4D-9716-FD77264B817F} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
+ {73DC604C-C501-410D-B56B-0544AD6EF1C2} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {440AE83C-6DB0-4F18-B2C1-BCD33F0645B6}
diff --git a/Taskfile.yml b/Taskfile.yml
index 0306ea674..ec5513e7e 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -68,7 +68,12 @@ tasks:
cmds:
- tilt up
- # dev
+ prod-ui-up:
+ interactive: true
+ desc: 'Starts the cluster using the production build of UI, good for automated testing'
+ cmds:
+ - tilt up -- --prod-ui-build
+ # dev
infra-up:
desc: 'Starts infrastructure for our ui and api, does not forward ports for api, if you want port forwarding use k8s:infra-forward'
cmds:
diff --git a/Tiltfile b/Tiltfile
index 24b320e75..224a00bcf 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -4,8 +4,10 @@
version_settings(constraint='>=0.33.20')
secret_settings(disable_scrub=True)
config.define_bool("lexbox-api-local")
+config.define_bool("prod-ui-build")
cfg = config.parse()
forward_lexbox = not cfg.get("lexbox-api-local", False)
+prod_ui_build = cfg.get("prod-ui-build", False)
docker_build(
'local-dev-init',
@@ -28,21 +30,28 @@ docker_build(
context='backend',
dockerfile='./backend/FwHeadless/dev.Dockerfile',
only=['.'],
- ignore=['LexBoxApi'],
+ ignore=['LexBoxApi', '**/Mercurial', '**/MercurialExtensions'],
live_update=[
sync('backend', '/src/backend')
]
)
-
-docker_build(
- 'ghcr.io/sillsdev/lexbox-ui',
- context='frontend',
- dockerfile='./frontend/dev.Dockerfile',
- only=['.'],
- live_update=[
- sync('frontend', '/app'),
- ]
-)
+if prod_ui_build:
+ docker_build(
+ 'ghcr.io/sillsdev/lexbox-ui',
+ context='frontend',
+ dockerfile='./frontend/Dockerfile',
+ only=['.']
+ )
+else:
+ docker_build(
+ 'ghcr.io/sillsdev/lexbox-ui',
+ context='frontend',
+ dockerfile='./frontend/dev.Dockerfile',
+ only=['.'],
+ live_update=[
+ sync('frontend', '/app'),
+ ]
+ )
docker_build(
'ghcr.io/sillsdev/lexbox-hgweb',
diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props
index e70320586..574e961ef 100644
--- a/backend/Directory.Build.props
+++ b/backend/Directory.Build.props
@@ -9,7 +9,10 @@
$(MSBuildProjectDirectory)/bin/container/
+ dev
+ net9.0
false
+ enable
enable
Nullable
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 4c4630a7f..3a43c094f 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,10 +1,10 @@
# syntax=docker/dockerfile:1
-FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
-FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
COPY . .
# WORKDIR /src
diff --git a/backend/FixFwData/FixFwData.csproj b/backend/FixFwData/FixFwData.csproj
index 7c217f173..e96561fca 100644
--- a/backend/FixFwData/FixFwData.csproj
+++ b/backend/FixFwData/FixFwData.csproj
@@ -2,14 +2,17 @@
WinExe
- net8.0
- enable
- enable
+ FixFwData
+ FixFwData
+ SIL Global
+ SIL Global
+ LexBoxApi Testing
+ Copyright © 2024 SIL Global
-
-
+
+
diff --git a/backend/FwHeadless/FwHeadless.csproj b/backend/FwHeadless/FwHeadless.csproj
index 82dcf0521..a106c1fa5 100644
--- a/backend/FwHeadless/FwHeadless.csproj
+++ b/backend/FwHeadless/FwHeadless.csproj
@@ -1,17 +1,15 @@
- net9.0
- enable
- enable
$(MSBuildProjectDirectory)
-
+
+
diff --git a/backend/FwHeadless/HttpClientAuthHandler.cs b/backend/FwHeadless/HttpClientAuthHandler.cs
index cc3efa799..41d364334 100644
--- a/backend/FwHeadless/HttpClientAuthHandler.cs
+++ b/backend/FwHeadless/HttpClientAuthHandler.cs
@@ -34,7 +34,10 @@ protected override async Task SendAsync(HttpRequestMessage
private async Task SetAuthHeader(HttpRequestMessage request, CancellationToken cancellationToken, Uri lexboxUrl)
{
var cookieContainer = new CookieContainer();
- cookieContainer.Add(new Cookie(LexAuthConstants.AuthCookieName, await GetToken(cancellationToken), null, lexboxUrl.Authority));
+ cookieContainer.Add(new Cookie(LexAuthConstants.AuthCookieName, await GetToken(cancellationToken), null, lexboxUrl.Host)
+ {
+ Port = $"\"{lexboxUrl.Port}\""
+ });
request.Headers.Add("Cookie", cookieContainer.GetCookieHeader(lexboxUrl));
}
diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs
index e4e897016..620635504 100644
--- a/backend/FwHeadless/Program.cs
+++ b/backend/FwHeadless/Program.cs
@@ -4,6 +4,7 @@
using FwLiteProjectSync;
using LcmCrdt;
using LcmCrdt.RemoteSync;
+using LexCore.Sync;
using LexData;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.Options;
@@ -51,13 +52,13 @@
app.Run();
-static async Task, NotFound, ProblemHttpResult>> ExecuteMergeRequest(
+static async Task, NotFound, ProblemHttpResult>> ExecuteMergeRequest(
ILogger logger,
IServiceProvider services,
SendReceiveService srService,
IOptions config,
FwDataFactory fwDataFactory,
- ProjectsService projectsService,
+ CrdtProjectsService projectsService,
ProjectLookupService projectLookupService,
CrdtFwdataProjectSyncService syncService,
CrdtHttpSyncService crdtHttpSyncService,
@@ -69,7 +70,7 @@
if (dryRun)
{
logger.LogInformation("Dry run, not actually syncing");
- return TypedResults.Ok(new CrdtFwdataProjectSyncService.SyncResult(0, 0));
+ return TypedResults.Ok(new SyncResult(0, 0));
}
var projectCode = await projectLookupService.GetProjectCode(projectId);
@@ -136,7 +137,7 @@ static async Task SetupFwData(FwDataProject fwDataProject,
static async Task SetupCrdtProject(string crdtFile,
ProjectLookupService projectLookupService,
Guid projectId,
- ProjectsService projectsService,
+ CrdtProjectsService projectsService,
string projectFolder,
Guid fwProjectId,
string lexboxUrl)
diff --git a/backend/FwHeadless/dev.Dockerfile b/backend/FwHeadless/dev.Dockerfile
index db60a422c..38a83bf9d 100644
--- a/backend/FwHeadless/dev.Dockerfile
+++ b/backend/FwHeadless/dev.Dockerfile
@@ -11,13 +11,13 @@ WORKDIR /src/backend
# Uncomment line below if second COPY fails
# RUN mkdir -p FwLite && chown www-data:www-data FwLite
# Copy the main source project files
-COPY --chown=www-data:www-data *.sln FwHeadless/FwHeadless.csproj FixFwData/FixFwData.csproj LexCore/LexCore.csproj LexData/LexData.csproj ./
+COPY --chown=www-data:www-data *.sln FwHeadless/FwHeadless.csproj FixFwData/FixFwData.csproj LexCore/LexCore.csproj LexData/LexData.csproj Directory.Build.props ./
# move them into the proper sub folders, based on the name of the project
RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p ${dir}/ && mv -v $file ${dir}/; done
# Do the same for csproj files in slightly different hierarchies
COPY --chown=www-data:www-data harmony/src/*/*.csproj ./
RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p harmony/src/${dir}/ && mv -v $file harmony/src/${dir}/; done
-COPY --chown=www-data:www-data harmony/src/Directory.Build.props ./harmony/src/
+COPY --chown=www-data:www-data harmony/src/Directory.Build.props harmony/Directory.Packages.props ./harmony/src/
COPY --chown=www-data:www-data FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj FwLite/LcmCrdt/LcmCrdt.csproj FwLite/MiniLcm/MiniLcm.csproj FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj ./
RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p FwLite/${dir}/ && mv -v $file FwLite/${dir}/; done
diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs
index cc951c27d..84a55beee 100644
--- a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs
+++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs
@@ -6,13 +6,16 @@ namespace FwDataMiniLcmBridge.Tests.Fixtures;
public static class FwDataTestsKernel
{
- public static IServiceCollection AddTestFwDataBridge(this IServiceCollection services)
+ public static IServiceCollection AddTestFwDataBridge(this IServiceCollection services, bool mockProjectLoader = true)
{
services.AddFwDataBridge();
services.AddSingleton(_ => new ConfigurationRoot([]));
- services.AddSingleton();
- services.AddSingleton(sp => sp.GetRequiredService());
- services.AddSingleton();
+ if (mockProjectLoader)
+ {
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton();
+ }
return services;
}
}
diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/MockFwProjectList.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/MockFwProjectList.cs
index 43e3a65b8..6463670c1 100644
--- a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/MockFwProjectList.cs
+++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/MockFwProjectList.cs
@@ -7,7 +7,7 @@ public class MockFwProjectList(IOptions config, MockFwProjec
{
public override IEnumerable EnumerateProjects()
{
- return loader.Projects.Keys.Select(k => new FwDataProject(k, config.Value.ProjectsFolder));
+ return loader.Projects.Keys.Select(k => new FwDataProject(k, _config.Value.ProjectsFolder));
}
public override FwDataProject? GetProject(string name)
diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj b/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj
index 13c53ae52..c7f4be0c0 100644
--- a/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj
+++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj
@@ -1,9 +1,6 @@
- net8.0
- enable
- enable
false
true
@@ -18,16 +15,16 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
-
+
+
+
+
+
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs
new file mode 100644
index 000000000..5681d7e93
--- /dev/null
+++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs
@@ -0,0 +1,12 @@
+using FwDataMiniLcmBridge.Tests.Fixtures;
+
+namespace FwDataMiniLcmBridge.Tests.MiniLcmTests;
+
+[Collection(ProjectLoaderFixture.Name)]
+public class SortingTests(ProjectLoaderFixture fixture) : SortingTestsBase
+{
+ protected override Task NewApi()
+ {
+ return Task.FromResult(fixture.NewProjectApi("sorting-test", "en", "en"));
+ }
+}
diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/UpdateComplexFormsTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/UpdateComplexFormsTests.cs
index 7499f06e3..acc4c96bf 100644
--- a/backend/FwLite/FwDataMiniLcmBridge.Tests/UpdateComplexFormsTests.cs
+++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/UpdateComplexFormsTests.cs
@@ -34,7 +34,7 @@ await _api.UpdateEntry(complexForm.Id,
ComplexFormComponent.FromEntries(complexForm, component)));
var entry = await _api.GetEntry(complexForm.Id);
entry.Should().NotBeNull();
- entry!.Components.Should()
+ entry.Components.Should()
.ContainSingle(c => c.ComponentEntryId == component.Id && c.ComplexFormEntryId == complexForm.Id);
}
@@ -63,7 +63,7 @@ await _api.UpdateEntry(complexForm.Id,
new UpdateObjectInput().Remove(e => e.Components, 0));
var entry = await _api.GetEntry(complexForm.Id);
entry.Should().NotBeNull();
- entry!.Components.Should().BeEmpty();
+ entry.Components.Should().BeEmpty();
}
[Fact]
@@ -92,7 +92,7 @@ await _api.UpdateEntry(complexForm.Id,
new UpdateObjectInput().Set(e => e.Components[0].ComponentEntryId, component2.Id));
var entry = await _api.GetEntry(complexForm.Id);
entry.Should().NotBeNull();
- var complexFormComponent = entry!.Components.Should().ContainSingle().Subject;
+ var complexFormComponent = entry.Components.Should().ContainSingle().Subject;
complexFormComponent.ComponentEntryId.Should().Be(component2.Id);
}
@@ -127,7 +127,7 @@ await _api.UpdateEntry(complexForm.Id,
new UpdateObjectInput().Set(e => e.Components[0].ComponentSenseId, component2SenseId));
var entry = await _api.GetEntry(complexForm.Id);
entry.Should().NotBeNull();
- var complexFormComponent = entry!.Components.Should().ContainSingle().Subject;
+ var complexFormComponent = entry.Components.Should().ContainSingle().Subject;
complexFormComponent.ComponentEntryId.Should().Be(component2.Id);
complexFormComponent.ComponentSenseId.Should().Be(component2SenseId);
}
@@ -163,7 +163,7 @@ await _api.UpdateEntry(complexForm.Id,
new UpdateObjectInput().Set(e => e.Components[0].ComponentSenseId, null));
var entry = await _api.GetEntry(complexForm.Id);
entry.Should().NotBeNull();
- entry!.Components.Should()
+ entry.Components.Should()
.ContainSingle(c => c.ComponentEntryId == component2.Id && c.ComponentSenseId == null);
}
@@ -193,7 +193,7 @@ await _api.UpdateEntry(complexForm.Id,
new UpdateObjectInput().Set(e => e.Components[0].ComplexFormEntryId, complexForm2.Id));
var entry = await _api.GetEntry(complexForm2.Id);
entry.Should().NotBeNull();
- var complexFormComponent = entry!.Components.Should().ContainSingle().Subject;
+ var complexFormComponent = entry.Components.Should().ContainSingle().Subject;
complexFormComponent.ComponentEntryId.Should().Be(component1.Id);
}
@@ -208,7 +208,7 @@ await _api.UpdateEntry(component.Id,
ComplexFormComponent.FromEntries(complexForm, component)));
var entry = await _api.GetEntry(component.Id);
entry.Should().NotBeNull();
- entry!.ComplexForms.Should()
+ entry.ComplexForms.Should()
.ContainSingle(c => c.ComponentEntryId == component.Id && c.ComplexFormEntryId == complexForm.Id);
}
@@ -237,7 +237,7 @@ await _api.UpdateEntry(component.Id,
new UpdateObjectInput().Remove(e => e.ComplexForms, 0));
var entry = await _api.GetEntry(component.Id);
entry.Should().NotBeNull();
- entry!.ComplexForms.Should().BeEmpty();
+ entry.ComplexForms.Should().BeEmpty();
}
[Fact]
@@ -266,7 +266,7 @@ await _api.UpdateEntry(component1.Id,
new UpdateObjectInput().Set(e => e.ComplexForms[0].ComplexFormEntryId, complexForm2.Id));
var entry = await _api.GetEntry(component1.Id);
entry.Should().NotBeNull();
- var complexFormComponent = entry!.ComplexForms.Should().ContainSingle().Subject;
+ var complexFormComponent = entry.ComplexForms.Should().ContainSingle().Subject;
complexFormComponent.ComplexFormEntryId.Should().Be(complexForm2.Id);
}
@@ -296,7 +296,7 @@ await _api.UpdateEntry(component1.Id,
new UpdateObjectInput().Set(e => e.ComplexForms[0].ComponentEntryId, component2.Id));
var entry = await _api.GetEntry(component2.Id);
entry.Should().NotBeNull();
- var complexFormComponent = entry!.ComplexForms.Should().ContainSingle().Subject;
+ var complexFormComponent = entry.ComplexForms.Should().ContainSingle().Subject;
complexFormComponent.ComponentEntryId.Should().Be(component2.Id);
complexFormComponent.ComplexFormEntryId.Should().Be(complexFormId);
}
@@ -338,7 +338,7 @@ await _api.UpdateEntry(component1.Id,
new UpdateObjectInput().Set(e => e.ComplexForms[0].ComponentSenseId, component1SenseId2));
var entry = await _api.GetEntry(component1.Id);
entry.Should().NotBeNull();
- var complexFormComponent = entry!.ComplexForms.Should().ContainSingle().Subject;
+ var complexFormComponent = entry.ComplexForms.Should().ContainSingle().Subject;
complexFormComponent.ComponentEntryId.Should().Be(componentId1);
complexFormComponent.ComponentSenseId.Should().Be(component1SenseId2);
}
@@ -353,7 +353,7 @@ await _api.UpdateEntry(complexForm.Id,
new UpdateObjectInput().Add(e => e.ComplexFormTypes, complexFormType));
var entry = await _api.GetEntry(complexForm.Id);
entry.Should().NotBeNull();
- entry!.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id);
+ entry.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id);
}
[Fact]
@@ -369,7 +369,7 @@ await _api.UpdateEntry(complexForm.Id,
new UpdateObjectInput().Remove(e => e.ComplexFormTypes, 0));
var entry = await _api.GetEntry(complexForm.Id);
entry.Should().NotBeNull();
- entry!.ComplexFormTypes.Should().BeEmpty();
+ entry.ComplexFormTypes.Should().BeEmpty();
}
[Fact]
@@ -382,6 +382,6 @@ await _api.UpdateEntry(complexForm.Id,
new UpdateObjectInput().Set(e => e.ComplexFormTypes[0].Id, complexFormType2.Id));
var entry = await _api.GetEntry(complexForm.Id);
entry.Should().NotBeNull();
- entry!.ComplexFormTypes.Should().ContainSingle().Which.Id.Should().Be(complexFormType2.Id);
+ entry.ComplexFormTypes.Should().ContainSingle().Which.Id.Should().Be(complexFormType2.Id);
}
}
diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
index 900649e58..899823455 100644
--- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
+++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
@@ -2,12 +2,15 @@
using System.Globalization;
using System.Reflection;
using System.Text;
+using FluentValidation;
using FwDataMiniLcmBridge.Api.UpdateProxy;
using FwDataMiniLcmBridge.LcmUtils;
using Microsoft.Extensions.Logging;
using MiniLcm;
+using MiniLcm.Exceptions;
using MiniLcm.Models;
using MiniLcm.SyncHelpers;
+using MiniLcm.Validators;
using SIL.LCModel;
using SIL.LCModel.Core.KernelInterfaces;
using SIL.LCModel.Core.Text;
@@ -17,7 +20,7 @@
namespace FwDataMiniLcmBridge.Api;
-public class FwDataMiniLcmApi(Lazy cacheLazy, bool onCloseSave, ILogger logger, FwDataProject project) : IMiniLcmApi, IDisposable
+public class FwDataMiniLcmApi(Lazy cacheLazy, bool onCloseSave, ILogger logger, FwDataProject project, MiniLcmValidators validators) : IMiniLcmApi, IDisposable
{
internal LcmCache Cache => cacheLazy.Value;
public FwDataProject Project { get; } = project;
@@ -37,6 +40,8 @@ public class FwDataMiniLcmApi(Lazy cacheLazy, bool onCloseSave, ILogge
private ICmTranslationFactory CmTranslationFactory => Cache.ServiceLocator.GetInstance();
private ICmPossibilityRepository CmPossibilityRepository => Cache.ServiceLocator.GetInstance();
private ICmPossibilityList ComplexFormTypes => Cache.LangProject.LexDbOA.ComplexEntryTypesOA;
+ private IEnumerable ComplexFormTypesFlattened => ComplexFormTypes.PossibilitiesOS.Cast().Flatten();
+
private ICmPossibilityList VariantTypes => Cache.LangProject.LexDbOA.VariantEntryTypesOA;
public void Dispose()
@@ -132,6 +137,11 @@ private WritingSystem FromLcmWritingSystem(CoreWritingSystemDefinition ws, int i
};
}
+ public Task GetWritingSystem(WritingSystemId id, WritingSystemType type)
+ {
+ throw new NotImplementedException();
+ }
+
internal void CompleteExemplars(WritingSystems writingSystems)
{
var wsExemplars = writingSystems.Vernacular.Concat(writingSystems.Analysis)
@@ -183,9 +193,36 @@ public Task CreateWritingSystem(WritingSystemType type, WritingSy
return Task.FromResult(FromLcmWritingSystem(ws, index, type));
}
- public Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update)
+ public async Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update)
{
- throw new NotImplementedException();
+ if (!Cache.ServiceLocator.WritingSystemManager.TryGet(id.Code, out var lcmWritingSystem))
+ {
+ throw new InvalidOperationException($"Writing system {id.Code} not found");
+ }
+ await Cache.DoUsingNewOrCurrentUOW("Update WritingSystem",
+ "Revert WritingSystem",
+ async () =>
+ {
+ var updateProxy = new UpdateWritingSystemProxy(lcmWritingSystem, this)
+ {
+ Id = Guid.Empty,
+ Type = type,
+ };
+ update.Apply(updateProxy);
+ updateProxy.CommitUpdate(Cache);
+ });
+ return await GetWritingSystem(id, type);
+ }
+
+ public async Task UpdateWritingSystem(WritingSystem before, WritingSystem after)
+ {
+ await Cache.DoUsingNewOrCurrentUOW("Update WritingSystem",
+ "Revert WritingSystem",
+ async () =>
+ {
+ await WritingSystemSync.Sync(after, before, this);
+ });
+ return await GetWritingSystem(after.WsId, after.Type) ?? throw new NullReferenceException($"unable to find {after.Type} writing system with id {after.WsId}");
}
public IAsyncEnumerable GetPartsOfSpeech()
@@ -235,6 +272,12 @@ public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after)
+ {
+ await PartOfSpeechSync.Sync(before, after, this);
+ return await GetPartOfSpeech(after.Id) ?? throw new NullReferenceException($"unable to find part of speech with id {after.Id}");
+ }
+
public Task DeletePartOfSpeech(Guid id)
{
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Part of Speech",
@@ -306,6 +349,12 @@ public Task UpdateSemanticDomain(Guid id, UpdateObjectInput UpdateSemanticDomain(SemanticDomain before, SemanticDomain after)
+ {
+ await SemanticDomainSync.Sync(before, after, this);
+ return await GetSemanticDomain(after.Id) ?? throw new NullReferenceException($"unable to find semantic domain with id {after.Id}");
+ }
+
public Task DeleteSemanticDomain(Guid id)
{
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Semantic Domain",
@@ -326,18 +375,16 @@ public Task DeleteSemanticDomain(Guid id)
public IAsyncEnumerable GetComplexFormTypes()
{
- return ComplexFormTypes.PossibilitiesOS
- .Select(ToComplexFormType)
- .ToAsyncEnumerable();
+ return ComplexFormTypesFlattened.Select(ToComplexFormType).ToAsyncEnumerable();
}
-
- private ComplexFormType ToComplexFormType(ICmPossibility t)
+ private ComplexFormType ToComplexFormType(ILexEntryType t)
{
return new ComplexFormType() { Id = t.Guid, Name = FromLcmMultiString(t.Name) };
}
- public Task CreateComplexFormType(ComplexFormType complexFormType)
+ public async Task CreateComplexFormType(ComplexFormType complexFormType)
{
+ await validators.ValidateAndThrow(complexFormType);
if (complexFormType.Id == default) complexFormType.Id = Guid.NewGuid();
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create complex form type",
"Remove complex form type",
@@ -350,7 +397,7 @@ public Task CreateComplexFormType(ComplexFormType complexFormTy
ComplexFormTypes.PossibilitiesOS.Add(lexComplexFormType);
UpdateLcmMultiString(lexComplexFormType.Name, complexFormType.Name);
});
- return Task.FromResult(ToComplexFormType(ComplexFormTypes.PossibilitiesOS.Single(c => c.Guid == complexFormType.Id)));
+ return ToComplexFormType(ComplexFormTypesFlattened.Single(c => c.Guid == complexFormType.Id));
}
public IAsyncEnumerable GetVariantTypes()
@@ -390,6 +437,15 @@ private Entry FromLexEntry(ILexEntry entry)
};
}
+ private string LexEntryHeadword(ILexEntry entry)
+ {
+ return new Entry()
+ {
+ LexemeForm = FromLcmMultiString(entry.LexemeFormOA.Form),
+ CitationForm = FromLcmMultiString(entry.CitationForm),
+ }.Headword();
+ }
+
private IList ToComplexFormTypes(ILexEntry entry)
{
return entry.ComplexFormEntryRefs.SingleOrDefault()
@@ -438,9 +494,9 @@ private ComplexFormComponent ToEntryReference(ILexEntry component, ILexEntry com
return new ComplexFormComponent
{
ComponentEntryId = component.Guid,
- ComponentHeadword = component.HeadWord.Text,
+ ComponentHeadword = LexEntryHeadword(component),
ComplexFormEntryId = complexEntry.Guid,
- ComplexFormHeadword = complexEntry.HeadWord.Text
+ ComplexFormHeadword = LexEntryHeadword(complexEntry)
};
}
@@ -452,7 +508,7 @@ private ComplexFormComponent ToSenseReference(ILexSense componentSense, ILexEntr
ComponentSenseId = componentSense.Guid,
ComponentHeadword = componentSense.Entry.HeadWord.Text,
ComplexFormEntryId = complexEntry.Guid,
- ComplexFormHeadword = complexEntry.HeadWord.Text
+ ComplexFormHeadword = LexEntryHeadword(complexEntry)
};
}
@@ -559,40 +615,48 @@ public IAsyncEnumerable SearchEntries(string query, QueryOptions? options
public async Task CreateEntry(Entry entry)
{
entry.Id = entry.Id == default ? Guid.NewGuid() : entry.Id;
- UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Entry",
- "Remove entry",
- Cache.ServiceLocator.ActionHandler,
- () =>
- {
- var lexEntry = LexEntryFactory.Create(entry.Id, Cache.ServiceLocator.GetInstance().Singleton.LexDbOA);
- lexEntry.LexemeFormOA = Cache.ServiceLocator.GetInstance().Create();
- UpdateLcmMultiString(lexEntry.LexemeFormOA.Form, entry.LexemeForm);
- UpdateLcmMultiString(lexEntry.CitationForm, entry.CitationForm);
- UpdateLcmMultiString(lexEntry.LiteralMeaning, entry.LiteralMeaning);
- UpdateLcmMultiString(lexEntry.Comment, entry.Note);
-
- foreach (var sense in entry.Senses)
- {
- CreateSense(lexEntry, sense);
- }
-
- //form types should be created before components, otherwise the form type "unspecified" will be added
- foreach (var complexFormType in entry.ComplexFormTypes)
- {
- AddComplexFormType(lexEntry, complexFormType.Id);
- }
-
- foreach (var component in entry.Components)
- {
- AddComplexFormComponent(lexEntry, component);
- }
-
- foreach (var complexForm in entry.ComplexForms)
+ try
+ {
+ UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Entry",
+ "Remove entry",
+ Cache.ServiceLocator.ActionHandler,
+ () =>
{
- var complexLexEntry = EntriesRepository.GetObject(complexForm.ComplexFormEntryId);
- AddComplexFormComponent(complexLexEntry, complexForm);
- }
- });
+ var lexEntry = LexEntryFactory.Create(entry.Id,
+ Cache.ServiceLocator.GetInstance().Singleton.LexDbOA);
+ lexEntry.LexemeFormOA = Cache.ServiceLocator.GetInstance().Create();
+ UpdateLcmMultiString(lexEntry.LexemeFormOA.Form, entry.LexemeForm);
+ UpdateLcmMultiString(lexEntry.CitationForm, entry.CitationForm);
+ UpdateLcmMultiString(lexEntry.LiteralMeaning, entry.LiteralMeaning);
+ UpdateLcmMultiString(lexEntry.Comment, entry.Note);
+
+ foreach (var sense in entry.Senses)
+ {
+ CreateSense(lexEntry, sense);
+ }
+
+ //form types should be created before components, otherwise the form type "unspecified" will be added
+ foreach (var complexFormType in entry.ComplexFormTypes)
+ {
+ AddComplexFormType(lexEntry, complexFormType.Id);
+ }
+
+ foreach (var component in entry.Components)
+ {
+ AddComplexFormComponent(lexEntry, component);
+ }
+
+ foreach (var complexForm in entry.ComplexForms)
+ {
+ var complexLexEntry = EntriesRepository.GetObject(complexForm.ComplexFormEntryId);
+ AddComplexFormComponent(complexLexEntry, complexForm);
+ }
+ });
+ }
+ catch (Exception e)
+ {
+ throw new CreateObjectException($"Failed to create entry {entry}", e);
+ }
return await GetEntry(entry.Id) ?? throw new InvalidOperationException("Entry was not created");
}
@@ -693,7 +757,7 @@ internal void AddComplexFormType(ILexEntry lexEntry, Guid complexFormTypeId)
entryRef.HideMinorEntry = 0;
}
- var lexEntryType = (ILexEntryType)ComplexFormTypes.PossibilitiesOS.Single(c => c.Guid == complexFormTypeId);
+ var lexEntryType = ComplexFormTypesFlattened.Single(c => c.Guid == complexFormTypeId);
entryRef.ComplexEntryTypesRS.Add(lexEntryType);
}
@@ -701,7 +765,8 @@ internal void RemoveComplexFormType(ILexEntry lexEntry, Guid complexFormTypeId)
{
ILexEntryRef? entryRef = lexEntry.ComplexFormEntryRefs.SingleOrDefault();
if (entryRef is null) return;
- var lexEntryType = (ILexEntryType)ComplexFormTypes.PossibilitiesOS.Single(c => c.Guid == complexFormTypeId);
+ var lexEntryType = entryRef.ComplexEntryTypesRS.SingleOrDefault(c => c.Guid == complexFormTypeId);
+ if (lexEntryType is null) return;
entryRef.ComplexEntryTypesRS.Remove(lexEntryType);
}
@@ -881,6 +946,12 @@ public Task DeleteSense(Guid entryId, Guid senseId)
return Task.CompletedTask;
}
+ public Task GetExampleSentence(Guid entryId, Guid senseId, Guid id)
+ {
+ var lcmExampleSentence = ExampleSentenceRepository.GetObject(id);
+ return Task.FromResult(lcmExampleSentence is null ? null : FromLexExampleSentence(senseId, lcmExampleSentence));
+ }
+
internal void CreateExampleSentence(ILexSense lexSense, ExampleSentence exampleSentence)
{
var lexExampleSentence = LexExampleSentenceFactory.Create(exampleSentence.Id, lexSense);
@@ -922,6 +993,20 @@ public Task UpdateExampleSentence(Guid entryId,
return Task.FromResult(FromLexExampleSentence(senseId, lexExampleSentence));
}
+ public async Task UpdateExampleSentence(Guid entryId,
+ Guid senseId,
+ ExampleSentence before,
+ ExampleSentence after)
+ {
+ await Cache.DoUsingNewOrCurrentUOW("Update Example Sentence",
+ "Revert Example Sentence",
+ async () =>
+ {
+ await ExampleSentenceSync.Sync(entryId, senseId, after, before, this);
+ });
+ return await GetExampleSentence(entryId, senseId, after.Id) ?? throw new NullReferenceException("unable to find example sentence with id " + after.Id);
+ }
+
public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId)
{
var lexExampleSentence = ExampleSentenceRepository.GetObject(exampleSentenceId);
diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/PossibilityExtensions.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/PossibilityExtensions.cs
new file mode 100644
index 000000000..e5ee6416f
--- /dev/null
+++ b/backend/FwLite/FwDataMiniLcmBridge/Api/PossibilityExtensions.cs
@@ -0,0 +1,18 @@
+using SIL.LCModel;
+
+namespace FwDataMiniLcmBridge.Api;
+
+public static class PossibilityExtensions
+{
+ public static IEnumerable Flatten(this IEnumerable enumerable) where T : ICmPossibility
+ {
+ foreach (var cmPossibility in enumerable)
+ {
+ yield return cmPossibility;
+ foreach (var child in Flatten(cmPossibility.SubPossibilitiesOS.Cast()))
+ {
+ yield return child;
+ }
+ }
+ }
+}
diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs
index 503222fca..d0defe8ad 100644
--- a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs
+++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs
@@ -4,7 +4,7 @@
namespace FwDataMiniLcmBridge.Api.UpdateProxy;
-public class UpdateEntryProxy : Entry
+public record UpdateEntryProxy : Entry
{
private readonly ILexEntry _lcmEntry;
private readonly FwDataMiniLcmApi _lexboxLcmApi;
diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateWritingSystemProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateWritingSystemProxy.cs
new file mode 100644
index 000000000..eaf849fcb
--- /dev/null
+++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateWritingSystemProxy.cs
@@ -0,0 +1,69 @@
+using System.Diagnostics.CodeAnalysis;
+using MiniLcm.Models;
+using SIL.LCModel;
+using SIL.LCModel.Core.WritingSystems;
+using SIL.LCModel.DomainServices;
+using SIL.WritingSystems;
+
+namespace FwDataMiniLcmBridge.Api.UpdateProxy;
+
+public record UpdateWritingSystemProxy : WritingSystem
+{
+ private readonly CoreWritingSystemDefinition _origLcmWritingSystem;
+ private readonly CoreWritingSystemDefinition _workingLcmWritingSystem;
+ private readonly FwDataMiniLcmApi _lexboxLcmApi;
+
+ [SetsRequiredMembers]
+ public UpdateWritingSystemProxy(CoreWritingSystemDefinition lcmWritingSystem, FwDataMiniLcmApi lexboxLcmApi)
+ {
+ _origLcmWritingSystem = lcmWritingSystem;
+ _workingLcmWritingSystem = new CoreWritingSystemDefinition(lcmWritingSystem, cloneId: true);
+ base.Abbreviation = Abbreviation = _origLcmWritingSystem.Abbreviation ?? "";
+ base.Name = Name = _origLcmWritingSystem.LanguageName ?? "";
+ base.Font = Font = _origLcmWritingSystem.DefaultFontName ?? "";
+ _lexboxLcmApi = lexboxLcmApi;
+ }
+
+ public void CommitUpdate(LcmCache cache)
+ {
+ if (_workingLcmWritingSystem.Id == _origLcmWritingSystem.Id)
+ {
+ cache.ServiceLocator.WritingSystemManager.Set(_workingLcmWritingSystem);
+ }
+ else
+ {
+ // Changing the ID of a writing system requires LCM to do a lot of work, so only go through that process if absolutely required
+ WritingSystemServices.MergeWritingSystems(cache, _workingLcmWritingSystem, _origLcmWritingSystem);
+ }
+ }
+
+ public override required WritingSystemId WsId
+ {
+ get => _workingLcmWritingSystem.Id;
+ set => _workingLcmWritingSystem.Id = value;
+ }
+
+ public override required string Name
+ {
+ get => _workingLcmWritingSystem.LanguageName;
+ set { } // Silently do nothing; name should be derived from WsId at all times, so if the name should change then so should the WsId
+ }
+
+ public override required string Abbreviation
+ {
+ get => _workingLcmWritingSystem.Abbreviation;
+ set => _workingLcmWritingSystem.Abbreviation = value;
+ }
+
+ public override required string Font
+ {
+ get => _workingLcmWritingSystem.DefaultFontName;
+ set
+ {
+ if (value != _workingLcmWritingSystem.DefaultFontName)
+ {
+ _workingLcmWritingSystem.DefaultFont = new FontDefinition(value);
+ }
+ }
+ }
+}
diff --git a/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs b/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs
index 47cb91b57..f77bf944c 100644
--- a/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs
+++ b/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs
@@ -6,15 +6,17 @@ namespace FwDataMiniLcmBridge;
public class FieldWorksProjectList(IOptions config)
{
+ protected readonly IOptions _config = config;
+
public virtual IEnumerable EnumerateProjects()
{
- if (!Directory.Exists(config.Value.ProjectsFolder)) Directory.CreateDirectory(config.Value.ProjectsFolder);
- foreach (var directory in Directory.EnumerateDirectories(config.Value.ProjectsFolder))
+ if (!Directory.Exists(_config.Value.ProjectsFolder)) Directory.CreateDirectory(_config.Value.ProjectsFolder);
+ foreach (var directory in Directory.EnumerateDirectories(_config.Value.ProjectsFolder))
{
var projectName = Path.GetFileName(directory);
if (string.IsNullOrEmpty(projectName)) continue;
if (!File.Exists(Path.Combine(directory, projectName + ".fwdata"))) continue;
- yield return new FwDataProject(projectName, config.Value.ProjectsFolder);
+ yield return new FwDataProject(projectName, _config.Value.ProjectsFolder);
}
}
diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs b/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs
index 0e20c2cf4..395c5c78d 100644
--- a/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs
+++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs
@@ -1,6 +1,7 @@
using FwDataMiniLcmBridge.LcmUtils;
using Microsoft.Extensions.DependencyInjection;
using MiniLcm;
+using MiniLcm.Validators;
namespace FwDataMiniLcmBridge;
@@ -16,6 +17,7 @@ public static IServiceCollection AddFwDataBridge(this IServiceCollection service
services.AddSingleton();
services.AddSingleton();
services.AddKeyedScoped(FwDataApiKey, (provider, o) => provider.GetRequiredService().GetCurrentFwDataMiniLcmApi(true));
+ services.AddMiniLcmValidators();
services.AddSingleton();
return services;
}
diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs b/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs
index 78fc74092..f780b9c13 100644
--- a/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs
+++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using MiniLcm.Validators;
using SIL.LCModel;
namespace FwDataMiniLcmBridge;
@@ -14,7 +15,8 @@ public class FwDataFactory(
IMemoryCache cache,
ILogger logger,
IProjectLoader projectLoader,
- FieldWorksProjectList fieldWorksProjectList) : IDisposable
+ FieldWorksProjectList fieldWorksProjectList,
+ MiniLcmValidators validators) : IDisposable
{
private bool _shuttingDown = false;
public FwDataFactory(FwDataProjectContext context,
@@ -23,7 +25,8 @@ public FwDataFactory(FwDataProjectContext context,
ILogger logger,
IProjectLoader projectLoader,
IHostApplicationLifetime lifetime,
- FieldWorksProjectList fieldWorksProjectList) : this(context, fwdataLogger, cache, logger, projectLoader, fieldWorksProjectList)
+ FieldWorksProjectList fieldWorksProjectList,
+ MiniLcmValidators validators) : this(context, fwdataLogger, cache, logger, projectLoader, fieldWorksProjectList, validators)
{
lifetime.ApplicationStopping.Register(() =>
{
@@ -43,7 +46,7 @@ public FwDataMiniLcmApi GetFwDataMiniLcmApi(string projectName, bool saveOnDispo
public FwDataMiniLcmApi GetFwDataMiniLcmApi(FwDataProject project, bool saveOnDispose)
{
- return new FwDataMiniLcmApi(new (() => GetProjectServiceCached(project)), saveOnDispose, fwdataLogger, project);
+ return new FwDataMiniLcmApi(new (() => GetProjectServiceCached(project)), saveOnDispose, fwdataLogger, project, validators);
}
private HashSet _projects = [];
diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj
index 5ab11b140..dd6ae3743 100644
--- a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj
+++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj
@@ -1,25 +1,22 @@
- net8.0
- enable
- enable
$(ApplicationDisplayVersion)
$(ApplicationDisplayVersion)
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj b/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj
index 043dacd5d..14017906e 100644
--- a/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj
+++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj
@@ -2,7 +2,7 @@
- net8.0-windows10.0.19041.0
+ net9.0-windows10.0.19041.0
@@ -80,7 +80,6 @@
-
diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs
index 0d7727ee2..ece707252 100644
--- a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs
+++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs
@@ -1,7 +1,7 @@
using Windows.ApplicationModel;
using FwLiteDesktop.ServerBridge;
+using FwLiteShared.Auth;
using LcmCrdt;
-using LocalWebApp.Auth;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
diff --git a/backend/FwLite/FwLiteDesktop/MauiProgram.cs b/backend/FwLite/FwLiteDesktop/MauiProgram.cs
index 2dfbdb6fd..ef91d179f 100644
--- a/backend/FwLite/FwLiteDesktop/MauiProgram.cs
+++ b/backend/FwLite/FwLiteDesktop/MauiProgram.cs
@@ -1,7 +1,6 @@
using FwLiteDesktop.ServerBridge;
using LcmCrdt;
using LocalWebApp;
-using LocalWebApp.Auth;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Maui.LifecycleEvents;
diff --git a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs
index d1b264839..b2377ae5c 100644
--- a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs
+++ b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs
@@ -1,7 +1,7 @@
-using FluentAssertions.Equivalency;
-using FwLiteProjectSync.Tests.Fixtures;
+using FwLiteProjectSync.Tests.Fixtures;
using MiniLcm.Models;
using MiniLcm.SyncHelpers;
+using MiniLcm.Tests;
using MiniLcm.Tests.AutoFakerHelpers;
using Soenneker.Utils.AutoBogus;
@@ -95,8 +95,8 @@ public async Task CanChangeComplexFormViaSync_ComplexForms()
[Fact]
public async Task CanChangeComplexFormTypeViaSync()
{
+ var complexFormType = await _fixture.CrdtApi.CreateComplexFormType(new() { Name = new() { { "en", "complexFormType" } } });
var entry = await _fixture.CrdtApi.CreateEntry(new() { LexemeForm = { { "en", "complexForm1" } } });
- var complexFormType = await _fixture.CrdtApi.GetComplexFormTypes().FirstAsync();
var after = (Entry) entry.Copy();
after.ComplexFormTypes = [complexFormType];
await EntrySync.Sync(after, entry, _fixture.CrdtApi);
diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/MercurialTestHelper.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/MercurialTestHelper.cs
new file mode 100644
index 000000000..e0ec3f816
--- /dev/null
+++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/MercurialTestHelper.cs
@@ -0,0 +1,31 @@
+using Chorus.VcsDrivers.Mercurial;
+using SIL.CommandLineProcessing;
+using SIL.PlatformUtilities;
+using SIL.Progress;
+
+namespace FwLiteProjectSync.Tests.Fixtures;
+
+public static class MercurialTestHelper
+{
+ private static readonly NullProgress NullProgress = new NullProgress();
+
+ private static string RunHgCommand(string repoPath, string args)
+ {
+ var result = HgRunner.Run(args, repoPath, 120, NullProgress);
+ if (result.ExitCode == 0) return result.StandardOutput;
+ throw new Exception(
+ $"hg {args} failed.\nStdOut: {result.StandardOutput}\nStdErr: {result.StandardError}");
+
+ }
+
+ public static void HgClean(string repoPath, string exclude)
+ {
+ RunHgCommand(repoPath, $"purge --no-confirm --exclude {exclude}");
+ }
+
+ public static void HgUpdate(string repoPath, string rev)
+ {
+ RunHgCommand(repoPath, $"update \"{rev}\"");
+ }
+}
+
diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs
new file mode 100644
index 000000000..cc3551181
--- /dev/null
+++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs
@@ -0,0 +1,93 @@
+using System.IO.Compression;
+using FwDataMiniLcmBridge;
+using FwDataMiniLcmBridge.Api;
+using LcmCrdt;
+using LexCore.Utils;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using SIL.IO;
+using SIL.Progress;
+
+namespace FwLiteProjectSync.Tests.Fixtures;
+
+public class Sena3Fixture : IAsyncLifetime
+{
+ private static readonly HttpClient http = new HttpClient();
+
+ public async Task InitializeAsync()
+ {
+ var services = new ServiceCollection()
+ .AddSyncServices(nameof(Sena3Fixture), false);
+ var rootServiceProvider = services.BuildServiceProvider();
+ var fwProjectsFolder = rootServiceProvider.GetRequiredService>()
+ .Value
+ .ProjectsFolder;
+ if (Path.Exists(fwProjectsFolder)) Directory.Delete(fwProjectsFolder, true);
+ Directory.CreateDirectory(fwProjectsFolder);
+
+ var crdtProjectsFolder =
+ rootServiceProvider.GetRequiredService>().Value.ProjectPath;
+ if (Path.Exists(crdtProjectsFolder)) Directory.Delete(crdtProjectsFolder, true);
+ rootServiceProvider.Dispose();
+
+ Directory.CreateDirectory(crdtProjectsFolder);
+ await DownloadSena3();
+ }
+
+ public async Task<(CrdtMiniLcmApi CrdtApi, FwDataMiniLcmApi FwDataApi, IServiceProvider services, IDisposable cleanup)> SetupProjects()
+ {
+ var sena3MasterCopy = await DownloadSena3();
+
+ var rootServiceProvider = new ServiceCollection()
+ .AddSyncServices(nameof(Sena3Fixture), false)
+ .BuildServiceProvider();
+ var cleanup = Defer.Action(() => rootServiceProvider.Dispose());
+ var services = rootServiceProvider.CreateAsyncScope().ServiceProvider;
+ var projectName = "sena-3_" + Guid.NewGuid().ToString("N");
+
+ var projectsFolder = services.GetRequiredService>()
+ .Value
+ .ProjectsFolder;
+ var fwDataProject = new FwDataProject(projectName, projectsFolder);
+ var fwDataProjectPath = Path.Combine(fwDataProject.ProjectsPath, fwDataProject.Name);
+ DirectoryHelper.Copy(sena3MasterCopy, fwDataProjectPath);
+ File.Move(Path.Combine(fwDataProjectPath, "sena-3.fwdata"), fwDataProject.FilePath);
+ var fwDataMiniLcmApi = services.GetRequiredService().GetFwDataMiniLcmApi(fwDataProject, false);
+
+ var crdtProject = await services.GetRequiredService()
+ .CreateProject(new(projectName, FwProjectId: fwDataMiniLcmApi.ProjectId, SeedNewProjectData: false));
+ var crdtMiniLcmApi = (CrdtMiniLcmApi)await services.OpenCrdtProject(crdtProject);
+ return (crdtMiniLcmApi, fwDataMiniLcmApi, services, cleanup);
+ }
+
+ public Task DisposeAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ private async Task DownloadSena3ProjectBackupStream()
+ {
+ var backupUrl = new Uri("https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS");
+ var result = await http.GetAsync(backupUrl, HttpCompletionOption.ResponseHeadersRead);
+ return await result.Content.ReadAsStreamAsync();
+ }
+
+ private async Task DownloadSena3()
+ {
+ var tempFolder = Path.Combine(Path.GetTempPath(), nameof(Sena3Fixture));
+ var sena3MasterCopy = Path.Combine(tempFolder, "sena-3");
+ if (!Directory.Exists(sena3MasterCopy) || !File.Exists(Path.Combine(sena3MasterCopy, "sena-3.fwdata")))
+ {
+ Directory.CreateDirectory(sena3MasterCopy);
+ await using var zipStream = await DownloadSena3ProjectBackupStream();
+ //the zip file is structured like this: /sena-3/.hg
+ //by extracting it to tempFolder it should merge with sena-3
+ ZipFile.ExtractToDirectory(zipStream, tempFolder);
+
+ MercurialTestHelper.HgUpdate(sena3MasterCopy, "tip");
+ LfMergeBridge.LfMergeBridge.ReassembleFwdataFile(new NullProgress(), false, Path.Combine(sena3MasterCopy, "sena-3.fwdata"));
+ MercurialTestHelper.HgClean(sena3MasterCopy, "sena-3.fwdata");
+ }
+ return sena3MasterCopy;
+ }
+}
diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs
index 0b0443e0d..149adf76a 100644
--- a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs
+++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs
@@ -4,6 +4,7 @@
using FwDataMiniLcmBridge.LcmUtils;
using FwDataMiniLcmBridge.Tests.Fixtures;
using LcmCrdt;
+using LexCore.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
@@ -20,7 +21,7 @@ public class SyncFixture : IAsyncLifetime
_services.ServiceProvider.GetRequiredService();
public IServiceProvider Services => _services.ServiceProvider;
private readonly string _projectName;
- private readonly MockProjectContext _projectContext = new(null);
+ private readonly IDisposable _cleanup;
public static SyncFixture Create([CallerMemberName] string projectName = "") => new(projectName);
@@ -28,15 +29,10 @@ private SyncFixture(string projectName)
{
_projectName = projectName;
var crdtServices = new ServiceCollection()
- .AddLcmCrdtClient()
- .AddSingleton(_projectContext)
- .AddTestFwDataBridge()
- .AddFwLiteProjectSync()
- .Configure(c => c.ProjectsFolder = Path.Combine(".", _projectName, "FwData"))
- .Configure(c => c.ProjectPath = Path.Combine(".", _projectName, "LcmCrdt"))
- .AddLogging(builder => builder.AddDebug())
- .BuildServiceProvider();
- _services = crdtServices.CreateAsyncScope();
+ .AddSyncServices(_projectName);
+ var rootServiceProvider = crdtServices.BuildServiceProvider();
+ _cleanup = Defer.Action(() => rootServiceProvider.Dispose());
+ _services = rootServiceProvider.CreateAsyncScope();
}
public SyncFixture(): this("sena-3_" + Guid.NewGuid().ToString("N"))
@@ -50,28 +46,30 @@ public async Task InitializeAsync()
if (Path.Exists(projectsFolder)) Directory.Delete(projectsFolder, true);
Directory.CreateDirectory(projectsFolder);
_services.ServiceProvider.GetRequiredService()
- .NewProject(new FwDataProject(_projectName, projectsFolder), "en", "fr");
+ .NewProject(new FwDataProject(_projectName, projectsFolder), "en", "en");
FwDataApi = _services.ServiceProvider.GetRequiredService().GetFwDataMiniLcmApi(_projectName, false);
var crdtProjectsFolder =
_services.ServiceProvider.GetRequiredService>().Value.ProjectPath;
if (Path.Exists(crdtProjectsFolder)) Directory.Delete(crdtProjectsFolder, true);
Directory.CreateDirectory(crdtProjectsFolder);
- var crdtProject = await _services.ServiceProvider.GetRequiredService()
- .CreateProject(new(_projectName, FwProjectId: FwDataApi.ProjectId, SeedNewProjectData: true));
+ var crdtProject = await _services.ServiceProvider.GetRequiredService()
+ .CreateProject(new(_projectName, FwProjectId: FwDataApi.ProjectId, SeedNewProjectData: false));
CrdtApi = (CrdtMiniLcmApi) await _services.ServiceProvider.OpenCrdtProject(crdtProject);
}
public async Task DisposeAsync()
{
await _services.DisposeAsync();
+ _cleanup.Dispose();
}
public CrdtMiniLcmApi CrdtApi { get; set; } = null!;
public FwDataMiniLcmApi FwDataApi { get; set; } = null!;
-}
-public class MockProjectContext(CrdtProject? project) : ProjectContext
-{
- public override CrdtProject? Project { get; set; } = project;
+ public void DeleteSyncSnapshot()
+ {
+ var snapshotPath = CrdtFwdataProjectSyncService.SnapshotPath(FwDataApi.Project);
+ if (File.Exists(snapshotPath)) File.Delete(snapshotPath);
+ }
}
diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/TestingKernel.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/TestingKernel.cs
new file mode 100644
index 000000000..6eb9f8b40
--- /dev/null
+++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/TestingKernel.cs
@@ -0,0 +1,27 @@
+using FwDataMiniLcmBridge;
+using FwDataMiniLcmBridge.Tests.Fixtures;
+using LcmCrdt;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace FwLiteProjectSync.Tests.Fixtures;
+
+public static class TestingKernel
+{
+ public static IServiceCollection AddSyncServices(this IServiceCollection services, string projectName, bool mockFwProjectLoader = true)
+ {
+ return services.AddLcmCrdtClient()
+ .AddTestFwDataBridge(mockFwProjectLoader)
+ .AddFwLiteProjectSync()
+ .AddSingleton(new MockProjectContext(null))
+ .Configure(c => c.ProjectsFolder = Path.Combine(".", projectName, "FwData"))
+ .Configure(c => c.ProjectPath = Path.Combine(".", projectName, "LcmCrdt"))
+ .AddLogging(builder => builder.AddDebug());
+ }
+
+ public class MockProjectContext(CrdtProject? project) : ProjectContext
+ {
+ public override CrdtProject? Project { get; set; } = project;
+ }
+
+}
diff --git a/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj b/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj
index 1ae1063f4..abe85567f 100644
--- a/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj
+++ b/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj
@@ -1,12 +1,9 @@
- net8.0
- enable
- enable
-
false
true
+ $(MSBuildProjectDirectory)
@@ -18,16 +15,18 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
+
+
+
-
+
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
+
+
@@ -41,4 +40,12 @@
+
+
+
+
+
+
+
+
diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs
new file mode 100644
index 000000000..77add42f5
--- /dev/null
+++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs
@@ -0,0 +1,144 @@
+using FluentAssertions.Equivalency;
+using FluentAssertions.Execution;
+using FwDataMiniLcmBridge.Api;
+using FwLiteProjectSync.Tests.Fixtures;
+using LcmCrdt;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using MiniLcm;
+using MiniLcm.Models;
+using SystemTextJsonPatch;
+
+namespace FwLiteProjectSync.Tests;
+
+public class Sena3SyncTests : IClassFixture, IAsyncLifetime
+{
+ private readonly Sena3Fixture _fixture;
+ private CrdtFwdataProjectSyncService _syncService = null!;
+ private CrdtMiniLcmApi _crdtApi = null!;
+ private FwDataMiniLcmApi _fwDataApi = null!;
+ private IDisposable? _cleanup;
+
+
+ public Sena3SyncTests(Sena3Fixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ public async Task InitializeAsync()
+ {
+ (_crdtApi, _fwDataApi, var services, _cleanup) = await _fixture.SetupProjects();
+ _syncService = services.GetRequiredService();
+ _fwDataApi.EntryCount.Should().BeGreaterThan(100, "project should be loaded and have entries");
+ }
+
+ public Task DisposeAsync()
+ {
+ _cleanup?.Dispose();
+ return Task.CompletedTask;
+ }
+
+ private void ShouldAllBeEquivalentTo(Dictionary crdtEntries, Dictionary fwdataEntries)
+ {
+ crdtEntries.Keys.Should().BeEquivalentTo(fwdataEntries.Keys);
+ using (new AssertionScope())
+ {
+ foreach (var crdtEntry in crdtEntries.Values)
+ {
+ var fwdataEntry = fwdataEntries[crdtEntry.Id];
+ crdtEntry.Should().BeEquivalentTo(fwdataEntry,
+ options => options
+ .For(e => e.Components).Exclude(c => c.Id)
+ .For(e => e.ComplexForms).Exclude(c => c.Id),
+ $"CRDT entry {crdtEntry.Id} was synced with FwData");
+ }
+ }
+ }
+
+ //by default the first sync is an import, this will skip that so that the sync will actually sync data
+ private async Task BypassImport()
+ {
+ await _syncService.SaveProjectSnapshot(_fwDataApi.Project, new ([], [], []));
+ }
+
+ //this lets us query entries when there is no writing system
+ private async Task WorkaroundMissingWritingSystems()
+ {
+ //must have at least one writing system to query for entries
+ await _crdtApi.CreateWritingSystem(WritingSystemType.Vernacular, (await _fwDataApi.GetWritingSystems()).Vernacular.First());
+
+ }
+
+ [Fact]
+ public async Task DryRunImport_MakesNoChanges()
+ {
+ await WorkaroundMissingWritingSystems();
+ _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty();
+ await _syncService.SyncDryRun(_crdtApi, _fwDataApi);
+ //should still be empty
+ _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task DryRunImport_MakesTheSameChangesAsImport()
+ {
+ var dryRunSyncResult = await _syncService.SyncDryRun(_crdtApi, _fwDataApi);
+ var syncResult = await _syncService.Sync(_crdtApi, _fwDataApi);
+ dryRunSyncResult.Should().BeEquivalentTo(syncResult);
+ }
+
+ [Fact]
+ public async Task DryRunSync_MakesNoChanges()
+ {
+ await BypassImport();
+ await WorkaroundMissingWritingSystems();
+ _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty();
+ await _syncService.SyncDryRun(_crdtApi, _fwDataApi);
+ //should still be empty
+ _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty();
+ }
+
+ [Fact(Skip = "this test is waiting for syncing ComplexFormTypes and WritingSystems")]
+ public async Task DryRunSync_MakesTheSameChangesAsImport()
+ {
+ await BypassImport();
+ var dryRunSyncResult = await _syncService.SyncDryRun(_crdtApi, _fwDataApi);
+ var syncResult = await _syncService.Sync(_crdtApi, _fwDataApi);
+ dryRunSyncResult.Should().BeEquivalentTo(syncResult);
+ }
+
+ [Fact]
+ public async Task FirstSena3SyncJustDoesAnSync()
+ {
+ var results = await _syncService.Sync(_crdtApi, _fwDataApi);
+ results.FwdataChanges.Should().Be(0);
+ results.CrdtChanges.Should().BeGreaterThanOrEqualTo(_fwDataApi.EntryCount);
+
+ var crdtEntries = await _crdtApi.GetEntries().ToDictionaryAsync(e => e.Id);
+ var fwdataEntries = await _fwDataApi.GetEntries().ToDictionaryAsync(e => e.Id);
+ ShouldAllBeEquivalentTo(crdtEntries, fwdataEntries);
+ }
+
+ [Fact(Skip = "this test is waiting for syncing ComplexFormTypes and WritingSystems")]
+ public async Task SyncWithoutImport_CrdtShouldMatchFwdata()
+ {
+ await BypassImport();
+
+ var results = await _syncService.Sync(_crdtApi, _fwDataApi);
+ results.FwdataChanges.Should().Be(0);
+ results.CrdtChanges.Should().BeGreaterThan(_fwDataApi.EntryCount);
+
+ var crdtEntries = await _crdtApi.GetEntries().ToDictionaryAsync(e => e.Id);
+ var fwdataEntries = await _fwDataApi.GetEntries().ToDictionaryAsync(e => e.Id);
+ ShouldAllBeEquivalentTo(crdtEntries, fwdataEntries);
+ }
+
+ [Fact]
+ public async Task SecondSena3SyncDoesNothing()
+ {
+ await _syncService.Sync(_crdtApi, _fwDataApi);
+ var secondSync = await _syncService.Sync(_crdtApi, _fwDataApi);
+ secondSync.CrdtChanges.Should().Be(0);
+ secondSync.FwdataChanges.Should().Be(0);
+ }
+}
diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs
index 752d77fa2..36b4084f2 100644
--- a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs
+++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs
@@ -65,6 +65,8 @@ public async Task DisposeAsync()
{
await _fixture.CrdtApi.DeleteEntry(entry.Id);
}
+
+ _fixture.DeleteSyncSnapshot();
}
public SyncTests(SyncFixture fixture)
@@ -84,7 +86,18 @@ public async Task FirstSyncJustDoesAnImport()
var fwdataEntries = await fwdataApi.GetEntries().ToArrayAsync();
crdtEntries.Should().BeEquivalentTo(fwdataEntries,
options => options.For(e => e.Components).Exclude(c => c.Id)
- .For(e => e.ComplexForms).Exclude(c => c.Id));
+ .For(e => e.ComplexForms).Exclude(c => c.Id));
+ }
+
+ [Fact]
+ public async Task SecondSyncDoesNothing()
+ {
+ var crdtApi = _fixture.CrdtApi;
+ var fwdataApi = _fixture.FwDataApi;
+ await _syncService.Sync(crdtApi, fwdataApi);
+ var secondSync = await _syncService.Sync(crdtApi, fwdataApi);
+ secondSync.CrdtChanges.Should().Be(0);
+ secondSync.FwdataChanges.Should().Be(0);
}
[Fact]
@@ -138,6 +151,90 @@ await crdtApi.CreateEntry(new Entry()
.For(e => e.ComplexForms).Exclude(c => c.Id));
}
+ [Fact]
+ public async Task SyncDryRun_NoChangesAreSynced()
+ {
+ var crdtApi = _fixture.CrdtApi;
+ var fwdataApi = _fixture.FwDataApi;
+ await _syncService.Sync(crdtApi, fwdataApi);
+ var fwDataEntryId = Guid.NewGuid();
+ var crdtEntryId = Guid.NewGuid();
+
+ await fwdataApi.CreateEntry(new Entry()
+ {
+ Id = fwDataEntryId,
+ LexemeForm = { { "en", "Pear" } },
+ Senses =
+ [
+ new Sense() { Gloss = { { "en", "Pear" } }, }
+ ]
+ });
+ await crdtApi.CreateEntry(new Entry()
+ {
+ Id = crdtEntryId,
+ LexemeForm = { { "en", "Banana" } },
+ Senses =
+ [
+ new Sense() { Gloss = { { "en", "Banana" } }, }
+ ]
+ });
+ await _syncService.SyncDryRun(crdtApi, fwdataApi);
+
+ var crdtEntries = await crdtApi.GetEntries().ToArrayAsync();
+ var fwdataEntries = await fwdataApi.GetEntries().ToArrayAsync();
+ crdtEntries.Select(e => e.Id).Should().NotContain(fwDataEntryId);
+ fwdataEntries.Select(e => e.Id).Should().NotContain(crdtEntryId);
+ }
+
+ [Fact]
+ public async Task CreatingAComplexEntryInFwDataSyncsWithoutIssue()
+ {
+ var crdtApi = _fixture.CrdtApi;
+ var fwdataApi = _fixture.FwDataApi;
+ await _syncService.Sync(crdtApi, fwdataApi);
+
+ var hat = await fwdataApi.CreateEntry(new Entry()
+ {
+ LexemeForm = { { "en", "Hat" } },
+ Senses =
+ [
+ new Sense() { Gloss = { { "en", "Hat" } }, }
+ ]
+ });
+ var stand = await fwdataApi.CreateEntry(new Entry()
+ {
+ LexemeForm = { { "en", "Stand" } },
+ Senses =
+ [
+ new Sense() { Gloss = { { "en", "Stand" } }, }
+ ]
+ });
+ var hatstand = await fwdataApi.CreateEntry(new Entry()
+ {
+ LexemeForm = { { "en", "Hatstand" } },
+ Senses =
+ [
+ new Sense() { Gloss = { { "en", "Hatstand" } }, }
+ ],
+ });
+ var component1 = ComplexFormComponent.FromEntries(hatstand, hat);
+ var component2 = ComplexFormComponent.FromEntries(hatstand, stand);
+ hatstand.Components = [component1, component2];
+ await _syncService.Sync(crdtApi, fwdataApi);
+
+ var crdtEntries = await crdtApi.GetEntries().ToArrayAsync();
+ var fwdataEntries = await fwdataApi.GetEntries().ToArrayAsync();
+ crdtEntries.Should().BeEquivalentTo(fwdataEntries,
+ options => options.For(e => e.Components).Exclude(c => c.Id)
+ .For(e => e.ComplexForms).Exclude(c => c.Id));
+
+ // Sync again, ensure no problems or changes
+ var secondSync = await _syncService.Sync(crdtApi, fwdataApi);
+ secondSync.CrdtChanges.Should().Be(0);
+ secondSync.FwdataChanges.Should().Be(0);
+ }
+
+
[Fact]
public async Task PartsOfSpeechSyncBothWays()
{
@@ -273,7 +370,7 @@ await fwdataApi.CreateEntry(new Entry()
LexemeForm = { { "en", "Pear" } },
Senses =
[
- new Sense() { Gloss = { { "en", "Pear" } }, SemanticDomains = [ semdom3 ] }
+ new Sense() { Gloss = { { "en", "Pear" } }, SemanticDomains = [semdom3] }
]
});
await crdtApi.CreateEntry(new Entry()
@@ -281,7 +378,7 @@ await crdtApi.CreateEntry(new Entry()
LexemeForm = { { "en", "Banana" } },
Senses =
[
- new Sense() { Gloss = { { "en", "Banana" } }, SemanticDomains = [ semdom3 ] }
+ new Sense() { Gloss = { { "en", "Banana" } }, SemanticDomains = [semdom3] }
]
});
await _syncService.Sync(crdtApi, fwdataApi);
@@ -365,7 +462,7 @@ public async Task AddingASenseToAnEntryInEachProjectSyncsAcrossBoth()
await _syncService.Sync(crdtApi, fwdataApi);
await fwdataApi.CreateSense(_testEntry.Id, new Sense()
- {
+ {
Gloss = { { "en", "Fruit" } },
Definition = { { "en", "a round fruit, red or yellow" } },
});
@@ -373,7 +470,7 @@ public async Task AddingASenseToAnEntryInEachProjectSyncsAcrossBoth()
{
Gloss = { { "en", "Tree" } },
Definition = { { "en", "a tall, woody plant, which grows fruit" } },
- });
+ });
await _syncService.Sync(crdtApi, fwdataApi);
@@ -383,4 +480,24 @@ public async Task AddingASenseToAnEntryInEachProjectSyncsAcrossBoth()
options => options.For(e => e.Components).Exclude(c => c.Id)
.For(e => e.ComplexForms).Exclude(c => c.Id));
}
+
+ [Fact]
+ public async Task CanCreateAComplexFormAndItsComponentInOneSync()
+ {
+ //ensure they are synced so a real sync will happen when we want it to
+ await _fixture.SyncService.Sync(_fixture.CrdtApi, _fixture.FwDataApi);
+
+ var complexFormEntry = await _fixture.CrdtApi.CreateEntry(new() { LexemeForm = { { "en", "complexForm" } } });
+ var componentEntry = await _fixture.CrdtApi.CreateEntry(new()
+ {
+ LexemeForm = { { "en", "component" } },
+ ComplexForms =
+ [
+ new ComplexFormComponent() { ComplexFormEntryId = complexFormEntry.Id, ComponentEntryId = Guid.Empty }
+ ]
+ });
+
+ //one of the entries will be created first, it will try to create the reference to the other but it won't exist yet
+ await _fixture.SyncService.Sync(_fixture.CrdtApi, _fixture.FwDataApi);
+ }
}
diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs
index 12258dcee..58685043b 100644
--- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs
+++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs
@@ -1,8 +1,9 @@
using System.Text.Json;
+using FwDataMiniLcmBridge;
using FwDataMiniLcmBridge.Api;
using LcmCrdt;
+using LexCore.Sync;
using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
using MiniLcm;
using MiniLcm.Models;
using MiniLcm.SyncHelpers;
@@ -11,9 +12,18 @@
namespace FwLiteProjectSync;
-public class CrdtFwdataProjectSyncService(IOptions lcmCrdtConfig, MiniLcmImport miniLcmImport, ILogger logger)
+public class CrdtFwdataProjectSyncService(MiniLcmImport miniLcmImport, ILogger logger)
{
- public record SyncResult(int CrdtChanges, int FwdataChanges);
+ public record DryRunSyncResult(
+ int CrdtChanges,
+ int FwdataChanges,
+ List CrdtDryRunRecords,
+ List FwDataDryRunRecords) : SyncResult(CrdtChanges, FwdataChanges);
+
+ public async Task SyncDryRun(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataApi)
+ {
+ return (DryRunSyncResult) await Sync(crdtApi, fwdataApi, true);
+ }
public async Task Sync(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataApi, bool dryRun = false)
{
@@ -21,13 +31,13 @@ public async Task Sync(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataA
{
throw new InvalidOperationException($"Project id mismatch, CRDT Id: {crdt.ProjectData.FwProjectId}, FWData Id: {fwdataApi.ProjectId}");
}
- var projectSnapshot = await GetProjectSnapshot(fwdataApi.Project.Name, fwdataApi.Project.ProjectsPath);
+ var projectSnapshot = await GetProjectSnapshot(fwdataApi.Project);
SyncResult result = await Sync(crdtApi, fwdataApi, dryRun, fwdataApi.EntryCount, projectSnapshot);
fwdataApi.Save();
if (!dryRun)
{
- await SaveProjectSnapshot(fwdataApi.Project.Name, fwdataApi.Project.ProjectsPath,
+ await SaveProjectSnapshot(fwdataApi.Project,
new ProjectSnapshot(
await fwdataApi.GetEntries().ToArrayAsync(),
await fwdataApi.GetPartsOfSpeech().ToArrayAsync(),
@@ -48,6 +58,7 @@ private async Task Sync(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi,
{
await miniLcmImport.ImportProject(crdtApi, fwdataApi, entryCount);
LogDryRun(crdtApi, "crdt");
+ if (dryRun) return new DryRunSyncResult(entryCount, 0, GetDryRunRecords(crdtApi), []);
return new SyncResult(entryCount, 0);
}
@@ -69,7 +80,7 @@ private async Task Sync(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi,
LogDryRun(fwdataApi, "fwdata");
//todo push crdt changes to lexbox
-
+ if (dryRun) return new DryRunSyncResult(crdtChanges, fwdataChanges, GetDryRunRecords(crdtApi), GetDryRunRecords(fwdataApi));
return new SyncResult(crdtChanges, fwdataChanges);
}
@@ -84,23 +95,32 @@ private void LogDryRun(IMiniLcmApi api, string type)
logger.LogInformation($"Dry run {type} changes: {dryRunApi.DryRunRecords.Count}");
}
+ private List GetDryRunRecords(IMiniLcmApi api)
+ {
+ return ((DryRunMiniLcmApi)api).DryRunRecords;
+ }
+
public record ProjectSnapshot(Entry[] Entries, PartOfSpeech[] PartsOfSpeech, SemanticDomain[] SemanticDomains);
- private async Task GetProjectSnapshot(string projectName, string? projectPath)
+ private async Task GetProjectSnapshot(FwDataProject project)
{
- projectPath ??= lcmCrdtConfig.Value.ProjectPath;
- var snapshotPath = Path.Combine(projectPath, $"{projectName}_snapshot.json");
+ var snapshotPath = SnapshotPath(project);
if (!File.Exists(snapshotPath)) return null;
await using var file = File.OpenRead(snapshotPath);
return await JsonSerializer.DeserializeAsync(file);
}
- private async Task SaveProjectSnapshot(string projectName, string? projectPath, ProjectSnapshot projectSnapshot)
+ internal async Task SaveProjectSnapshot(FwDataProject project, ProjectSnapshot projectSnapshot)
{
- projectPath ??= lcmCrdtConfig.Value.ProjectPath;
- var snapshotPath = Path.Combine(projectPath, $"{projectName}_snapshot.json");
+ var snapshotPath = SnapshotPath(project);
await using var file = File.Create(snapshotPath);
await JsonSerializer.SerializeAsync(file, projectSnapshot);
}
+ internal static string SnapshotPath(FwDataProject project)
+ {
+ var projectPath = project.ProjectsPath;
+ var snapshotPath = Path.Combine(projectPath, $"{project.Name}_snapshot.json");
+ return snapshotPath;
+ }
}
diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs
index 39e767702..3aac9f480 100644
--- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs
+++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs
@@ -34,6 +34,12 @@ public async Task UpdateWritingSystem(WritingSystemId id,
}).First(w => w.WsId == id);
}
+ public Task UpdateWritingSystem(WritingSystem before, WritingSystem after)
+ {
+ DryRunRecords.Add(new DryRunRecord(nameof(UpdateEntry), $"Update {after.Type} writing system {after.WsId}"));
+ return Task.FromResult(after);
+ }
+
public IAsyncEnumerable GetPartsOfSpeech()
{
return api.GetPartsOfSpeech();
@@ -56,6 +62,12 @@ public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after)
+ {
+ DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {after.Id}"));
+ return Task.FromResult(after);
+ }
+
public Task DeletePartOfSpeech(Guid id)
{
DryRunRecords.Add(new DryRunRecord(nameof(DeletePartOfSpeech), $"Delete part of speech {id}"));
@@ -81,13 +93,19 @@ public Task CreateSemanticDomain(SemanticDomain semanticDomain)
public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update)
{
- DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update part of speech {id}"));
+ DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {id}"));
return GetSemanticDomain(id)!;
}
+ public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after)
+ {
+ DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {after.Id}"));
+ return Task.FromResult(after);
+ }
+
public Task DeleteSemanticDomain(Guid id)
{
- DryRunRecords.Add(new DryRunRecord(nameof(DeleteSemanticDomain), $"Delete part of speech {id}"));
+ DryRunRecords.Add(new DryRunRecord(nameof(DeleteSemanticDomain), $"Delete semantic domain {id}"));
return Task.CompletedTask;
}
@@ -194,6 +212,11 @@ public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId)
return Task.CompletedTask;
}
+ public Task GetExampleSentence(Guid entryId, Guid senseId, Guid id)
+ {
+ return api.GetExampleSentence(entryId, senseId, id);
+ }
+
public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence)
{
DryRunRecords.Add(new DryRunRecord(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}"));
@@ -207,11 +230,17 @@ public async Task UpdateExampleSentence(Guid entryId,
{
DryRunRecords.Add(new DryRunRecord(nameof(UpdateExampleSentence),
$"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}"));
- var entry = await GetEntry(entryId) ??
- throw new NullReferenceException($"unable to find entry with id {entryId}");
- var sense = entry.Senses.First(s => s.Id == senseId);
- var exampleSentence = sense.ExampleSentences.First(s => s.Id == exampleSentenceId);
- return exampleSentence;
+ var exampleSentence = await GetExampleSentence(entryId, senseId, exampleSentenceId);
+ return exampleSentence ?? throw new NullReferenceException($"unable to find example sentence with id {exampleSentenceId}");
+ }
+
+ public Task UpdateExampleSentence(Guid entryId,
+ Guid senseId,
+ ExampleSentence before,
+ ExampleSentence after)
+ {
+ DryRunRecords.Add(new DryRunRecord(nameof(UpdateExampleSentence), $"Update example sentence {after.Id}"));
+ return Task.FromResult(after);
}
public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId)
@@ -228,7 +257,7 @@ public Task CreateComplexFormComponent(ComplexFormComponen
public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent)
{
- DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component complex entry: {complexFormComponent.ComplexFormHeadword}, component entry: {complexFormComponent.ComponentHeadword}"));
+ DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component: {complexFormComponent}"));
return Task.CompletedTask;
}
diff --git a/backend/FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj b/backend/FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj
index 7e4c26e04..8cbde01b8 100644
--- a/backend/FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj
+++ b/backend/FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj
@@ -1,9 +1,6 @@
- net8.0
- enable
- enable
$(ApplicationDisplayVersion)
$(ApplicationDisplayVersion)
@@ -14,9 +11,13 @@
-
-
+
+
+
+
+
+
diff --git a/backend/FwLite/FwLiteProjectSync/Program.cs b/backend/FwLite/FwLiteProjectSync/Program.cs
index e59799d28..d587f5fb0 100644
--- a/backend/FwLite/FwLiteProjectSync/Program.cs
+++ b/backend/FwLite/FwLiteProjectSync/Program.cs
@@ -52,7 +52,7 @@ public static Task Main(string[] args)
var services = scope.ServiceProvider;
var logger = services.GetRequiredService>();
var fwdataApi = services.GetRequiredService().GetFwDataMiniLcmApi(fwProjectName, true);
- var projectsService = services.GetRequiredService();
+ var projectsService = services.GetRequiredService();
var crdtProject = projectsService.GetProject(crdtProjectName);
if (crdtProject is null)
{
diff --git a/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs b/backend/FwLite/FwLiteShared/Auth/AuthConfig.cs
similarity index 97%
rename from backend/FwLite/LocalWebApp/Auth/AuthConfig.cs
rename to backend/FwLite/FwLiteShared/Auth/AuthConfig.cs
index 542aae2db..788316c6e 100644
--- a/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs
+++ b/backend/FwLite/FwLiteShared/Auth/AuthConfig.cs
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using LcmCrdt;
-namespace LocalWebApp.Auth;
+namespace FwLiteShared.Auth;
public class AuthConfig
{
diff --git a/backend/FwLite/FwLiteShared/Auth/AuthService.cs b/backend/FwLite/FwLiteShared/Auth/AuthService.cs
new file mode 100644
index 000000000..ec7621a87
--- /dev/null
+++ b/backend/FwLite/FwLiteShared/Auth/AuthService.cs
@@ -0,0 +1,44 @@
+using FwLiteShared.Projects;
+using Microsoft.Extensions.Options;
+
+namespace FwLiteShared.Auth;
+
+public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs, string? Authority);
+public class AuthService(LexboxProjectService lexboxProjectService, OAuthClientFactory clientFactory, IOptions options)
+{
+ public IAsyncEnumerable Servers()
+ {
+ return lexboxProjectService.Servers().ToAsyncEnumerable().SelectAwait(async s =>
+ {
+ var currentName = await clientFactory.GetClient(s).GetCurrentName();
+ return new ServerStatus(s.DisplayName,
+ !string.IsNullOrEmpty(currentName),
+ currentName,
+ s.Authority.Authority);
+ });
+ }
+
+ public async Task SignInWebView(LexboxServer server)
+ {
+ var result = await clientFactory.GetClient(server).SignIn(string.Empty);//does nothing here
+ if (!result.HandledBySystemWebView) throw new InvalidOperationException("Sign in not handled by system web view");
+ }
+
+ public async Task SignInWebApp(LexboxServer server, string returnUrl)
+ {
+ var result = await clientFactory.GetClient(server).SignIn(returnUrl);
+ if (result.HandledBySystemWebView) throw new InvalidOperationException("Sign in handled by system web view");
+ if (result.AuthUri is null) throw new InvalidOperationException("AuthUri is null");
+ return result.AuthUri.ToString();
+ }
+
+ public async Task Logout(LexboxServer server)
+ {
+ await clientFactory.GetClient(server).Logout();
+ }
+
+ public async Task GetLoggedInName(LexboxServer server)
+ {
+ return await clientFactory.GetClient(server).GetCurrentName();
+ }
+}
diff --git a/backend/FwLite/FwLiteShared/Auth/IRedirectUrlProvider.cs b/backend/FwLite/FwLiteShared/Auth/IRedirectUrlProvider.cs
new file mode 100644
index 000000000..4c2b2541b
--- /dev/null
+++ b/backend/FwLite/FwLiteShared/Auth/IRedirectUrlProvider.cs
@@ -0,0 +1,7 @@
+namespace FwLiteShared.Auth;
+
+public interface IRedirectUrlProvider
+{
+ string? GetRedirectUrl();
+ bool ShouldRecreateAuthHelper(string? redirectUrl);
+}
diff --git a/backend/FwLite/LocalWebApp/Auth/LoggerAdapter.cs b/backend/FwLite/FwLiteShared/Auth/LoggerAdapter.cs
similarity index 88%
rename from backend/FwLite/LocalWebApp/Auth/LoggerAdapter.cs
rename to backend/FwLite/FwLiteShared/Auth/LoggerAdapter.cs
index ea75bdd5c..dab270f29 100644
--- a/backend/FwLite/LocalWebApp/Auth/LoggerAdapter.cs
+++ b/backend/FwLite/FwLiteShared/Auth/LoggerAdapter.cs
@@ -1,6 +1,7 @@
-using Microsoft.IdentityModel.Abstractions;
+using Microsoft.Extensions.Logging;
+using Microsoft.IdentityModel.Abstractions;
-namespace LocalWebApp.Auth;
+namespace FwLiteShared.Auth;
public class LoggerAdapter(ILogger logger): IIdentityLogger
{
diff --git a/backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs
similarity index 84%
rename from backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs
rename to backend/FwLite/FwLiteShared/Auth/OAuthClient.cs
index b33738f06..b9affb158 100644
--- a/backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs
+++ b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs
@@ -1,65 +1,56 @@
using System.Net.Http.Headers;
-using System.Security.Cryptography;
-using LocalWebApp.Routes;
-using LocalWebApp.Services;
+using FwLiteShared.Projects;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Extensions.Msal;
-namespace LocalWebApp.Auth;
+namespace FwLiteShared.Auth;
///
-/// when injected directly it will use the authority of the current project, to get a different authority use
+/// when injected directly it will use the authority of the current project, to get a different authority use
/// helper class for using MSAL.net
/// docs: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/overview
///
-public class AuthHelpers
+public class OAuthClient
{
public static IReadOnlyCollection DefaultScopes { get; } = ["profile", "openid"];
public const string AuthHttpClientName = "AuthHttpClient";
- private readonly HostString _redirectHost;
- private readonly bool _isRedirectHostGuess;
+ public string? RedirectUrl { get; }
private readonly IHttpMessageHandlerFactory _httpMessageHandlerFactory;
private readonly OAuthService _oAuthService;
- private readonly UrlContext _urlContext;
private readonly LexboxServer _lexboxServer;
private readonly LexboxProjectService _lexboxProjectService;
- private readonly ILogger _logger;
+ private readonly ILogger _logger;
private readonly IPublicClientApplication _application;
AuthenticationResult? _authResult;
- public AuthHelpers(LoggerAdapter loggerAdapter,
+ public OAuthClient(LoggerAdapter loggerAdapter,
IHttpMessageHandlerFactory httpMessageHandlerFactory,
IOptions options,
- LinkGenerator linkGenerator,
+ IRedirectUrlProvider? redirectUrlProvider,
OAuthService oAuthService,
- UrlContext urlContext,
LexboxServer lexboxServer,
LexboxProjectService lexboxProjectService,
- ILogger logger,
+ ILogger logger,
IHostEnvironment hostEnvironment)
{
_httpMessageHandlerFactory = httpMessageHandlerFactory;
_oAuthService = oAuthService;
- _urlContext = urlContext;
_lexboxServer = lexboxServer;
_lexboxProjectService = lexboxProjectService;
_logger = logger;
- (var hostUrl, _isRedirectHostGuess) = urlContext.GetUrl();
- _redirectHost = HostString.FromUriComponent(hostUrl);
- var redirectUri = options.Value.SystemWebViewLogin
+ RedirectUrl = options.Value.SystemWebViewLogin
? "http://localhost" //system web view will always have no path, changing this will not do anything in that case
- : linkGenerator.GetUriByRouteValues(AuthRoutes.CallbackRoute,
- new RouteValueDictionary(),
- hostUrl.Scheme,
- _redirectHost);
+ : redirectUrlProvider?.GetRedirectUrl() ?? throw new InvalidOperationException("No IRedirectUrlProvider configured, required for non-system web view login");
//todo configure token cache as seen here
//https://github.com/AzureAD/microsoft-authentication-extensions-for-dotnet/wiki/Cross-platform-Token-Cache
_application = PublicClientApplicationBuilder.Create(options.Value.ClientId)
.WithExperimentalFeatures()
.WithLogging(loggerAdapter, hostEnvironment.IsDevelopment())
.WithHttpClientFactory(new HttpClientFactoryAdapter(httpMessageHandlerFactory))
- .WithRedirectUri(redirectUri)
+ .WithRedirectUri(RedirectUrl)
.WithOidcAuthority(lexboxServer.Authority.ToString())
.Build();
_ = MsalCacheHelper.CreateAsync(BuildCacheProperties(options.Value.CacheFileName)).ContinueWith(
@@ -99,11 +90,6 @@ private static StorageCreationProperties BuildCacheProperties(string cacheFileNa
return propertiesBuilder.Build();
}
- public bool IsHostUrlValid()
- {
- return !_isRedirectHostGuess || _redirectHost == HostString.FromUriComponent(_urlContext.GetUrl().host);
- }
-
private class HttpClientFactoryAdapter(IHttpMessageHandlerFactory httpMessageHandlerFactory)
: IMsalHttpClientFactory
{
@@ -178,10 +164,10 @@ await _application
return auth?.AccessToken;
}
- ///
+ /// ]
/// will return null if no auth token is available
///
- public async ValueTask CreateClient()
+ public async ValueTask CreateHttpClient()
{
var auth = await GetAuth();
if (auth is null) return null;
diff --git a/backend/FwLite/FwLiteShared/Auth/OAuthClientFactory.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClientFactory.cs
new file mode 100644
index 000000000..1058e5aa2
--- /dev/null
+++ b/backend/FwLite/FwLiteShared/Auth/OAuthClientFactory.cs
@@ -0,0 +1,47 @@
+using System.Collections.Concurrent;
+using LcmCrdt;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace FwLiteShared.Auth;
+
+public class OAuthClientFactory(IServiceProvider provider,
+ IOptions options,
+ IRedirectUrlProvider? redirectUrlProvider,
+ ILogger logger)
+{
+ private readonly ConcurrentDictionary _helpers = new();
+
+ private string AuthorityKey(LexboxServer server) => "AuthHelper|" + server.Authority.Authority;
+
+ ///
+ /// gets an Auth Helper for the given server
+ ///
+ public OAuthClient GetClient(LexboxServer server)
+ {
+ var helper = _helpers.GetOrAdd(AuthorityKey(server),
+ static (host, arg) => ActivatorUtilities.CreateInstance(arg.provider, arg.server),
+ (server, provider));
+ //an auth helper can get created based on the server host, however in development that will not be the same as the client host
+ //so we need to recreate it if the host is not valid, this is only required when not using system web view login
+ if (!options.Value.SystemWebViewLogin && redirectUrlProvider is not null && redirectUrlProvider.ShouldRecreateAuthHelper(helper.RedirectUrl))
+ {
+ logger.LogInformation("Recreating auth helper with Redirect Url {RedirectUrl}", helper.RedirectUrl);
+ _helpers.TryRemove(AuthorityKey(server), out _);
+ return GetClient(server);
+ }
+
+ return helper;
+ }
+
+ ///
+ /// get auth helper for a given project
+ ///
+ public OAuthClient GetClient(ProjectData project)
+ {
+ var originDomain = project.OriginDomain;
+ if (string.IsNullOrEmpty(originDomain)) throw new InvalidOperationException("No origin domain in project data");
+ return GetClient(options.Value.GetServer(project));
+ }
+}
diff --git a/backend/FwLite/LocalWebApp/Auth/OAuthService.cs b/backend/FwLite/FwLiteShared/Auth/OAuthService.cs
similarity index 96%
rename from backend/FwLite/LocalWebApp/Auth/OAuthService.cs
rename to backend/FwLite/FwLiteShared/Auth/OAuthService.cs
index fe9d9c94a..9c0605a6c 100644
--- a/backend/FwLite/LocalWebApp/Auth/OAuthService.cs
+++ b/backend/FwLite/FwLiteShared/Auth/OAuthService.cs
@@ -1,11 +1,12 @@
using System.Threading.Channels;
using System.Web;
-using LocalWebApp.Utils;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Extensibility;
-namespace LocalWebApp.Auth;
+namespace FwLiteShared.Auth;
//this class is commented with a number of step comments, these are the steps in the OAuth flow
//if a step comes before a method that means it awaits that call, if it comes after that means it resumes after the above await
@@ -36,7 +37,7 @@ public async Task SubmitLoginRequest(IPublicClientApplication appl
private async Task HandleSystemWebViewLogin(IPublicClientApplication application, CancellationToken cancellation)
{
- var result = await application.AcquireTokenInteractive(AuthHelpers.DefaultScopes)
+ var result = await application.AcquireTokenInteractive(OAuthClient.DefaultScopes)
.WithUseEmbeddedWebView(false)
.WithSystemWebViewOptions(new() { })
.ExecuteAsync(cancellation);
@@ -69,7 +70,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
//todo we can get stuck here if the user doesn't complete the login, this basically bricks the login at the moment. We need a timeout or something
//step 2
- var result = await loginRequest.Application.AcquireTokenInteractive(AuthHelpers.DefaultScopes)
+ var result = await loginRequest.Application.AcquireTokenInteractive(OAuthClient.DefaultScopes)
.WithCustomWebUi(loginRequest)
.ExecuteAsync(stoppingToken);
//step 7, causes step 8 to resume
diff --git a/backend/FwLite/LocalWebApp/Utils/CancellationTokenExtensions.cs b/backend/FwLite/FwLiteShared/CancellationTokenExtensions.cs
similarity index 89%
rename from backend/FwLite/LocalWebApp/Utils/CancellationTokenExtensions.cs
rename to backend/FwLite/FwLiteShared/CancellationTokenExtensions.cs
index 35da0129f..51e0c859b 100644
--- a/backend/FwLite/LocalWebApp/Utils/CancellationTokenExtensions.cs
+++ b/backend/FwLite/FwLiteShared/CancellationTokenExtensions.cs
@@ -1,4 +1,4 @@
-namespace LocalWebApp.Utils;
+namespace FwLiteShared;
public static class CancellationTokenExtensions
{
diff --git a/backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs b/backend/FwLite/FwLiteShared/ChangeEventBus.cs
similarity index 52%
rename from backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs
rename to backend/FwLite/FwLiteShared/ChangeEventBus.cs
index 06d331d8e..29734cc0e 100644
--- a/backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs
+++ b/backend/FwLite/FwLiteShared/ChangeEventBus.cs
@@ -1,33 +1,13 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using LcmCrdt;
-using LocalWebApp.Hubs;
-using Microsoft.AspNetCore.SignalR;
-using Microsoft.Extensions.Caching.Memory;
using MiniLcm.Models;
-namespace LocalWebApp.Services;
+namespace FwLiteShared;
-public class ChangeEventBus(
- ProjectContext projectContext,
- IHubContext hubContext,
- ILogger logger,
- IMemoryCache cache)
+public class ChangeEventBus(ProjectContext projectContext)
: IDisposable
{
- public IDisposable ListenForEntryChanges(string projectName, string connectionId) =>
- _entryUpdated
- .Where(n => n.ProjectName == projectName)
- .Subscribe(n => OnEntryChangedExternal(n.Entry, connectionId));
-
- private void OnEntryChangedExternal(Entry e, string connectionId)
- {
- var currentFilter = CrdtMiniLcmApiHub.CurrentProjectFilter(cache, connectionId);
- if (currentFilter.Invoke(e))
- {
- _ = hubContext.Clients.Client(connectionId).OnEntryUpdated(e);
- }
- }
private record struct ChangeNotification(Entry Entry, string ProjectName);
diff --git a/backend/FwLite/FwLiteShared/FwLiteShared.csproj b/backend/FwLite/FwLiteShared/FwLiteShared.csproj
new file mode 100644
index 000000000..9da6832da
--- /dev/null
+++ b/backend/FwLite/FwLiteShared/FwLiteShared.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs
new file mode 100644
index 000000000..fc0ca094f
--- /dev/null
+++ b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs
@@ -0,0 +1,62 @@
+using FwDataMiniLcmBridge;
+using FwLiteProjectSync;
+using FwLiteShared.Auth;
+using FwLiteShared.Projects;
+using FwLiteShared.Sync;
+using LcmCrdt;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace FwLiteShared;
+
+public static class FwLiteSharedKernel
+{
+ public static IServiceCollection AddFwLiteShared(this IServiceCollection services, IHostEnvironment environment)
+ {
+ services.AddHttpClient();
+ services.AddAuthHelpers(environment);
+ services.AddLcmCrdtClient();
+ services.AddFwDataBridge();
+ services.AddFwLiteProjectSync();
+
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(s => s.GetRequiredService());
+ return services;
+ }
+
+ private static void AddAuthHelpers(this IServiceCollection services, IHostEnvironment environment)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddScoped(CurrentAuthHelperFactory);
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddOptionsWithValidateOnStart().BindConfiguration("Auth").ValidateDataAnnotations();
+ services.AddSingleton();
+ var httpClientBuilder = services.AddHttpClient(OAuthClient.AuthHttpClientName);
+ if (environment.IsDevelopment())
+ {
+ // Allow self-signed certificates in development
+ httpClientBuilder.ConfigurePrimaryHttpMessageHandler(() =>
+ {
+ return new HttpClientHandler
+ {
+ ClientCertificateOptions = ClientCertificateOption.Manual,
+ ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true
+ };
+ });
+ }
+ }
+
+ private static OAuthClient CurrentAuthHelperFactory(this IServiceProvider serviceProvider)
+ {
+ var authHelpersFactory = serviceProvider.GetRequiredService();
+ var currentProjectService = serviceProvider.GetRequiredService();
+ return authHelpersFactory.GetClient(currentProjectService.ProjectData);
+ }
+}
diff --git a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs
new file mode 100644
index 000000000..f53b28070
--- /dev/null
+++ b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs
@@ -0,0 +1,84 @@
+using FwDataMiniLcmBridge;
+using FwLiteShared.Auth;
+using FwLiteShared.Sync;
+using LcmCrdt;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FwLiteShared.Projects;
+
+public record ProjectModel(
+ string Name,
+ bool Crdt,
+ bool Fwdata,
+ bool Lexbox = false,
+ string? ServerAuthority = null,
+ Guid? Id = null);
+
+public record ServerProjects(LexboxServer Server, ProjectModel[] Projects);
+public class CombinedProjectsService(LexboxProjectService lexboxProjectService, CrdtProjectsService crdtProjectsService,
+ FieldWorksProjectList fieldWorksProjectList)
+{
+ public async Task RemoteProjects()
+ {
+ var lexboxServers = lexboxProjectService.Servers();
+ ServerProjects[] serverProjects = new ServerProjects[lexboxServers.Length];
+ for (var i = 0; i < lexboxServers.Length; i++)
+ {
+ var server = lexboxServers[i];
+ var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server);
+ serverProjects[i] = new ServerProjects(server,
+ lexboxProjects.Select(p => new ProjectModel(p.Name,
+ Crdt: p.IsCrdtProject,
+ Fwdata: false,
+ Lexbox: true,
+ server.Authority.Authority,
+ p.Id))
+ .ToArray());
+ }
+
+ return serverProjects;
+ }
+
+ public async Task> LocalProjects()
+ {
+ var crdtProjects = await crdtProjectsService.ListProjects();
+ //todo get project Id and use that to specify the Id in the model. Also pull out server
+ var projects = crdtProjects.ToDictionary(p => p.Name,
+ p =>
+ {
+ var uri = p.Data?.OriginDomain is not null ? new Uri(p.Data.OriginDomain) : null;
+ return new ProjectModel(p.Name,
+ true,
+ false,
+ p.Data?.OriginDomain is not null,
+ uri?.Authority,
+ p.Data?.Id);
+ });
+ //basically populate projects and indicate if they are lexbox or fwdata
+ foreach (var p in fieldWorksProjectList.EnumerateProjects())
+ {
+ if (projects.TryGetValue(p.Name, out var project))
+ {
+ projects[p.Name] = project with { Fwdata = true };
+ }
+ else
+ {
+ projects.Add(p.Name, new ProjectModel(p.Name, false, true));
+ }
+ }
+
+ return projects.Values;
+ }
+
+ public async Task DownloadProject(Guid lexboxProjectId, string projectName, LexboxServer server)
+ {
+ await crdtProjectsService.CreateProject(new(projectName,
+ lexboxProjectId,
+ server.Authority,
+ async (provider, project) =>
+ {
+ await provider.GetRequiredService().ExecuteSync();
+ },
+ SeedNewProjectData: false));
+ }
+}
diff --git a/backend/FwLite/LocalWebApp/Services/ImportFwdataService.cs b/backend/FwLite/FwLiteShared/Projects/ImportFwdataService.cs
similarity index 85%
rename from backend/FwLite/LocalWebApp/Services/ImportFwdataService.cs
rename to backend/FwLite/FwLiteShared/Projects/ImportFwdataService.cs
index 20f6ab04c..e78cc7a42 100644
--- a/backend/FwLite/LocalWebApp/Services/ImportFwdataService.cs
+++ b/backend/FwLite/FwLiteShared/Projects/ImportFwdataService.cs
@@ -1,14 +1,16 @@
using System.Diagnostics;
-using FwLiteProjectSync;
using FwDataMiniLcmBridge;
+using FwLiteProjectSync;
using Humanizer;
using LcmCrdt;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using MiniLcm;
-namespace LocalWebApp.Services;
+namespace FwLiteShared.Projects;
public class ImportFwdataService(
- ProjectsService projectsService,
+ CrdtProjectsService crdtProjectsService,
ILogger logger,
FwDataFactory fwDataFactory,
FieldWorksProjectList fieldWorksProjectList,
@@ -26,7 +28,7 @@ public async Task Import(string projectName)
try
{
using var fwDataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, false);
- var project = await projectsService.CreateProject(new(fwDataProject.Name,
+ var project = await crdtProjectsService.CreateProject(new(fwDataProject.Name,
SeedNewProjectData: false,
FwProjectId: fwDataApi.ProjectId,
AfterCreate: async (provider, project) =>
diff --git a/backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs b/backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs
similarity index 89%
rename from backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs
rename to backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs
index c392ce503..b60b2e915 100644
--- a/backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs
+++ b/backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs
@@ -1,14 +1,17 @@
-using LcmCrdt;
-using LocalWebApp.Auth;
-using Microsoft.Extensions.Options;
+using System.Net.Http.Json;
+using FwLiteShared.Auth;
+using FwLiteShared.Sync;
+using LcmCrdt;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
using MiniLcm.Push;
-namespace LocalWebApp.Services;
+namespace FwLiteShared.Projects;
public class LexboxProjectService(
- AuthHelpersFactory helpersFactory,
+ OAuthClientFactory clientFactory,
ILogger logger,
IHttpMessageHandlerFactory httpMessageHandlerFactory,
BackgroundSyncService backgroundSyncService,
@@ -28,7 +31,7 @@ public async Task GetLexboxProjects(LexboxServer server)
async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
- var httpClient = await helpersFactory.GetHelper(server).CreateClient();
+ var httpClient = await clientFactory.GetClient(server).CreateHttpClient();
if (httpClient is null) return [];
try
{
@@ -49,7 +52,7 @@ private static string CacheKey(LexboxServer server)
public async Task GetLexboxProjectId(LexboxServer server, string code)
{
- var httpClient = await helpersFactory.GetHelper(server).CreateClient();
+ var httpClient = await clientFactory.GetClient(server).CreateHttpClient();
if (httpClient is null) return null;
try
{
@@ -86,7 +89,7 @@ public async Task ListenForProjectChanges(ProjectData projectData, CancellationT
return connection;
}
- if (await helpersFactory.GetHelper(server).GetCurrentToken() is null)
+ if (await clientFactory.GetClient(server).GetCurrentToken() is null)
{
logger.LogWarning("Unable to create signalR client, user is not authenticated to {OriginDomain}", server.Authority);
@@ -103,10 +106,10 @@ public async Task ListenForProjectChanges(ProjectData projectData, CancellationT
connectionOptions.HttpMessageHandlerFactory = handler =>
{
//use a client that does not validate certs in dev
- return httpMessageHandlerFactory.CreateHandler(AuthHelpers.AuthHttpClientName);
+ return httpMessageHandlerFactory.CreateHandler(OAuthClient.AuthHttpClientName);
};
connectionOptions.AccessTokenProvider =
- async () => await helpersFactory.GetHelper(server).GetCurrentToken();
+ async () => await clientFactory.GetClient(server).GetCurrentToken();
})
.Build();
diff --git a/backend/FwLite/LocalWebApp/BackgroundSyncService.cs b/backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs
similarity index 87%
rename from backend/FwLite/LocalWebApp/BackgroundSyncService.cs
rename to backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs
index bbe93c26c..cacac6867 100644
--- a/backend/FwLite/LocalWebApp/BackgroundSyncService.cs
+++ b/backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs
@@ -1,12 +1,15 @@
using System.Threading.Channels;
-using SIL.Harmony;
using LcmCrdt;
using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SIL.Harmony;
-namespace LocalWebApp;
+namespace FwLiteShared.Sync;
public class BackgroundSyncService(
- ProjectsService projectsService,
+ CrdtProjectsService crdtProjectsService,
IHostApplicationLifetime applicationLifetime,
ProjectContext projectContext,
ILogger logger,
@@ -28,7 +31,7 @@ public void TriggerSync(Guid projectId, Guid? ignoredClientId = null)
return;
}
- var crdtProject = projectsService.GetProject(projectData.Name);
+ var crdtProject = crdtProjectsService.GetProject(projectData.Name);
if (crdtProject is null)
{
logger.LogWarning("Received project update for unknown project {ProjectName}", projectData.Name);
@@ -58,7 +61,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
//need to wait until application is started, otherwise Server urls will be unknown which prevents creating downstream services
await StartedAsync();
- var crdtProjects = await projectsService.ListProjects();
+ var crdtProjects = await crdtProjectsService.ListProjects();
foreach (var crdtProject in crdtProjects)
{
await SyncProject(crdtProject);
@@ -76,7 +79,7 @@ private async Task SyncProject(CrdtProject crdtProject)
{
try
{
- await using var serviceScope = projectsService.CreateProjectScope(crdtProject);
+ await using var serviceScope = crdtProjectsService.CreateProjectScope(crdtProject);
await serviceScope.ServiceProvider.GetRequiredService().PopulateProjectDataCache();
var syncService = serviceScope.ServiceProvider.GetRequiredService();
return await syncService.ExecuteSync();
diff --git a/backend/FwLite/LocalWebApp/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs
similarity index 76%
rename from backend/FwLite/LocalWebApp/SyncService.cs
rename to backend/FwLite/FwLiteShared/Sync/SyncService.cs
index 7c4be59f6..e797be14d 100644
--- a/backend/FwLite/LocalWebApp/SyncService.cs
+++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs
@@ -1,20 +1,21 @@
-using SIL.Harmony;
+using FwLiteShared.Auth;
+using FwLiteShared.Projects;
using LcmCrdt;
using LcmCrdt.RemoteSync;
-using LocalWebApp.Auth;
-using LocalWebApp.Services;
+using Microsoft.Extensions.Logging;
using MiniLcm;
using MiniLcm.Models;
-using SIL.Harmony.Entities;
+using SIL.Harmony;
-namespace LocalWebApp;
+namespace FwLiteShared.Sync;
public class SyncService(
DataModel dataModel,
CrdtHttpSyncService remoteSyncServiceServer,
- AuthHelpersFactory authHelpersFactory,
+ OAuthClientFactory oAuthClientFactory,
CurrentProjectService currentProjectService,
ChangeEventBus changeEventBus,
+ LexboxProjectService lexboxProjectService,
IMiniLcmApi lexboxApi,
ILogger logger)
{
@@ -28,7 +29,7 @@ public async Task ExecuteSync()
return new SyncResults([], [], false);
}
- var httpClient = await authHelpersFactory.GetHelper(project).CreateClient();
+ var httpClient = await oAuthClientFactory.GetClient(project).CreateHttpClient();
if (httpClient is null)
{
logger.LogWarning(
@@ -75,4 +76,21 @@ private async Task SendNotifications(SyncResults syncResults)
_ => null
};
}
+
+ public async Task UploadProject(Guid lexboxProjectId, LexboxServer server)
+ {
+ await currentProjectService.SetProjectSyncOrigin(server.Authority, lexboxProjectId);
+ try
+ {
+ await ExecuteSync();
+ }
+ catch
+ {
+ await currentProjectService.SetProjectSyncOrigin(null, null);
+ throw;
+ }
+
+ //todo maybe decouple this
+ lexboxProjectService.InvalidateProjectsCache(server);
+ }
}
diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/ComplexFormTests.cs b/backend/FwLite/LcmCrdt.Tests/Changes/ComplexFormTests.cs
index 5f5be9ed1..e53c60b01 100644
--- a/backend/FwLite/LcmCrdt.Tests/Changes/ComplexFormTests.cs
+++ b/backend/FwLite/LcmCrdt.Tests/Changes/ComplexFormTests.cs
@@ -11,21 +11,21 @@ public class ComplexFormTests(MiniLcmApiFixture fixture) : IClassFixture e.ComponentEntryId == coatEntry.Id);
+ complexEntry.Components.Should().ContainSingle(e => e.ComponentEntryId == coatEntry.Id);
complexEntry.Components.Should().ContainSingle(e => e.ComponentEntryId == rackEntry.Id);
coatEntry = await fixture.Api.GetEntry(coatEntry.Id);
coatEntry.Should().NotBeNull();
- coatEntry!.ComplexForms.Should().ContainSingle(e => e.ComplexFormEntryId == complexEntry.Id);
+ coatEntry.ComplexForms.Should().ContainSingle(e => e.ComplexFormEntryId == complexEntry.Id);
}
[Fact]
@@ -74,11 +74,11 @@ public async Task DeleteEntryComponent()
await fixture.DataModel.AddChange(Guid.NewGuid(), new AddEntryComponentChange(ComplexFormComponent.FromEntries(complexEntry, rackEntry)));
complexEntry = await fixture.Api.GetEntry(complexEntry.Id);
complexEntry.Should().NotBeNull();
- var component = complexEntry!.Components.First();
+ var component = complexEntry.Components.First();
await fixture.DataModel.AddChange(Guid.NewGuid(), new DeleteChange(component.Id));
complexEntry = await fixture.Api.GetEntry(complexEntry.Id);
complexEntry.Should().NotBeNull();
- complexEntry!.Components.Should().NotContain(c => c.Id == component.Id);
+ complexEntry.Components.Should().NotContain(c => c.Id == component.Id);
}
}
diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt
index a7b2cc8c5..5e1e13fe7 100644
--- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt
+++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt
@@ -24,6 +24,8 @@
ComponentEntryId (Guid) Required FK Index
ComponentHeadword (string)
ComponentSenseId (Guid?) FK Index
+ Annotations:
+ Relational:ColumnName: ComponentSenseId
DeletedAt (DateTimeOffset?)
SnapshotId (no field, Guid?) Shadow FK Index
Keys:
@@ -34,10 +36,15 @@
ComplexFormComponent {'ComponentSenseId'} -> Sense {'Id'} Cascade
ComplexFormComponent {'SnapshotId'} -> ObjectSnapshot {'Id'} Unique SetNull
Indexes:
- ComplexFormEntryId
ComponentEntryId
ComponentSenseId
SnapshotId Unique
+ ComplexFormEntryId, ComponentEntryId Unique
+ Annotations:
+ Relational:Filter: ComponentSenseId IS NULL
+ ComplexFormEntryId, ComponentEntryId, ComponentSenseId Unique
+ Annotations:
+ Relational:Filter: ComponentSenseId IS NOT NULL
Annotations:
DiscriminatorProperty:
Relational:FunctionName:
@@ -328,4 +335,4 @@
Relational:ViewName:
Relational:ViewSchema:
Annotations:
- ProductVersion: 8.0.4
\ No newline at end of file
+ ProductVersion: 8.0.11
\ No newline at end of file
diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs
index 359edff20..3173e290d 100644
--- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs
+++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs
@@ -43,7 +43,7 @@ public async Task InitializeAsync()
{
await _crdtDbContext.Database.OpenConnectionAsync();
//can't use ProjectsService.CreateProject because it opens and closes the db context, this would wipe out the in memory db.
- await ProjectsService.InitProjectDb(_crdtDbContext,
+ await CrdtProjectsService.InitProjectDb(_crdtDbContext,
new ProjectData("Sena 3", Guid.NewGuid(), null, Guid.NewGuid()));
await _services.ServiceProvider.GetRequiredService().PopulateProjectDataCache();
}
diff --git a/backend/FwLite/LcmCrdt.Tests/JsonPatchEntryRewriteTests.cs b/backend/FwLite/LcmCrdt.Tests/JsonPatchEntryRewriteTests.cs
index b2370c85e..13eb26933 100644
--- a/backend/FwLite/LcmCrdt.Tests/JsonPatchEntryRewriteTests.cs
+++ b/backend/FwLite/LcmCrdt.Tests/JsonPatchEntryRewriteTests.cs
@@ -20,7 +20,6 @@ public void ChangesFromJsonPatch_AddComponentMakesAddEntryComponentChange()
changes.Should().ContainSingle().Which.Should().BeOfType().Subject;
addEntryComponentChange.ComplexFormEntryId.Should().Be(_entry.Id);
addEntryComponentChange.ComponentEntryId.Should().Be(componentEntry.Id);
- addEntryComponentChange.ComponentHeadword.Should().Be(componentEntry.Headword());
}
[Fact]
@@ -84,7 +83,6 @@ public void ChangesFromJsonPatch_AddComplexFormMakesAddEntryComponentChange()
changes.Should().ContainSingle().Which.Should().BeOfType().Subject;
addEntryComponentChange.ComplexFormEntryId.Should().Be(_entry.Id);
addEntryComponentChange.ComponentEntryId.Should().Be(componentEntry.Id);
- addEntryComponentChange.ComponentHeadword.Should().Be(componentEntry.Headword());
}
[Fact]
diff --git a/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj b/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj
index d955b734a..82a3eb3d8 100644
--- a/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj
+++ b/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj
@@ -1,9 +1,6 @@
- net8.0
- enable
- enable
false
true
@@ -15,14 +12,14 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
-
+
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
all
diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs
index bc93339cc..1ce5a0321 100644
--- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs
+++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs
@@ -37,7 +37,7 @@ public async Task InitializeAsync()
_crdtDbContext = _services.ServiceProvider.GetRequiredService();
await _crdtDbContext.Database.OpenConnectionAsync();
//can't use ProjectsService.CreateProject because it opens and closes the db context, this would wipe out the in memory db.
- await ProjectsService.InitProjectDb(_crdtDbContext,
+ await CrdtProjectsService.InitProjectDb(_crdtDbContext,
new ProjectData("Sena 3", Guid.NewGuid(), null, Guid.NewGuid()));
await _services.ServiceProvider.GetRequiredService().PopulateProjectDataCache();
diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs
new file mode 100644
index 000000000..100c89e9d
--- /dev/null
+++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs
@@ -0,0 +1,19 @@
+namespace LcmCrdt.Tests.MiniLcmTests;
+
+public class SortingTests : SortingTestsBase
+{
+ private readonly MiniLcmApiFixture _fixture = new();
+
+ protected override async Task NewApi()
+ {
+ await _fixture.InitializeAsync();
+ var api = _fixture.Api;
+ return api;
+ }
+
+ public override async Task DisposeAsync()
+ {
+ await base.DisposeAsync();
+ await _fixture.DisposeAsync();
+ }
+}
diff --git a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs
index cdaa72ab0..9fb67472a 100644
--- a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs
+++ b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs
@@ -10,12 +10,13 @@ public class OpenProjectTests
public async Task OpeningAProjectWorks()
{
var sqliteConnectionString = "OpeningAProjectWorks.sqlite";
+ if (File.Exists(sqliteConnectionString)) File.Delete(sqliteConnectionString);
var builder = Host.CreateEmptyApplicationBuilder(null);
builder.Services.AddLcmCrdtClient();
using var host = builder.Build();
var services = host.Services;
var asyncScope = services.CreateAsyncScope();
- await asyncScope.ServiceProvider.GetRequiredService()
+ await asyncScope.ServiceProvider.GetRequiredService()
.CreateProject(new(Name: "OpeningAProjectWorks", Path: "", SeedNewProjectData: true));
var miniLcmApi = (CrdtMiniLcmApi)await asyncScope.ServiceProvider.OpenCrdtProject(new CrdtProject("OpeningAProjectWorks", sqliteConnectionString));
diff --git a/backend/FwLite/LcmCrdt.Tests/SerializationTests.cs b/backend/FwLite/LcmCrdt.Tests/SerializationTests.cs
index 268255141..52002507f 100644
--- a/backend/FwLite/LcmCrdt.Tests/SerializationTests.cs
+++ b/backend/FwLite/LcmCrdt.Tests/SerializationTests.cs
@@ -76,7 +76,7 @@ public void CanDeserializeMultiString()
};
var actualMs = JsonSerializer.Deserialize(json);
actualMs.Should().NotBeNull();
- actualMs!.Values.Should().ContainKey("en");
+ actualMs.Values.Should().ContainKey("en");
actualMs.Should().BeEquivalentTo(expectedMs);
}
diff --git a/backend/FwLite/LcmCrdt/Changes/Entries/AddEntryComponentChange.cs b/backend/FwLite/LcmCrdt/Changes/Entries/AddEntryComponentChange.cs
index a51313198..f93630058 100644
--- a/backend/FwLite/LcmCrdt/Changes/Entries/AddEntryComponentChange.cs
+++ b/backend/FwLite/LcmCrdt/Changes/Entries/AddEntryComponentChange.cs
@@ -10,10 +10,8 @@ namespace LcmCrdt.Changes.Entries;
public class AddEntryComponentChange : CreateChange, ISelfNamedType
{
public Guid ComplexFormEntryId { get; }
- public string? ComplexFormHeadword { get; }
public Guid ComponentEntryId { get; }
public Guid? ComponentSenseId { get; }
- public string? ComponentHeadword { get; }
[JsonConstructor]
public AddEntryComponentChange(Guid entityId,
@@ -24,9 +22,7 @@ public AddEntryComponentChange(Guid entityId,
Guid? componentSenseId = null) : base(entityId)
{
ComplexFormEntryId = complexFormEntryId;
- ComplexFormHeadword = complexFormHeadword;
ComponentEntryId = componentEntryId;
- ComponentHeadword = componentHeadword;
ComponentSenseId = componentSenseId;
}
@@ -41,17 +37,22 @@ public AddEntryComponentChange(Guid entityId,
public override async ValueTask NewEntity(Commit commit, ChangeContext context)
{
+ var complexFormEntry = await context.GetCurrent(ComplexFormEntryId);
+ var componentEntry = await context.GetCurrent(ComponentEntryId);
+ Sense? componentSense = null;
+ if (ComponentSenseId is not null)
+ componentSense = await context.GetCurrent(ComponentSenseId.Value);
return new ComplexFormComponent
{
Id = EntityId,
ComplexFormEntryId = ComplexFormEntryId,
- ComplexFormHeadword = ComplexFormHeadword,
+ ComplexFormHeadword = complexFormEntry?.Headword(),
ComponentEntryId = ComponentEntryId,
- ComponentHeadword = ComponentHeadword,
+ ComponentHeadword = componentEntry?.Headword(),
ComponentSenseId = ComponentSenseId,
- DeletedAt = (await context.IsObjectDeleted(ComponentEntryId) ||
- await context.IsObjectDeleted(ComplexFormEntryId) ||
- ComponentSenseId.HasValue && await context.IsObjectDeleted(ComponentSenseId.Value))
+ DeletedAt = (complexFormEntry?.DeletedAt is not null ||
+ componentEntry?.DeletedAt is not null ||
+ (ComponentSenseId.HasValue && componentSense?.DeletedAt is not null))
? commit.DateTime
: (DateTime?)null,
};
diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
index ad6914812..08fca04dc 100644
--- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
+++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
@@ -1,4 +1,5 @@
using System.Linq.Expressions;
+using FluentValidation;
using SIL.Harmony;
using SIL.Harmony.Changes;
using LcmCrdt.Changes;
@@ -9,23 +10,25 @@
using LinqToDB.EntityFrameworkCore;
using MiniLcm.Exceptions;
using MiniLcm.SyncHelpers;
+using MiniLcm.Validators;
using SIL.Harmony.Db;
namespace LcmCrdt;
-public class CrdtMiniLcmApi(DataModel dataModel, CurrentProjectService projectService, LcmCrdtDbContext dbContext) : IMiniLcmApi
+public class CrdtMiniLcmApi(DataModel dataModel, CurrentProjectService projectService, LcmCrdtDbContext dbContext, MiniLcmValidators validators) : IMiniLcmApi
{
private Guid ClientId { get; } = projectService.ProjectData.ClientId;
public ProjectData ProjectData => projectService.ProjectData;
- private IQueryable Entries => dataModel.QueryLatest();
- private IQueryable ComplexFormComponents => dataModel.QueryLatest();
- private IQueryable ComplexFormTypes => dataModel.QueryLatest();
- private IQueryable Senses => dataModel.QueryLatest();
- private IQueryable ExampleSentences => dataModel.QueryLatest();
- private IQueryable WritingSystems => dataModel.QueryLatest();
- private IQueryable SemanticDomains => dataModel.QueryLatest();
- private IQueryable PartsOfSpeech => dataModel.QueryLatest();
+ private IQueryable Entries => dataModel.QueryLatest().AsTracking(false);
+ private IQueryable ComplexFormComponents => dataModel.QueryLatest()
+ .AsTracking(false);
+ private IQueryable ComplexFormTypes => dataModel.QueryLatest().AsTracking(false);
+ private IQueryable Senses => dataModel.QueryLatest().AsTracking(false);
+ private IQueryable ExampleSentences => dataModel.QueryLatest().AsTracking(false);
+ private IQueryable WritingSystems => dataModel.QueryLatest().AsTracking(false);
+ private IQueryable SemanticDomains => dataModel.QueryLatest().AsTracking(false);
+ private IQueryable PartsOfSpeech => dataModel.QueryLatest().AsTracking(false);
public async Task GetWritingSystems()
{
@@ -56,6 +59,12 @@ public async Task UpdateWritingSystem(WritingSystemId id, Writing
return await dataModel.GetLatest(ws.Id) ?? throw new NullReferenceException();
}
+ public async Task UpdateWritingSystem(WritingSystem before, WritingSystem after)
+ {
+ await WritingSystemSync.Sync(after, before, this);
+ return await GetWritingSystem(after.WsId, after.Type) ?? throw new NullReferenceException("unable to find writing system with id " + after.WsId);
+ }
+
private WritingSystem? _defaultVernacularWs;
private WritingSystem? _defaultAnalysisWs;
private async Task GetWritingSystem(WritingSystemId id, WritingSystemType type)
@@ -97,6 +106,12 @@ public async Task UpdatePartOfSpeech(Guid id, UpdateObjectInput UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after)
+ {
+ await PartOfSpeechSync.Sync(before, after, this);
+ return await GetPartOfSpeech(after.Id) ?? throw new NullReferenceException($"unable to find part of speech with id {after.Id}");
+ }
+
public async Task DeletePartOfSpeech(Guid id)
{
await dataModel.AddChange(ClientId, new DeleteChange(id));
@@ -127,6 +142,12 @@ public async Task UpdateSemanticDomain(Guid id, UpdateObjectInpu
return await GetSemanticDomain(id) ?? throw new NullReferenceException();
}
+ public async Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after)
+ {
+ await SemanticDomainSync.Sync(before, after, this);
+ return await GetSemanticDomain(after.Id) ?? throw new NullReferenceException($"unable to find semantic domain with id {after.Id}");
+ }
+
public async Task DeleteSemanticDomain(Guid id)
{
await dataModel.AddChange(ClientId, new DeleteChange(id));
@@ -134,7 +155,7 @@ public async Task DeleteSemanticDomain(Guid id)
public async Task BulkImportSemanticDomains(IEnumerable semanticDomains)
{
- await dataModel.AddChanges(ClientId, semanticDomains.Select(sd => new CreateSemanticDomainChange(sd.Id, sd.Name, sd.Code)));
+ await dataModel.AddChanges(ClientId, semanticDomains.Select(sd => new CreateSemanticDomainChange(sd.Id, sd.Name, sd.Code, sd.Predefined)));
}
public IAsyncEnumerable GetComplexFormTypes()
@@ -144,6 +165,7 @@ public IAsyncEnumerable GetComplexFormTypes()
public async Task CreateComplexFormType(ComplexFormType complexFormType)
{
+ await validators.ValidateAndThrow(complexFormType);
if (complexFormType.Id == default) complexFormType.Id = Guid.NewGuid();
await dataModel.AddChange(ClientId, new CreateComplexFormType(complexFormType.Id, complexFormType.Name));
return await ComplexFormTypes.SingleAsync(c => c.Id == complexFormType.Id);
@@ -151,6 +173,11 @@ public async Task CreateComplexFormType(ComplexFormType complex
public async Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent)
{
+ var existing = await ComplexFormComponents.SingleOrDefaultAsync(c =>
+ c.ComplexFormEntryId == complexFormComponent.ComplexFormEntryId
+ && c.ComponentEntryId == complexFormComponent.ComponentEntryId
+ && c.ComponentSenseId == complexFormComponent.ComponentSenseId);
+ if (existing is not null) return existing;
var addEntryComponentChange = new AddEntryComponentChange(complexFormComponent);
await dataModel.AddChange(ClientId, addEntryComponentChange);
return (await ComplexFormComponents.SingleOrDefaultAsync(c => c.Id == addEntryComponentChange.EntityId)) ?? throw NotFoundException.ForType();
@@ -206,7 +233,7 @@ private async IAsyncEnumerable GetEntries(
queryable = queryable.WhereExemplar(ws.Value, options.Exemplar.Value);
}
- var sortWs = (await GetWritingSystem(options.Order.WritingSystem, WritingSystemType.Vernacular))?.WsId;
+ var sortWs = (await GetWritingSystem(options.Order.WritingSystem, WritingSystemType.Vernacular));
if (sortWs is null)
throw new NullReferenceException($"sort writing system {options.Order.WritingSystem} not found");
queryable = queryable
@@ -214,7 +241,7 @@ private async IAsyncEnumerable GetEntries(
.LoadWith(e => e.ComplexForms)
.LoadWith(e => e.Components)
.AsQueryable()
- .OrderBy(e => e.Headword(sortWs.Value))
+ .OrderBy(e => e.Headword(sortWs.WsId).CollateUnicode(sortWs))
.ThenBy(e => e.Id)
.Skip(options.Offset)
.Take(options.Count);
@@ -256,7 +283,12 @@ public async Task BulkCreateEntries(IAsyncEnumerable entries)
{
var semanticDomains = await SemanticDomains.ToDictionaryAsync(sd => sd.Id, sd => sd);
var partsOfSpeech = await PartsOfSpeech.ToDictionaryAsync(p => p.Id, p => p);
- await dataModel.AddChanges(ClientId, entries.ToBlockingEnumerable().SelectMany(entry => CreateEntryChanges(entry, semanticDomains, partsOfSpeech)));
+ await dataModel.AddChanges(ClientId,
+ entries.ToBlockingEnumerable()
+ .SelectMany(entry => CreateEntryChanges(entry, semanticDomains, partsOfSpeech))
+ //force entries to be created first, this avoids issues where references are created before the entry is created
+ .OrderBy(c => c is CreateEntryChange ? 0 : 1)
+ );
}
private IEnumerable CreateEntryChanges(Entry entry, Dictionary semanticDomains, Dictionary partsOfSpeech)
@@ -458,6 +490,14 @@ public async Task CreateExampleSentence(Guid entryId,
return await dataModel.GetLatest(exampleSentence.Id) ?? throw new NullReferenceException();
}
+ public async Task GetExampleSentence(Guid entryId, Guid senseId, Guid id)
+ {
+ var exampleSentence = await ExampleSentences.AsTracking(false)
+ .AsQueryable()
+ .SingleOrDefaultAsync(e => e.Id == id);
+ return exampleSentence;
+ }
+
public async Task UpdateExampleSentence(Guid entryId,
Guid senseId,
Guid exampleSentenceId,
@@ -469,6 +509,15 @@ public async Task UpdateExampleSentence(Guid entryId,
return await dataModel.GetLatest(exampleSentenceId) ?? throw new NullReferenceException();
}
+ public async Task UpdateExampleSentence(Guid entryId,
+ Guid senseId,
+ ExampleSentence before,
+ ExampleSentence after)
+ {
+ await ExampleSentenceSync.Sync(entryId, senseId, after, before, this);
+ return await GetExampleSentence(entryId, senseId, after.Id) ?? throw new NullReferenceException();
+ }
+
public async Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId)
{
await dataModel.AddChange(ClientId, new DeleteChange(exampleSentenceId));
diff --git a/backend/FwLite/LcmCrdt/ProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs
similarity index 60%
rename from backend/FwLite/LcmCrdt/ProjectsService.cs
rename to backend/FwLite/LcmCrdt/CrdtProjectsService.cs
index 63aba27fa..aadc0dbaf 100644
--- a/backend/FwLite/LcmCrdt/ProjectsService.cs
+++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs
@@ -1,4 +1,5 @@
-using SIL.Harmony;
+using System.Text.RegularExpressions;
+using SIL.Harmony;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -7,7 +8,7 @@
namespace LcmCrdt;
-public class ProjectsService(IServiceProvider provider, ProjectContext projectContext, ILogger logger, IOptions config, IMemoryCache memoryCache)
+public partial class CrdtProjectsService(IServiceProvider provider, ProjectContext projectContext, ILogger logger, IOptions config, IMemoryCache memoryCache)
{
public Task ListProjects()
{
@@ -42,8 +43,14 @@ public record CreateProjectRequest(
string? Path = null,
Guid? FwProjectId = null);
+ public async Task CreateExampleProject(string name)
+ {
+ return await CreateProject(new(name, AfterCreate: SampleProjectData, SeedNewProjectData: true));
+ }
+
public async Task CreateProject(CreateProjectRequest request)
{
+ if (!ProjectName().IsMatch(request.Name)) throw new InvalidOperationException("Project name is invalid");
//poor man's sanitation
var name = Path.GetFileName(request.Name);
var sqliteFile = Path.Combine(request.Path ?? config.Value.ProjectPath, $"{name}.sqlite");
@@ -104,4 +111,62 @@ public void SetActiveProject(string name)
var project = GetProject(name) ?? throw new InvalidOperationException($"Crdt Project {name} not found");
SetProjectScope(project);
}
+
+ [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")]
+ public static partial Regex ProjectName();
+
+ public static async Task SampleProjectData(IServiceProvider provider, CrdtProject project)
+ {
+ var lexboxApi = provider.GetRequiredService();
+ await lexboxApi.CreateEntry(new()
+ {
+ Id = Guid.NewGuid(),
+ LexemeForm = { Values = { { "en", "Apple" } } },
+ CitationForm = { Values = { { "en", "Apple" } } },
+ LiteralMeaning = { Values = { { "en", "Fruit" } } },
+ Senses =
+ [
+ new()
+ {
+ Gloss = { Values = { { "en", "Fruit" } } },
+ Definition =
+ {
+ Values =
+ {
+ {
+ "en",
+ "fruit with red, yellow, or green skin with a sweet or tart crispy white flesh"
+ }
+ }
+ },
+ SemanticDomains = [],
+ ExampleSentences = [new() { Sentence = { Values = { { "en", "We ate an apple" } } } }]
+ }
+ ]
+ });
+
+ await lexboxApi.CreateWritingSystem(WritingSystemType.Vernacular,
+ new()
+ {
+ Id = Guid.NewGuid(),
+ Type = WritingSystemType.Vernacular,
+ WsId = "en",
+ Name = "English",
+ Abbreviation = "en",
+ Font = "Arial",
+ Exemplars = WritingSystem.LatinExemplars
+ });
+
+ await lexboxApi.CreateWritingSystem(WritingSystemType.Analysis,
+ new()
+ {
+ Id = Guid.NewGuid(),
+ Type = WritingSystemType.Analysis,
+ WsId = "en",
+ Name = "English",
+ Abbreviation = "en",
+ Font = "Arial",
+ Exemplars = WritingSystem.LatinExemplars
+ });
+ }
}
diff --git a/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs b/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs
new file mode 100644
index 000000000..1a9a1033a
--- /dev/null
+++ b/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs
@@ -0,0 +1,152 @@
+using System.Data;
+using System.Data.Common;
+using System.Globalization;
+using System.Text;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+
+namespace LcmCrdt.Data;
+
+public class SetupCollationInterceptor(IMemoryCache cache, ILogger logger) : IDbConnectionInterceptor, ISaveChangesInterceptor
+{
+ private WritingSystem[] GetWritingSystems(LcmCrdtDbContext dbContext, DbConnection connection)
+ {
+ //todo this needs to be invalidated when the writing systems change
+ return cache.GetOrCreate(CacheKey(connection),
+ entry =>
+ {
+ entry.SlidingExpiration = TimeSpan.FromMinutes(30);
+ try
+ {
+
+ return dbContext.WritingSystems.ToArray();
+ }
+ catch (SqliteException e)
+ {
+ return [];
+ }
+ }) ?? [];
+ }
+
+ private static string CacheKey(DbConnection connection)
+ {
+ return $"writingSystems|{connection.ConnectionString}";
+ }
+
+ private void InvalidateWritingSystemsCache(DbConnection connection)
+ {
+ cache.Remove(CacheKey(connection));
+ }
+
+ public void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
+ {
+ var context = (LcmCrdtDbContext?)eventData.Context;
+ if (context is null) throw new InvalidOperationException("context is null");
+ var sqliteConnection = (SqliteConnection)connection;
+ SetupCollations(sqliteConnection, GetWritingSystems(context, connection));
+
+ //setup general use collation
+ sqliteConnection.CreateCollation(SqlSortingExtensions.CollateUnicodeNoCase,
+ CultureInfo.CurrentCulture.CompareInfo,
+ (compareInfo, x, y) => compareInfo.Compare(x, y, CompareOptions.IgnoreCase));
+ }
+
+ public Task ConnectionOpenedAsync(DbConnection connection,
+ ConnectionEndEventData eventData,
+ CancellationToken cancellationToken = default)
+ {
+ ConnectionOpened(connection, eventData);
+ return Task.CompletedTask;
+ }
+
+ public InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result)
+ {
+ UpdateCollationsOnSave(eventData.Context);
+ return result;
+ }
+
+ public ValueTask> SavingChangesAsync(DbContextEventData eventData,
+ InterceptionResult result,
+ CancellationToken cancellationToken = default)
+ {
+ UpdateCollationsOnSave(eventData.Context);
+ return ValueTask.FromResult(result);
+ }
+
+ private void UpdateCollationsOnSave(DbContext? dbContext)
+ {
+ if (dbContext is null) return;
+ var connection = (SqliteConnection)dbContext.Database.GetDbConnection();
+ bool updateWs = false;
+ foreach (var entityEntry in dbContext.ChangeTracker.Entries())
+ {
+ if (entityEntry.State is EntityState.Added or EntityState.Modified)
+ {
+ var writingSystem = entityEntry.Entity;
+ SetupCollation(connection, writingSystem);
+ updateWs = true;
+ }
+ }
+
+ if (updateWs)
+ {
+ InvalidateWritingSystemsCache(connection);
+ }
+ }
+
+ private void SetupCollations(SqliteConnection connection, WritingSystem[] writingSystems)
+ {
+ foreach (var writingSystem in writingSystems)
+ {
+ SetupCollation(connection, writingSystem);
+ }
+ }
+
+ private void SetupCollation(SqliteConnection connection, WritingSystem writingSystem)
+ {
+ CompareInfo compareInfo;
+ try
+ {
+ //todo use ICU/SLDR instead
+ compareInfo = CultureInfo.CreateSpecificCulture(writingSystem.WsId.Code).CompareInfo;
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "Failed to create compare info for '{WritingSystemId}'", writingSystem.WsId);
+ compareInfo = CultureInfo.InvariantCulture.CompareInfo;
+ }
+
+ //todo use custom comparison based on the writing system
+ CreateSpanCollation(connection, SqlSortingExtensions.CollationName(writingSystem),
+ compareInfo,
+ static (compareInfo, x, y) => compareInfo.Compare(x, y, CompareOptions.IgnoreCase));
+ }
+
+ //this is a premature optimization, but it avoids creating strings for each comparison and instead uses spans which avoids allocations
+ //if the new comparison function does not support spans then we can use SqliteConnection.CreateCollation instead which works with strings
+ private void CreateSpanCollation(SqliteConnection connection,
+ string name, T state,
+ Func, ReadOnlySpan, int> compare)
+ {
+ if (connection.State != ConnectionState.Open)
+ throw new InvalidOperationException("Unable to create custom collation Connection must be open.");
+ var rc = SQLitePCL.raw.sqlite3__create_collation_utf8(connection.Handle,
+ name,
+ Tuple.Create(state, compare),
+ static (s, x, y) =>
+ {
+ var (state, compare) = (Tuple, ReadOnlySpan, int>>) s;
+ Span xSpan = stackalloc char[Encoding.UTF8.GetCharCount(x)];
+ Span ySpan = stackalloc char[Encoding.UTF8.GetCharCount(y)];
+ Encoding.UTF8.GetChars(x, xSpan);
+ Encoding.UTF8.GetChars(y, ySpan);
+
+ return compare(state, xSpan, ySpan);
+ });
+ SqliteException.ThrowExceptionForRC(rc, connection.Handle);
+
+ }
+}
diff --git a/backend/FwLite/LcmCrdt/Data/SqlSortingExtensions.cs b/backend/FwLite/LcmCrdt/Data/SqlSortingExtensions.cs
new file mode 100644
index 000000000..8e5465fa2
--- /dev/null
+++ b/backend/FwLite/LcmCrdt/Data/SqlSortingExtensions.cs
@@ -0,0 +1,30 @@
+using System.Data.SQLite;
+using System.Linq.Expressions;
+using LinqToDB;
+using SIL.WritingSystems;
+
+namespace LcmCrdt.Data;
+
+public static class SqlSortingExtensions
+{
+ public const string CollateUnicodeNoCase = "NOCASE_UNICODE";
+
+ [ExpressionMethod(nameof(CollateUnicodeExpression))]
+ internal static string CollateUnicode(this string value, WritingSystem ws)
+ {
+ //could optionally just return the value here, but it would work differently than sql
+ throw new InvalidOperationException("CollateUnicode is a LinqToDB only API.");
+ }
+
+ private static Expression> CollateUnicodeExpression()
+ {
+ //todo maybe in the future we use a custom collation based on the writing system
+ return (s, ws) => s.Collate(CollationName(ws));
+ }
+
+ internal static string CollationName(WritingSystem ws)
+ {
+ //don't use ':' in the name, it won't work
+ return $"NOCASE_WS_{ws.WsId}";
+ }
+}
diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs
new file mode 100644
index 000000000..e70e6c412
--- /dev/null
+++ b/backend/FwLite/LcmCrdt/HistoryService.cs
@@ -0,0 +1,104 @@
+using Humanizer;
+using SIL.Harmony;
+using SIL.Harmony.Changes;
+using SIL.Harmony.Core;
+using SIL.Harmony.Db;
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using SIL.Harmony.Entities;
+
+namespace LcmCrdt;
+public record ProjectActivity(
+ Guid CommitId,
+ DateTimeOffset Timestamp,
+ List> Changes)
+{
+ public string ChangeName => ChangeNameHelper(Changes);
+
+ private static string ChangeNameHelper(List> changeEntities)
+ {
+ return changeEntities switch
+ {
+ { Count: 0 } => "No changes",
+ { Count: 1 } => changeEntities[0].Change switch
+ {
+ //todo call JsonPatchChange.Summarize() instead of this
+ IChange change when change.GetType().Name.StartsWith("JsonPatchChange") => "Change " +
+ change.EntityType.Name,
+ IChange change => change.GetType().Name.Humanize()
+ },
+ { Count: var count } => $"{count} changes"
+ };
+ }
+}
+
+public record HistoryLineItem(
+ Guid CommitId,
+ Guid EntityId,
+ DateTimeOffset Timestamp,
+ Guid? SnapshotId,
+ string? ChangeName,
+ IObjectWithId? Entity,
+ string? EntityName)
+{
+ public HistoryLineItem(
+ Guid commitId,
+ Guid entityId,
+ DateTimeOffset timestamp,
+ Guid? snapshotId,
+ IChange? change,
+ IObjectBase? entity,
+ string typeName) : this(commitId,
+ entityId,
+ new DateTimeOffset(timestamp.Ticks,
+ TimeSpan.Zero), //todo this is a workaround for linq2db bug where it reads a date and assumes it's local when it's UTC
+ snapshotId,
+ change?.GetType().Name,
+ (IObjectWithId?) entity?.DbObject,
+ typeName)
+ {
+ }
+}
+
+public class HistoryService(ICrdtDbContext dbContext, DataModel dataModel)
+{
+ public IAsyncEnumerable ProjectActivity()
+ {
+ return dbContext.Commits
+ .DefaultOrderDescending()
+ .Take(20)
+ .Select(c => new ProjectActivity(c.Id, c.HybridDateTime.DateTime, c.ChangeEntities))
+ .AsAsyncEnumerable();
+ }
+
+ public async Task GetSnapshot(Guid snapshotId)
+ {
+ return await dbContext.Snapshots.SingleOrDefaultAsync(s => s.Id == snapshotId);
+ }
+
+ public async Task GetObject(DateTime timestamp, Guid entityId)
+ {
+ //todo requires the timestamp to be exact, otherwise the change made on that timestamp will not be included
+ //consider using a commitId and looking up the timestamp, but then we should be exact to the commit which we aren't right now.
+ return await dataModel.GetAtTime(new DateTimeOffset(timestamp), entityId);
+ }
+
+ public IAsyncEnumerable GetHistory(Guid entityId)
+ {
+ var changeEntities = dbContext.Set>();
+ var query = from commit in dbContext.Commits.DefaultOrder()
+ from snapshot in dbContext.Snapshots.LeftJoin(
+ s => s.CommitId == commit.Id && s.EntityId == entityId)
+ from change in changeEntities.LeftJoin(c =>
+ c.CommitId == commit.Id && c.EntityId == entityId)
+ where snapshot.Id != null || change.EntityId != null
+ select new HistoryLineItem(commit.Id,
+ entityId,
+ commit.HybridDateTime.DateTime,
+ snapshot.Id,
+ change.Change,
+ snapshot.Entity,
+ snapshot.TypeName);
+ return query.ToLinqToDB().AsAsyncEnumerable();
+ }
+}
diff --git a/backend/FwLite/LcmCrdt/LcmCrdt.csproj b/backend/FwLite/LcmCrdt/LcmCrdt.csproj
index c2382ec8a..66bd926ff 100644
--- a/backend/FwLite/LcmCrdt/LcmCrdt.csproj
+++ b/backend/FwLite/LcmCrdt/LcmCrdt.csproj
@@ -1,9 +1,6 @@
- net8.0
- enable
- enable
$(ApplicationDisplayVersion)
$(ApplicationDisplayVersion)
@@ -13,13 +10,14 @@
+
-
+
-
-
-
+
+
+
diff --git a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs
index 525d1d8e6..26e37bf6d 100644
--- a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs
+++ b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs
@@ -1,16 +1,25 @@
-using System.Text.Json;
+using System.Data.Common;
+using System.Text.Json;
+using LcmCrdt.Data;
+using Microsoft.Data.Sqlite;
using SIL.Harmony;
using SIL.Harmony.Db;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.Options;
namespace LcmCrdt;
-public class LcmCrdtDbContext(DbContextOptions dbContextOptions, IOptions options): DbContext(dbContextOptions), ICrdtDbContext
+public class LcmCrdtDbContext(DbContextOptions dbContextOptions, IOptions options, SetupCollationInterceptor setupCollationInterceptor)
+ : DbContext(dbContextOptions), ICrdtDbContext
{
public DbSet ProjectData => Set();
- public IQueryable Snapshots => ((ICrdtDbContext)this).Snapshots;
+ public IQueryable WritingSystems => Set().AsNoTracking();
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ {
+ optionsBuilder.AddInterceptors(setupCollationInterceptor);
+ }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs
index 9fd13ce3e..151e08652 100644
--- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs
+++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs
@@ -5,6 +5,7 @@
using SIL.Harmony.Changes;
using LcmCrdt.Changes;
using LcmCrdt.Changes.Entries;
+using LcmCrdt.Data;
using LcmCrdt.Objects;
using LcmCrdt.RemoteSync;
using LinqToDB;
@@ -16,6 +17,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using MiniLcm.Validators;
using Refit;
using SIL.Harmony.Db;
@@ -27,6 +29,7 @@ public static IServiceCollection AddLcmCrdtClient(this IServiceCollection servic
{
LinqToDBForEFTools.Initialize();
services.AddMemoryCache();
+ services.AddSingleton();
services.AddDbContext(ConfigureDbOptions);
services.AddOptions().BindConfiguration("LcmCrdt");
@@ -34,9 +37,11 @@ public static IServiceCollection AddLcmCrdtClient(this IServiceCollection servic
ConfigureCrdt
);
services.AddScoped();
+ services.AddMiniLcmValidators();
services.AddScoped();
+ services.AddScoped();
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton();
services.AddHttpClient();
services.AddSingleton(provider => new RefitSettings
@@ -134,7 +139,22 @@ public static void ConfigureCrdt(CrdtConfig config)
.Add()
.Add(builder =>
{
+ const string componentSenseId = "ComponentSenseId";
builder.ToTable("ComplexFormComponents");
+ builder.Property(c => c.ComponentSenseId).HasColumnName(componentSenseId);
+ //these indexes are used to ensure that we don't create duplicate complex form components
+ //we need the filter otherwise 2 components which are the same and have a null sense id can be created because 2 rows with the same null are not considered duplicates
+ builder.HasIndex(component => new
+ {
+ component.ComplexFormEntryId,
+ component.ComponentEntryId,
+ component.ComponentSenseId
+ }).IsUnique().HasFilter($"{componentSenseId} IS NOT NULL");
+ builder.HasIndex(component => new
+ {
+ component.ComplexFormEntryId,
+ component.ComponentEntryId
+ }).IsUnique().HasFilter($"{componentSenseId} IS NULL");
});
config.ChangeTypeListBuilder.Add>()
@@ -173,7 +193,7 @@ public static Task OpenCrdtProject(this IServiceProvider services,
//this method must not be async, otherwise Setting the project scope will not work as expected.
//the project is stored in the async scope, if a new scope is created in this method then it will be gone once the method returns
//making the lcm api unusable
- var projectsService = services.GetRequiredService();
+ var projectsService = services.GetRequiredService();
projectsService.SetProjectScope(project);
return LoadMiniLcmApi(services);
}
diff --git a/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs b/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs
deleted file mode 100644
index d9fc64aac..000000000
--- a/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using System.Collections.Concurrent;
-using LcmCrdt;
-using LocalWebApp.Services;
-using Microsoft.Extensions.Options;
-
-namespace LocalWebApp.Auth;
-
-public class AuthHelpersFactory(
- IServiceProvider provider,
- ProjectContext projectContext,
- IOptions options,
- IHttpContextAccessor contextAccessor)
-{
- private readonly ConcurrentDictionary _helpers = new();
-
- private string AuthorityKey(LexboxServer server) => "AuthHelper|" + server.Authority.Authority;
-
- ///
- /// gets an Auth Helper for the given server
- ///
- public AuthHelpers GetHelper(LexboxServer server)
- {
- var helper = _helpers.GetOrAdd(AuthorityKey(server),
- static (host, arg) => ActivatorUtilities.CreateInstance(arg.provider, arg.server),
- (server, provider));
- //an auth helper can get created based on the server host, however in development that will not be the same as the client host
- //so we need to recreate it if the host is not valid
- if (!helper.IsHostUrlValid())
- {
- _helpers.TryRemove(AuthorityKey(server), out _);
- return GetHelper(server);
- }
-
- return helper;
- }
-
- ///
- /// get auth helper for a given project
- ///
- public AuthHelpers GetHelper(ProjectData project)
- {
- var originDomain = project.OriginDomain;
- if (string.IsNullOrEmpty(originDomain)) throw new InvalidOperationException("No origin domain in project data");
- return GetHelper(options.Value.GetServer(project));
- }
-
- ///
- /// get the auth helper for the current project, this method is used when trying to inject an AuthHelper into a service
- ///
- /// when not in the context of a project (typically requests include the project name in the path)
- public AuthHelpers GetCurrentHelper()
- {
- if (projectContext.Project is null)
- throw new InvalidOperationException("No current project, probably not in a request context");
- var currentProjectService =
- contextAccessor.HttpContext?.RequestServices.GetRequiredService();
- if (currentProjectService is null) throw new InvalidOperationException("No current project service");
- return GetHelper(currentProjectService.ProjectData);
- }
-}
diff --git a/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs b/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs
index dcc92ae36..e9725f397 100644
--- a/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs
+++ b/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs
@@ -1,6 +1,10 @@
-using LcmCrdt;
+using FwLiteShared;
+using FwLiteShared.Projects;
+using FwLiteShared.Sync;
+using LcmCrdt;
using LcmCrdt.Data;
using LocalWebApp.Services;
+using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using MiniLcm;
using MiniLcm.Models;
@@ -15,7 +19,9 @@ public class CrdtMiniLcmApiHub(
ChangeEventBus changeEventBus,
CurrentProjectService projectContext,
LexboxProjectService lexboxProjectService,
- IMemoryCache memoryCache) : MiniLcmApiHubBase(miniLcmApi)
+ IMemoryCache memoryCache,
+ IHubContext hubContext
+) : MiniLcmApiHubBase(miniLcmApi)
{
public const string ProjectRouteKey = "project";
public static string ProjectGroup(string projectName) => "crdt-" + projectName;
@@ -31,12 +37,24 @@ public override async Task OnConnectedAsync()
await syncService.ExecuteSync();
Cleanup =
[
- changeEventBus.ListenForEntryChanges(projectContext.Project.Name, Context.ConnectionId)
+ changeEventBus.OnEntryUpdated.Subscribe(e => OnEntryChangedExternal(e, hubContext, memoryCache, Context.ConnectionId))
];
await lexboxProjectService.ListenForProjectChanges(projectContext.ProjectData, Context.ConnectionAborted);
}
+ private static void OnEntryChangedExternal(Entry entry,
+ IHubContext hubContext,
+ IMemoryCache cache,
+ string connectionId)
+ {
+ var currentFilter = CurrentProjectFilter(cache, connectionId);
+ if (currentFilter.Invoke(entry))
+ {
+ _ = hubContext.Clients.Client(connectionId).OnEntryUpdated(entry);
+ }
+ }
+
public override async Task OnDisconnectedAsync(Exception? exception)
{
await base.OnDisconnectedAsync(exception);
diff --git a/backend/FwLite/LocalWebApp/LocalAppKernel.cs b/backend/FwLite/LocalWebApp/LocalAppKernel.cs
index aa27e512d..4b375f243 100644
--- a/backend/FwLite/LocalWebApp/LocalAppKernel.cs
+++ b/backend/FwLite/LocalWebApp/LocalAppKernel.cs
@@ -2,9 +2,11 @@
using SIL.Harmony;
using FwLiteProjectSync;
using FwDataMiniLcmBridge;
+using FwLiteShared;
+using FwLiteShared.Auth;
+using FwLiteShared.Sync;
using LcmCrdt;
using LocalWebApp.Services;
-using LocalWebApp.Auth;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
@@ -17,18 +19,9 @@ public static class LocalAppKernel
public static IServiceCollection AddLocalAppServices(this IServiceCollection services, IHostEnvironment environment)
{
services.AddHttpContextAccessor();
- services.AddHttpClient();
- services.AddAuthHelpers(environment);
services.AddSingleton();
- services.AddScoped();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton(s => s.GetRequiredService());
- services.AddLcmCrdtClient();
- services.AddFwLiteProjectSync();
- services.AddFwDataBridge();
+ services.AddSingleton();
+ services.AddFwLiteShared(environment);
services.AddOptions().BindConfiguration("LocalWebApp");
@@ -44,28 +37,4 @@ public static IServiceCollection AddLocalAppServices(this IServiceCollection ser
});
return services;
}
-
- private static void AddAuthHelpers(this IServiceCollection services, IHostEnvironment environment)
- {
- services.AddSingleton();
- services.AddTransient(sp => sp.GetRequiredService().GetCurrentHelper());
- services.AddSingleton();
- services.AddSingleton(sp => sp.GetRequiredService());
- services.AddOptionsWithValidateOnStart().BindConfiguration("Auth").ValidateDataAnnotations();
- services.AddSingleton();
- var httpClientBuilder = services.AddHttpClient(AuthHelpers.AuthHttpClientName);
- if (environment.IsDevelopment())
- {
- // Allow self-signed certificates in development
- httpClientBuilder.ConfigurePrimaryHttpMessageHandler(() =>
- {
- return new HttpClientHandler
- {
- ClientCertificateOptions = ClientCertificateOption.Manual,
- ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true
- };
- });
- }
-
- }
}
diff --git a/backend/FwLite/LocalWebApp/LocalWebApp.csproj b/backend/FwLite/LocalWebApp/LocalWebApp.csproj
index 6d03a3e54..6b480b782 100644
--- a/backend/FwLite/LocalWebApp/LocalWebApp.csproj
+++ b/backend/FwLite/LocalWebApp/LocalWebApp.csproj
@@ -1,9 +1,6 @@
- net8.0
- enable
- enable
Linux
true
false
@@ -20,10 +17,8 @@
-
-
-
-
+
+
@@ -43,6 +38,7 @@
+
diff --git a/backend/FwLite/LocalWebApp/LocalWebAppServer.cs b/backend/FwLite/LocalWebApp/LocalWebAppServer.cs
index b05a93b34..91f5ba5da 100644
--- a/backend/FwLite/LocalWebApp/LocalWebAppServer.cs
+++ b/backend/FwLite/LocalWebApp/LocalWebAppServer.cs
@@ -1,9 +1,9 @@
using FwDataMiniLcmBridge;
using FwDataMiniLcmBridge.LcmUtils;
+using FwLiteShared.Auth;
using LcmCrdt;
using LocalWebApp;
using LocalWebApp.Hubs;
-using LocalWebApp.Auth;
using LocalWebApp.Routes;
using LocalWebApp.Utils;
using Microsoft.AspNetCore.SignalR;
@@ -89,7 +89,7 @@ public static WebApplication SetupAppServer(WebApplicationOptions options, Actio
var projectName = context.GetProjectName();
if (!string.IsNullOrWhiteSpace(projectName))
{
- var projectsService = context.RequestServices.GetRequiredService();
+ var projectsService = context.RequestServices.GetRequiredService();
projectsService.SetProjectScope(projectsService.GetProject(projectName) ??
throw new InvalidOperationException(
$"Project {projectName} not found"));
diff --git a/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs
index f14f8cb1b..4d00e619e 100644
--- a/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs
+++ b/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs
@@ -25,38 +25,7 @@ public static IEndpointConventionBuilder MapActivities(this WebApplication app)
});
return operation;
});
- group.MapGet("/",
- (ICrdtDbContext dbcontext) =>
- {
- return dbcontext.Commits
- .DefaultOrderDescending()
- .Take(20)
- .Select(c => new Activity(c.Id, c.HybridDateTime.DateTime, c.ChangeEntities))
- .AsAsyncEnumerable();
- });
+ group.MapGet("/", (HistoryService historyService) => historyService.ProjectActivity());
return group;
}
-
- private static string ChangeName(List> changeEntities)
- {
- return changeEntities switch
- {
- { Count: 0 } => "No changes",
- { Count: 1 } => changeEntities[0].Change switch
- {
- //todo call JsonPatchChange.Summarize() instead of this
- IChange change when change.GetType().Name.StartsWith("JsonPatchChange") => "Change " + change.EntityType.Name,
- IChange change => change.GetType().Name.Humanize()
- },
- { Count: var count } => $"{count} changes"
- };
- }
-
- public record Activity(
- Guid CommitId,
- DateTimeOffset Timestamp,
- List> Changes)
- {
- public string ChangeName => ChangeName(Changes);
- }
}
diff --git a/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs b/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs
index 21f521741..a270c3adc 100644
--- a/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs
+++ b/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs
@@ -1,6 +1,6 @@
using System.Security.AccessControl;
using System.Web;
-using LocalWebApp.Auth;
+using FwLiteShared.Auth;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -13,28 +13,21 @@ public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs
public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app)
{
var group = app.MapGroup("/api/auth").WithOpenApi();
- group.MapGet("/servers", (IOptions options, AuthHelpersFactory factory) =>
- {
- return options.Value.LexboxServers.ToAsyncEnumerable().SelectAwait(async s =>
- {
- var currentName = await factory.GetHelper(s).GetCurrentName();
- return new ServerStatus(s.DisplayName,
- !string.IsNullOrEmpty(currentName),
- currentName, s.Authority.Authority);
- });
- });
+ group.MapGet("/servers", (AuthService authService) => authService.Servers());
group.MapGet("/login/{authority}",
- async (AuthHelpersFactory factory, string authority, IOptions options, [FromHeader] string referer) =>
+ async (AuthService authService, string authority, IOptions options, [FromHeader] string referer) =>
{
var returnUrl = new Uri(referer).PathAndQuery;
- var result = await factory.GetHelper(options.Value.GetServerByAuthority(authority)).SignIn(returnUrl);
- if (result.HandledBySystemWebView)
+ //todo blazor, once we're using blazor this endpoint will only be used for non webview logins
+ if (options.Value.SystemWebViewLogin)
{
+ await authService.SignInWebView(options.Value.GetServerByAuthority(authority));
return Results.Redirect(returnUrl);
}
-
- if (result.AuthUri is null) throw new InvalidOperationException("AuthUri is null");
- return Results.Redirect(result.AuthUri.ToString());
+ else
+ {
+ return Results.Redirect(await authService.SignInWebApp(options.Value.GetServerByAuthority(authority), returnUrl));
+ }
});
group.MapGet("/oauth-callback",
async (OAuthService oAuthService, HttpContext context) =>
@@ -49,14 +42,14 @@ public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app)
return Results.Redirect(returnUrl);
}).WithName(CallbackRoute);
group.MapGet("/me/{authority}",
- async (AuthHelpersFactory factory, string authority, IOptions options) =>
+ async (AuthService authService, string authority, IOptions options) =>
{
- return new { name = await factory.GetHelper(options.Value.GetServerByAuthority(authority)).GetCurrentName() };
+ return new { name = await authService.GetLoggedInName(options.Value.GetServerByAuthority(authority)) };
});
group.MapGet("/logout/{authority}",
- async (AuthHelpersFactory factory, string authority, IOptions options) =>
+ async (AuthService authService, string authority, IOptions options) =>
{
- await factory.GetHelper(options.Value.GetServerByAuthority(authority)).Logout();
+ await authService.Logout(options.Value.GetServerByAuthority(authority));
return Results.Redirect("/");
});
return group;
diff --git a/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs b/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs
index 4ea387061..f77ea2d9e 100644
--- a/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs
+++ b/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs
@@ -1,4 +1,5 @@
-using SIL.Harmony;
+using LcmCrdt;
+using SIL.Harmony;
using SIL.Harmony.Changes;
using SIL.Harmony.Core;
using SIL.Harmony.Db;
@@ -7,6 +8,7 @@
using LinqToDB.EntityFrameworkCore;
using LocalWebApp.Hubs;
using Microsoft.OpenApi.Models;
+using MiniLcm.Models;
namespace LocalWebApp.Routes;
@@ -23,63 +25,12 @@ public static IEndpointConventionBuilder MapHistoryRoutes(this WebApplication ap
return operation;
});
group.MapGet("/snapshot/{snapshotId:guid}",
- async (Guid snapshotId, ICrdtDbContext dbcontext) =>
- {
- return await dbcontext.Snapshots.Where(s => s.Id == snapshotId).SingleOrDefaultAsync();
- });
+ async (Guid snapshotId, HistoryService historyService) => await historyService.GetSnapshot(snapshotId));
group.MapGet("/snapshot/at/{timestamp}",
- async (DateTime timestamp, Guid entityId, DataModel dataModel) =>
- {
- //todo requires the timestamp to be exact, otherwise the change made on that timestamp will not be included
- //consider using a commitId and looking up the timestamp, but then we should be exact to the commit which we aren't right now.
- return await dataModel.GetAtTime(new DateTimeOffset(timestamp), entityId);
- });
+ async (DateTime timestamp, Guid entityId, HistoryService historyService) =>
+ await historyService.GetObject(timestamp, entityId));
group.MapGet("/{entityId}",
- (Guid entityId, ICrdtDbContext dbcontext) =>
- {
- var query = from commit in dbcontext.Commits.DefaultOrder()
- from snapshot in dbcontext.Snapshots.LeftJoin(
- s => s.CommitId == commit.Id && s.EntityId == entityId)
- from change in dbcontext.Set>().LeftJoin(c =>
- c.CommitId == commit.Id && c.EntityId == entityId)
- where snapshot.Id != null || change.EntityId != null
- select new HistoryLineItem(commit.Id,
- entityId,
- commit.HybridDateTime.DateTime,
- snapshot.Id,
- change.Change,
- snapshot.Entity,
- snapshot.TypeName);
- return query.ToLinqToDB().AsAsyncEnumerable();
- });
+ (Guid entityId, HistoryService historyService) => historyService.GetHistory(entityId));
return group;
}
-
- public record HistoryLineItem(
- Guid CommitId,
- Guid EntityId,
- DateTimeOffset Timestamp,
- Guid? SnapshotId,
- string? ChangeName,
- IObjectBase? Entity,
- string? EntityName)
- {
- public HistoryLineItem(
- Guid commitId,
- Guid entityId,
- DateTimeOffset timestamp,
- Guid? snapshotId,
- IChange? change,
- IObjectBase? entity,
- string typeName) : this(commitId,
- entityId,
- new DateTimeOffset(timestamp.Ticks,
- TimeSpan.Zero), //todo this is a workaround for linq2db bug where it reads a date and assumes it's local when it's UTC
- snapshotId,
- change?.GetType().Name,
- entity,
- typeName)
- {
- }
- }
}
diff --git a/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs
index 426044b4a..cc057a783 100644
--- a/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs
+++ b/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs
@@ -1,4 +1,5 @@
- using SIL.Harmony.Db;
+ using FwLiteShared.Projects;
+ using SIL.Harmony.Db;
using LocalWebApp.Services;
using Microsoft.OpenApi.Models;
using MiniLcm;
diff --git a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs
index 62316ba1b..dc29e4463 100644
--- a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs
+++ b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs
@@ -1,7 +1,10 @@
using System.Text.RegularExpressions;
using FwDataMiniLcmBridge;
+using FwLiteShared;
+using FwLiteShared.Auth;
+using FwLiteShared.Projects;
+using FwLiteShared.Sync;
using LcmCrdt;
-using LocalWebApp.Auth;
using LocalWebApp.Hubs;
using LocalWebApp.Services;
using Microsoft.AspNetCore.Mvc;
@@ -11,174 +14,54 @@
namespace LocalWebApp.Routes;
-public static partial class ProjectRoutes
+public static class ProjectRoutes
{
public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication app)
{
var group = app.MapGroup("/api").WithOpenApi();
group.MapGet("/remoteProjects",
- async (
- LexboxProjectService lexboxProjectService,
- IOptions options) =>
+ async (CombinedProjectsService combinedProjectsService) =>
{
- var serversProjects = new Dictionary();
- foreach (var server in options.Value.LexboxServers)
- {
- var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server);
- serversProjects.Add(server.Authority.Authority, lexboxProjects.Select(p => new ProjectModel
- (p.Name, Crdt: p.IsCrdtProject, Fwdata: false, Lexbox: true, server.Authority.Authority, p.Id))
- .ToArray());
- }
-
- return serversProjects;
+ return (await combinedProjectsService.RemoteProjects()).ToDictionary(p => p.Server.Authority.Authority, p => p.Projects);
});
group.MapGet("/localProjects",
- async (
- ProjectsService projectService,
- FieldWorksProjectList fieldWorksProjectList) =>
- {
- var crdtProjects = await projectService.ListProjects();
- //todo get project Id and use that to specify the Id in the model. Also pull out server
- var projects = crdtProjects.ToDictionary(p => p.Name, p =>
- {
- var uri = p.Data?.OriginDomain is not null ? new Uri(p.Data.OriginDomain) : null;
- return new ProjectModel(p.Name,
- true,
- false,
- p.Data?.OriginDomain is not null,
- uri?.Authority,
- p.Data?.Id);
- });
- //basically populate projects and indicate if they are lexbox or fwdata
- foreach (var p in fieldWorksProjectList.EnumerateProjects())
- {
- if (projects.TryGetValue(p.Name, out var project))
- {
- projects[p.Name] = project with { Fwdata = true };
- }
- else
- {
- projects.Add(p.Name, new ProjectModel(p.Name, false, true));
- }
- }
- return projects.Values;
- });
+ async (CombinedProjectsService combinedProjectsService) => await combinedProjectsService.LocalProjects());
group.MapPost("/project",
- async (ProjectsService projectService, string name) =>
+ async (CrdtProjectsService projectService, string name) =>
{
if (string.IsNullOrWhiteSpace(name))
return Results.BadRequest("Project name is required");
if (projectService.ProjectExists(name))
return Results.BadRequest("Project already exists");
- if (!ProjectName().IsMatch(name))
+ if (!CrdtProjectsService.ProjectName().IsMatch(name))
return Results.BadRequest("Only letters, numbers, '-' and '_' are allowed");
- await projectService.CreateProject(new(name, AfterCreate: AfterCreate, SeedNewProjectData: true));
+ await projectService.CreateExampleProject(name);
return TypedResults.Ok();
});
group.MapPost($"/upload/crdt/{{serverAuthority}}/{{{CrdtMiniLcmApiHub.ProjectRouteKey}}}",
- async (LexboxProjectService lexboxProjectService,
- SyncService syncService,
+ async (SyncService syncService,
IOptions options,
- CurrentProjectService currentProjectService,
string serverAuthority,
[FromQuery] Guid lexboxProjectId) =>
{
var server = options.Value.GetServerByAuthority(serverAuthority);
- await currentProjectService.SetProjectSyncOrigin(server.Authority, lexboxProjectId);
- try
- {
- await syncService.ExecuteSync();
- }
- catch
- {
- await currentProjectService.SetProjectSyncOrigin(null, null);
- throw;
- }
- lexboxProjectService.InvalidateProjectsCache(server);
+ await syncService.UploadProject(lexboxProjectId, server);
return TypedResults.Ok();
});
group.MapPost("/download/crdt/{serverAuthority}/{projectId}",
- async (LexboxProjectService lexboxProjectService,
- IOptions options,
- ProjectsService projectService,
+ async (IOptions options,
+ CombinedProjectsService combinedProjectsService,
Guid projectId,
[FromQuery] string projectName,
string serverAuthority
) =>
{
- if (!ProjectName().IsMatch(projectName))
+ if (!CrdtProjectsService.ProjectName().IsMatch(projectName))
return Results.BadRequest("Project name is invalid");
var server = options.Value.GetServerByAuthority(serverAuthority);
- await projectService.CreateProject(new(projectName,
- projectId,
- server.Authority,
- async (provider, project) =>
- {
- await provider.GetRequiredService().ExecuteSync();
- },
- SeedNewProjectData: false));
+ await combinedProjectsService.DownloadProject(projectId, projectName, server);
return TypedResults.Ok();
});
return group;
}
-
- public record ProjectModel(string Name, bool Crdt, bool Fwdata, bool Lexbox = false, string? ServerAuthority = null, Guid? Id = null);
-
- private static async Task AfterCreate(IServiceProvider provider, CrdtProject project)
- {
- var lexboxApi = provider.GetRequiredService();
- await lexboxApi.CreateEntry(new()
- {
- Id = Guid.NewGuid(),
- LexemeForm = { Values = { { "en", "Apple" } } },
- CitationForm = { Values = { { "en", "Apple" } } },
- LiteralMeaning = { Values = { { "en", "Fruit" } } },
- Senses =
- [
- new()
- {
- Gloss = { Values = { { "en", "Fruit" } } },
- Definition =
- {
- Values =
- {
- {
- "en",
- "fruit with red, yellow, or green skin with a sweet or tart crispy white flesh"
- }
- }
- },
- SemanticDomains = [],
- ExampleSentences = [new() { Sentence = { Values = { { "en", "We ate an apple" } } } }]
- }
- ]
- });
-
- await lexboxApi.CreateWritingSystem(WritingSystemType.Vernacular,
- new()
- {
- Id = Guid.NewGuid(),
- Type = WritingSystemType.Vernacular,
- WsId = "en",
- Name = "English",
- Abbreviation = "en",
- Font = "Arial",
- Exemplars = WritingSystem.LatinExemplars
- });
-
- await lexboxApi.CreateWritingSystem(WritingSystemType.Analysis,
- new()
- {
- Id = Guid.NewGuid(),
- Type = WritingSystemType.Analysis,
- WsId = "en",
- Name = "English",
- Abbreviation = "en",
- Font = "Arial",
- Exemplars = WritingSystem.LatinExemplars
- });
- }
-
- [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")]
- private static partial Regex ProjectName();
}
diff --git a/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs b/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs
index 91cf0ef3d..965442f43 100644
--- a/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs
+++ b/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs
@@ -1,4 +1,5 @@
-using LocalWebApp.Hubs;
+using FwLiteShared;
+using LocalWebApp.Hubs;
using LocalWebApp.Services;
using Microsoft.OpenApi.Models;
using MiniLcm;
diff --git a/backend/FwLite/LocalWebApp/Services/ServerRedirectUrlProvider.cs b/backend/FwLite/LocalWebApp/Services/ServerRedirectUrlProvider.cs
new file mode 100644
index 000000000..531c84c59
--- /dev/null
+++ b/backend/FwLite/LocalWebApp/Services/ServerRedirectUrlProvider.cs
@@ -0,0 +1,29 @@
+using FwLiteShared.Auth;
+using LocalWebApp.Routes;
+
+namespace LocalWebApp.Services;
+
+public class ServerRedirectUrlProvider(LinkGenerator linkGenerator, UrlContext urlContext): IRedirectUrlProvider
+{
+ public string? GetRedirectUrl()
+ {
+ var (hostUrl, _) = urlContext.GetUrl();
+ return RedirectUrlFromHost(hostUrl);
+ }
+
+ private string? RedirectUrlFromHost(Uri hostUrl)
+ {
+ var redirectHost = HostString.FromUriComponent(hostUrl);
+ return linkGenerator.GetUriByRouteValues(AuthRoutes.CallbackRoute,
+ new RouteValueDictionary(),
+ hostUrl.Scheme,
+ redirectHost);
+ }
+
+ public bool ShouldRecreateAuthHelper(string? redirectUrl)
+ {
+ var (hostUrl, guess) = urlContext.GetUrl();
+ if (guess) return false;
+ return RedirectUrlFromHost(hostUrl) != redirectUrl;
+ }
+}
diff --git a/backend/FwLite/LocalWebApp/UrlContext.cs b/backend/FwLite/LocalWebApp/UrlContext.cs
index ec261a555..bafefe88f 100644
--- a/backend/FwLite/LocalWebApp/UrlContext.cs
+++ b/backend/FwLite/LocalWebApp/UrlContext.cs
@@ -18,7 +18,7 @@ public class UrlContext(IServer server, IHttpContextAccessor contextAccessor)
var uriBuilder = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host.Replace("127.0.0.1", "localhost"), httpContext.Request.Host.Port ?? 80);
return (uriBuilder.Uri, false);
}
- var address = server.Features.Get()?.Addresses.FirstOrDefault() ?? throw new InvalidOperationException("No server address");
+ var address = server.Features.Get()?.Addresses.FirstOrDefault(a => a.StartsWith("http:")) ?? throw new InvalidOperationException("No server address");
if (address.StartsWith("http://127.0.0.1")) address = address.Replace("http://127.0.0.1", "http://localhost");
if (address.StartsWith("https://127.0.0.1")) address = address.Replace("https://127.0.0.1", "http://localhost");
return (new Uri(address), true);
diff --git a/backend/FwLite/MiniLcm.Tests/BasicApiTestsBase.cs b/backend/FwLite/MiniLcm.Tests/BasicApiTestsBase.cs
index da63a7f4d..9b1f05144 100644
--- a/backend/FwLite/MiniLcm.Tests/BasicApiTestsBase.cs
+++ b/backend/FwLite/MiniLcm.Tests/BasicApiTestsBase.cs
@@ -202,7 +202,7 @@ public async Task GetEntry()
{
var entry = await Api.GetEntry(Entry1Id);
entry.Should().NotBeNull();
- entry!.LexemeForm.Values.Should().NotBeEmpty();
+ entry.LexemeForm.Values.Should().NotBeEmpty();
var sense = entry.Senses.Should()
.NotBeEmpty($"because '{entry.LexemeForm.Values.First().Value}' should have a sense").And.Subject.First();
sense.Gloss.Values.Should().NotBeEmpty();
diff --git a/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs b/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs
index 4f51ca275..d6f646435 100644
--- a/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs
+++ b/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs
@@ -50,6 +50,22 @@ public async Task CreateComplexFormComponent_Works()
component.ComponentHeadword.Should().Be("component");
}
+ [Fact]
+ public async Task CreateComplexFormComponent_UsingTheSameComponentWithNullSenseDoesNothing()
+ {
+ var component1 = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry));
+ var component2 = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry));
+ component2.Should().BeEquivalentTo(component1);
+ }
+
+ [Fact]
+ public async Task CreateComplexFormComponent_UsingTheSameComponentWithSenseDoesNothing()
+ {
+ var component1 = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry, _componentSenseId1));
+ var component2 = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry, _componentSenseId1));
+ component2.Should().BeEquivalentTo(component1);
+ }
+
[Fact]
public async Task CreateComplexFormComponent_WorksWithSense()
{
@@ -83,4 +99,43 @@ public async Task CreateComplexFormType_Works()
var types = await Api.GetComplexFormTypes().ToArrayAsync();
types.Should().ContainSingle(t => t.Id == complexFormType.Id);
}
+
+ [Fact]
+ public async Task AddComplexFormType_Works()
+ {
+ var complexFormType = new ComplexFormType() { Id = Guid.NewGuid(), Name = new() { { "en", "test" } } };
+ await Api.CreateComplexFormType(complexFormType);
+ await Api.AddComplexFormType(_complexFormEntryId, complexFormType.Id);
+ var entry = await Api.GetEntry(_complexFormEntryId);
+ entry.Should().NotBeNull();
+ entry!.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id);
+ }
+
+ [Fact]
+ public async Task RemoveComplexFormType_Works()
+ {
+ await AddComplexFormType_Works();
+ var entry = await Api.GetEntry(_complexFormEntryId);
+ await Api.RemoveComplexFormType(_complexFormEntryId, entry!.ComplexFormTypes[0].Id);
+ entry = await Api.GetEntry(_complexFormEntryId);
+ entry.Should().NotBeNull();
+ entry!.ComplexFormTypes.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task RemoveComplexFormType_WorksWhenTypeDoesNotExist()
+ {
+ await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry));
+ await Api.RemoveComplexFormType(_complexFormEntryId, Guid.NewGuid());
+ }
+
+ [Fact]
+ public async Task RemoveComplexFormType_WorksWhenTypeIsNotOnEntry()
+ {
+ //FW projects react differently if an entry has complex forms or not
+ await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry));
+ var complexFormType = new ComplexFormType() { Id = Guid.NewGuid(), Name = new() { { "en", "test" } } };
+ await Api.CreateComplexFormType(complexFormType);
+ await Api.RemoveComplexFormType(_complexFormEntryId, Guid.NewGuid());
+ }
}
diff --git a/backend/FwLite/MiniLcm.Tests/CreateEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/CreateEntryTestsBase.cs
index 477362c03..37fef2f21 100644
--- a/backend/FwLite/MiniLcm.Tests/CreateEntryTestsBase.cs
+++ b/backend/FwLite/MiniLcm.Tests/CreateEntryTestsBase.cs
@@ -9,7 +9,7 @@ public async Task CanCreateEntry()
{
var entry = await Api.CreateEntry(new() { LexemeForm = { { "en", "test" } } });
entry.Should().NotBeNull();
- entry!.LexemeForm.Values.Should().ContainKey("en");
+ entry.LexemeForm.Values.Should().ContainKey("en");
entry.LexemeForm.Values["en"].Should().Be("test");
}
@@ -53,7 +53,7 @@ public async Task CanCreate_WithComponentsProperty()
});
entry = await Api.GetEntry(entry.Id);
entry.Should().NotBeNull();
- entry!.Components.Should().ContainSingle(c => c.ComponentEntryId == component.Id);
+ entry.Components.Should().ContainSingle(c => c.ComponentEntryId == component.Id);
}
[Fact]
@@ -78,7 +78,7 @@ public async Task CanCreate_WithComplexFormsProperty()
});
entry = await Api.GetEntry(entry.Id);
entry.Should().NotBeNull();
- entry!.ComplexForms.Should().ContainSingle(c => c.ComplexFormEntryId == complexForm.Id);
+ entry.ComplexForms.Should().ContainSingle(c => c.ComplexFormEntryId == complexForm.Id);
}
[Fact]
@@ -110,11 +110,11 @@ await Api.CreateEntry(new()
var entry = await Api.GetEntry(component.Id);
entry.Should().NotBeNull();
- entry!.ComplexForms.Should().ContainSingle().Which.ComponentSenseId.Should().Be(componentSenseId);
+ entry.ComplexForms.Should().ContainSingle().Which.ComponentSenseId.Should().Be(componentSenseId);
entry = await Api.GetEntry(complexFormEntryId);
entry.Should().NotBeNull();
- entry!.Components.Should().ContainSingle(c =>
+ entry.Components.Should().ContainSingle(c =>
c.ComplexFormEntryId == complexFormEntryId && c.ComponentEntryId == component.Id &&
c.ComponentSenseId == componentSenseId);
}
@@ -133,6 +133,6 @@ public async Task CanCreate_WithComplexFormTypesProperty()
});
entry = await Api.GetEntry(entry.Id);
entry.Should().NotBeNull();
- entry!.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id);
+ entry.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id);
}
}
diff --git a/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj b/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj
index 83030857f..1e5de3a28 100644
--- a/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj
+++ b/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj
@@ -1,10 +1,6 @@
- net8.0
- enable
- enable
-
false
true
@@ -12,14 +8,17 @@
-
-
-
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/backend/FwLite/MiniLcm.Tests/Models/EntryTests.cs b/backend/FwLite/MiniLcm.Tests/Models/EntryTests.cs
new file mode 100644
index 000000000..8b680e716
--- /dev/null
+++ b/backend/FwLite/MiniLcm.Tests/Models/EntryTests.cs
@@ -0,0 +1,18 @@
+namespace MiniLcm.Tests.Models;
+
+public class EntryTests
+{
+ [Fact]
+ public void Headword_SameResultForDifferentOrderedMultiStrings()
+ {
+ var entry = new Entry()
+ {
+ LexemeForm = new MultiString() { Values = { { "en", "test" }, { "fr", "test2" } } }
+ };
+ var entry2 = new Entry()
+ {
+ LexemeForm = new MultiString() { Values = { { "fr", "test2" }, { "en", "test" } } }
+ };
+ entry.Headword().Should().Be(entry2.Headword());
+ }
+}
diff --git a/backend/FwLite/MiniLcm.Tests/SemanticDomainTestsBase.cs b/backend/FwLite/MiniLcm.Tests/SemanticDomainTestsBase.cs
index 6704a50f0..38b6a5f63 100644
--- a/backend/FwLite/MiniLcm.Tests/SemanticDomainTestsBase.cs
+++ b/backend/FwLite/MiniLcm.Tests/SemanticDomainTestsBase.cs
@@ -31,11 +31,11 @@ await Api.CreateEntry(new Entry()
});
}
- private Task GetEntry()
+ private async Task GetEntry()
{
- var entry = Api.GetEntry(_entryId);
+ var entry = await Api.GetEntry(_entryId);
entry.Should().NotBeNull();
- return entry!;
+ return entry;
}
[Fact]
@@ -55,7 +55,7 @@ public async Task Sense_HasSemanticDomains()
{
var entry = await GetEntry();
entry.Should().NotBeNull();
- var sense = entry!.Senses.First(s => s.SemanticDomains.Any());
+ var sense = entry.Senses.First(s => s.SemanticDomains.Any());
sense.SemanticDomains.Should().NotBeEmpty();
sense.SemanticDomains.Should().AllSatisfy(sd =>
{
diff --git a/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs
new file mode 100644
index 000000000..be58f4bee
--- /dev/null
+++ b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs
@@ -0,0 +1,62 @@
+namespace MiniLcm.Tests;
+
+public abstract class SortingTestsBase : MiniLcmTestBase
+{
+ public override async Task InitializeAsync()
+ {
+ await base.InitializeAsync();
+ await Api.CreateWritingSystem(WritingSystemType.Analysis,
+ new WritingSystem()
+ {
+ Id = Guid.NewGuid(),
+ Type = WritingSystemType.Analysis,
+ WsId = "en",
+ Name = "English",
+ Abbreviation = "En",
+ Font = "Arial",
+ Exemplars = []
+ });
+ await Api.CreateWritingSystem(WritingSystemType.Vernacular,
+ new WritingSystem()
+ {
+ Id = Guid.NewGuid(),
+ Type = WritingSystemType.Vernacular,
+ WsId = "en-US",
+ Name = "English",
+ Abbreviation = "En",
+ Font = "Arial",
+ Exemplars = []
+ });
+ }
+
+ private Task CreateEntry(string headword)
+ {
+ return Api.CreateEntry(new() { LexemeForm = { { "en", headword } }, });
+ }
+
+
+ // ReSharper disable InconsistentNaming
+ const string Ru_A= "\u0410";
+ const string Ru_a = "\u0430";
+ const string Ru_Б= "\u0411";
+ const string Ru_б = "\u0431";
+ const string Ru_В= "\u0412";
+ const string Ru_в = "\u0432";
+ // ReSharper restore InconsistentNaming
+
+ [Theory]
+ [InlineData("aa,ab,ac")]
+ [InlineData("aa,Ab,ac")]
+ [InlineData($"{Ru_a}{Ru_a},{Ru_a}{Ru_б},{Ru_a}{Ru_в}")]
+ [InlineData($"{Ru_a}{Ru_a},{Ru_A}{Ru_б},{Ru_a}{Ru_в}")]
+ public async Task EntriesAreSorted(string headwords)
+ {
+ var headwordList = headwords.Split(',');
+ foreach (var headword in headwordList.OrderBy(h => Random.Shared.Next()))
+ {
+ await CreateEntry(headword);
+ }
+ var entries = await Api.GetEntries().Select(e => e.Headword()).ToArrayAsync();
+ entries.Should().Equal(headwordList);
+ }
+}
diff --git a/backend/FwLite/MiniLcm.Tests/Validators/ComplexFormTypeValidationTests.cs b/backend/FwLite/MiniLcm.Tests/Validators/ComplexFormTypeValidationTests.cs
new file mode 100644
index 000000000..8b6005add
--- /dev/null
+++ b/backend/FwLite/MiniLcm.Tests/Validators/ComplexFormTypeValidationTests.cs
@@ -0,0 +1,43 @@
+using FluentValidation.TestHelper;
+using MiniLcm.Validators;
+
+namespace MiniLcm.Tests.Validators;
+
+public class ComplexFormTypeValidationTests
+{
+ private readonly ComplexFormTypeValidator _validator = new();
+
+ [Fact]
+ public void FailsForEmptyName()
+ {
+ var complexFormType = new ComplexFormType() { Name = new MultiString() };
+ _validator.TestValidate(complexFormType).ShouldHaveValidationErrorFor(c => c.Name);
+ }
+ [Fact]
+ public void FailsForNameWithEmptyStringValue()
+ {
+ var complexFormType = new ComplexFormType() { Name = new(){ { "en", string.Empty } } };
+ _validator.TestValidate(complexFormType).ShouldHaveValidationErrorFor(c => c.Name);
+ }
+
+ [Fact]
+ public void FailsForNonNullDeletedAt()
+ {
+ var complexFormType = new ComplexFormType()
+ {
+ Name = new() { { "en", "test" } }, DeletedAt = DateTimeOffset.UtcNow
+ };
+ _validator.TestValidate(complexFormType).ShouldHaveValidationErrorFor(c => c.DeletedAt);
+ }
+
+ [Fact]
+ public void Succeeds()
+ {
+ var complexFormType = new ComplexFormType()
+ {
+ Name = new() { { "en", "test" } },
+ DeletedAt = null
+ };
+ _validator.TestValidate(complexFormType).ShouldNotHaveAnyValidationErrors();
+ }
+}
diff --git a/backend/FwLite/MiniLcm.Tests/Validators/EntryValidatorTests.cs b/backend/FwLite/MiniLcm.Tests/Validators/EntryValidatorTests.cs
new file mode 100644
index 000000000..3a92fc6b9
--- /dev/null
+++ b/backend/FwLite/MiniLcm.Tests/Validators/EntryValidatorTests.cs
@@ -0,0 +1,34 @@
+using FluentValidation.TestHelper;
+using MiniLcm.Validators;
+
+namespace MiniLcm.Tests.Validators;
+
+public class EntryValidatorTests
+{
+ private readonly EntryValidator _validator = new();
+
+ [Fact]
+ public void Succeeds_WhenSenseEntryIdIsGuidEmpty()
+ {
+ var entryId = Guid.NewGuid();
+ var entry = new Entry() { Id = entryId, Senses = [new Sense() { EntryId = Guid.Empty, }] };
+ _validator.TestValidate(entry).ShouldNotHaveAnyValidationErrors();
+ }
+
+ [Fact]
+ public void Succeeds_WhenSenseEntryIdMatchesEntry()
+ {
+
+ var entryId = Guid.NewGuid();
+ var entry = new Entry() { Id = entryId, Senses = [new Sense() { EntryId = entryId, }] };
+ _validator.TestValidate(entry).ShouldNotHaveAnyValidationErrors();
+ }
+
+ [Fact]
+ public void Fails_WhenSenseEntryIdDoesNotMatchEntry()
+ {
+ var entryId = Guid.NewGuid();
+ var entry = new Entry() { Id = entryId, Senses = [new Sense() { EntryId = Guid.NewGuid(), }] };
+ _validator.TestValidate(entry).ShouldHaveValidationErrorFor("Senses[0].EntryId");
+ }
+}
diff --git a/backend/FwLite/MiniLcm.Tests/WritingSystemIdTests.cs b/backend/FwLite/MiniLcm.Tests/WritingSystemIdTests.cs
index 6448f77b0..14290cc07 100644
--- a/backend/FwLite/MiniLcm.Tests/WritingSystemIdTests.cs
+++ b/backend/FwLite/MiniLcm.Tests/WritingSystemIdTests.cs
@@ -1,6 +1,4 @@
-using MiniLcm.Models;
-
-namespace MiniLcm.Tests;
+namespace MiniLcm.Tests;
public class WritingSystemIdTests
{
@@ -11,7 +9,7 @@ public class WritingSystemIdTests
public void ValidWritingSystemId_ShouldNotThrow(string code)
{
var ws = new WritingSystemId(code);
- ws.Should().NotBeNull();
+ ws.Should().NotBe(default);
}
[Theory]
@@ -29,6 +27,6 @@ public void InvalidWritingSystemId_ShouldThrow(string code)
public void DefaultWritingSystemId_IsValid()
{
var ws = new WritingSystemId("default");
- ws.Should().NotBeNull();
+ ws.Should().NotBe(default);
}
}
diff --git a/backend/FwLite/MiniLcm/Exceptions/CreateObjectException.cs b/backend/FwLite/MiniLcm/Exceptions/CreateObjectException.cs
new file mode 100644
index 000000000..128cb0515
--- /dev/null
+++ b/backend/FwLite/MiniLcm/Exceptions/CreateObjectException.cs
@@ -0,0 +1,12 @@
+namespace MiniLcm.Exceptions;
+
+public class CreateObjectException: Exception
+{
+ public CreateObjectException(string? message) : base(message)
+ {
+ }
+
+ public CreateObjectException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/backend/FwLite/MiniLcm/Exceptions/SyncObjectException.cs b/backend/FwLite/MiniLcm/Exceptions/SyncObjectException.cs
new file mode 100644
index 000000000..e1f284cde
--- /dev/null
+++ b/backend/FwLite/MiniLcm/Exceptions/SyncObjectException.cs
@@ -0,0 +1,12 @@
+namespace MiniLcm.Exceptions;
+
+public class SyncObjectException: Exception
+{
+ public SyncObjectException(string? message) : base(message)
+ {
+ }
+
+ public SyncObjectException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs
index 40f4914e7..c54795bb8 100644
--- a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs
+++ b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs
@@ -15,6 +15,7 @@ public interface IMiniLcmReadApi
Task GetSense(Guid entryId, Guid id);
Task GetPartOfSpeech(Guid id);
Task GetSemanticDomain(Guid id);
+ Task GetExampleSentence(Guid entryId, Guid senseId, Guid id);
}
public record QueryOptions(
diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs
index e039cbe90..a279e565d 100644
--- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs
+++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs
@@ -11,17 +11,20 @@ public interface IMiniLcmWriteApi
Task