diff --git a/.github/workflows/develop-api.yaml b/.github/workflows/develop-api.yaml
index 9a2d3ed01..8851e1a9b 100644
--- a/.github/workflows/develop-api.yaml
+++ b/.github/workflows/develop-api.yaml
@@ -3,7 +3,13 @@ on:
workflow_dispatch:
push:
paths:
- - 'backend/**'
+ - 'backend/LexBoxApi/**'
+ - 'backend/LexCore/**'
+ - 'backend/LexData/**'
+ - 'backend/LfClassicData/**'
+ - 'backend/MiniLcm/**'
+ - 'backend/SyncReverserProxy/**'
+ - 'backend/Testing/**'
- '.github/workflows/lexbox-api.yaml'
- '.github/workflows/deploy.yaml'
- 'deployment/lexbox-deployment.yaml'
@@ -11,7 +17,13 @@ on:
- develop
pull_request:
paths:
- - 'backend/**'
+ - 'backend/LexBoxApi/**'
+ - 'backend/LexCore/**'
+ - 'backend/LexData/**'
+ - 'backend/LfClassicData/**'
+ - 'backend/MiniLcm/**'
+ - 'backend/SyncReverserProxy/**'
+ - 'backend/Testing/**'
- '.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
new file mode 100644
index 000000000..70cc22320
--- /dev/null
+++ b/.github/workflows/fw-lite.yaml
@@ -0,0 +1,77 @@
+name: FieldWorks Lite
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - 'backend/FwDataMiniLcmBridge/**'
+ - 'backend/harmony/**'
+ - 'backend/LcmCrdt/**'
+ - 'backend/LocalWebApp/**'
+ - 'backend/MiniLcm/**'
+ - 'frontend/viewer/**'
+ - '.github/workflows/fw-lite.yaml'
+ branches:
+ - develop
+ pull_request:
+ paths:
+ - 'backend/FwDataMiniLcmBridge/**'
+ - 'backend/harmony/**'
+ - 'backend/LcmCrdt/**'
+ - 'backend/LocalWebApp/**'
+ - 'backend/MiniLcm/**'
+ - 'frontend/viewer/**'
+ - '.github/workflows/fw-lite.yaml'
+ branches:
+ - develop
+
+jobs:
+ build-app:
+ name: Build FW Lite
+ timeout-minutes: 20
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.x'
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: './frontend/package.json'
+ - name: Dotnet build
+ working-directory: backend/LocalWebApp
+ run: dotnet build --configuration Release
+ - name: Dotnet test
+ working-directory: backend/LcmCrdt.Tests
+ run: dotnet test --configuration Release --logger:"xunit;LogFileName={assembly}.results.xml" --results-directory ./test-results
+ - name: Publish test results
+ uses: EnricoMi/publish-unit-test-result-action@8885e273a4343cd7b48eaa72428dea0c3067ea98 # v2.14.0
+ if: always()
+ with:
+ check_name: FW Lite Unit Tests
+ files: ./backend/LcmCrdt.Tests/test-results/*.xml
+ - name: Build viewer
+ working-directory: frontend/viewer
+ run: |
+ corepack enable
+ pnpm install
+ pnpm run build-app
+
+ - name: Publish linux
+ working-directory: backend/LocalWebApp
+ run: dotnet publish -r linux-x64 -o ./publish/linux
+ - name: Publish osx
+ working-directory: backend/LocalWebApp
+ run: dotnet publish -r osx-x64 -o ./publish/osx
+ - name: Publish win
+ working-directory: backend/LocalWebApp
+ run: dotnet publish -r win-x64 -o ./publish/win
+ - name: Upload artifacts
+ # uploading in one artifact as there's a lot of duplication between builds so compression goes far
+ uses: actions/upload-artifact@v4
+ with:
+ name: fw-lite
+ if-no-files-found: error
+ path: backend/LocalWebApp/publish/*
diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml
index 990c008b0..cf8afe3d9 100644
--- a/.github/workflows/integration-test.yaml
+++ b/.github/workflows/integration-test.yaml
@@ -12,10 +12,15 @@ on:
required: true
type: string
hg-version:
- description: 'The version of hg to test (3 or 6, default 3)'
+ description: 'The version of hg to test (3 or 6, default 6)'
required: false
- default: '3'
+ default: '6'
type: string
+ run-dotnet:
+ description: 'Whether to run dotnet tests'
+ required: false
+ default: true
+ type: boolean
run-playwright:
description: 'Whether to run Playwright tests'
required: false
@@ -32,20 +37,31 @@ on:
required: true
type: string
hg-version:
- description: 'The version of hg to test (3 or 6, default 3)'
+ description: 'The version of hg to test (3 or 6, default 6)'
required: false
- default: '3'
+ default: '6'
type: string
+ run-dotnet:
+ description: 'Whether to run dotnet tests'
+ required: false
+ default: true
+ type: boolean
run-playwright:
description: 'Whether to run Playwright tests'
required: false
default: true
type: boolean
+# TODO: Set up caching for /home/runner/.cache/ms-playwright
+# TODO: Ditto for /home/runner/.pnpm-store/v3
+# TODO: Ditto for /home/runner/.nuget/packages
+# TODO: Ditto for /var/cache/apt/archives/
+
jobs:
- test:
+ dotnet-test:
+ if: ${{ inputs.run-dotnet }}
timeout-minutes: 120
- name: Test ${{ inputs.runs-on }} for Mercurial ${{ inputs.hg-version }} on ${{ inputs.environment }}
+ name: Dotnet tests on ${{ inputs.runs-on }} for Mercurial ${{ inputs.hg-version }} on ${{ inputs.environment }}
permissions:
checks: write
environment:
@@ -68,23 +84,6 @@ jobs:
sudo apt-get install -f
rm powershell_7.4.1-1.deb_amd64.deb
pwsh #validate that powershell installed correctly
- # First we need to setup Node...
- - uses: actions/setup-node@v4
- if: ${{ inputs.run-playwright }}
- with:
- node-version-file: 'frontend/package.json'
- # Then we can set up pnpm...
- - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
- if: ${{ inputs.run-playwright }}
- with:
- package_json_file: 'frontend/package.json'
- # Then we can have Noede set up package caching
- - uses: actions/setup-node@v4
- if: ${{ inputs.run-playwright }}
- with:
- node-version-file: 'frontend/package.json'
- cache: 'pnpm'
- cache-dependency-path: 'frontend/pnpm-lock.yaml'
- uses: actions/setup-dotnet@v4
env:
DOTNET_INSTALL_DIR: ${{ inputs.runs-on == 'self-hosted' && '/opt/hostedtoolcache/dotnet' || '' }} #poor man's conditional
@@ -104,25 +103,12 @@ jobs:
- name: Dotnet build
# limit cpu count for self-hosted due to memory constraints
run: dotnet build ${{ inputs.runs-on == 'self-hosted' && '-maxcpucount:2' || '' }}
- # TODO: Set up caching for /home/runner/.cache/ms-playwright so this doesn't take as long
- # TODO: Ditto for /home/runner/.pnpm-store/v3
- # TODO: Ditto for /home/runner/.nuget/packages
- # TODO: Ditto for /var/cache/apt/archives/
- - name: Playwright setup
- working-directory: frontend
- if: ${{ inputs.run-playwright }}
- run: pnpm install && pnpm pretest
- - name: Set up Playwright dependencies
- working-directory: frontend
- if: ${{ inputs.run-playwright && inputs.runs-on == 'self-hosted' }}
- run: sudo pnpm exec playwright install-deps
- name: Build for tests
run: "dotnet build /p:MercurialVersion=$MERCURIAL_VERSION"
env:
MERCURIAL_VERSION: ${{ inputs.hg-version }}
- - name: Integration tests (Playwright)
- if: ${{ inputs.run-playwright }}
+ - name: Integration tests (.NET)
env:
TEST_SERVER_HOSTNAME: ${{ vars.TEST_SERVER_HOSTNAME }}
# this is not a typo, we need to use the lf domain because it has a cert that hg will validate
@@ -130,11 +116,70 @@ jobs:
TEST_RESUMABLE_HG_HOSTNAME: ${{ vars.TEST_RESUMABLE_HG_HOSTNAME }}
TEST_PROJECT_CODE: 'sena-3'
TEST_DEFAULT_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- working-directory: frontend
- run: pnpm test
+ # 1.5gb max heap size, must be in hex https://learn.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector#heap-limit
+ # the self hosted runner only has 2gb so this should prevent it from running out of memory
+ DOTNET_GCHeapHardLimit: '0x2CB417800'
- - name: Integration tests (.NET)
+ run: dotnet test --output ./bin --logger trx --results-directory ./test-results --filter Category=Integration
+ - name: Publish unit test results
+ uses: EnricoMi/publish-unit-test-result-action/composite@8885e273a4343cd7b48eaa72428dea0c3067ea98 # v2.14.0
+ if: ${{ always() && !env.act }}
+ with:
+ check_name: Integration Tests ${{ inputs.runs-on }} for Mercurial ${{ inputs.hg-version }}
+ files: ./test-results/*.trx
+ - name: Publish results to testspace
if: always()
+ run: testspace "[.Net Integration/${{ inputs.runs-on }} HG ${{ inputs.hg-version }}]./test-results/*.trx"
+
+ playwright-test:
+ if: ${{ inputs.run-playwright }}
+ timeout-minutes: 120
+ name: Playwright tests on ${{ inputs.runs-on }} for Mercurial ${{ inputs.hg-version }} on ${{ inputs.environment }}
+ permissions:
+ checks: write
+ environment:
+ name: ${{ inputs.environment }}
+ runs-on: ${{ inputs.runs-on }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+ - uses: testspace-com/setup-testspace@v1.0.6
+ with:
+ domain: ${{ github.repository_owner }}
+ - name: Setup self-hosted dependencies
+ if: ${{ inputs.runs-on == 'self-hosted' }}
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y p7zip-full iputils-ping python3-venv wget zlib1g-dev libssl-dev #needed by setup python
+ wget -q https://github.com/PowerShell/PowerShell/releases/download/v7.4.1/powershell_7.4.1-1.deb_amd64.deb
+ sudo dpkg -i powershell_7.4.1-1.deb_amd64.deb
+ sudo apt-get install -f
+ rm powershell_7.4.1-1.deb_amd64.deb
+ pwsh #validate that powershell installed correctly
+ # First we need to setup Node...
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: 'frontend/package.json'
+ # Then we can set up pnpm...
+ - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
+ with:
+ package_json_file: 'frontend/package.json'
+ # Then we can have Node set up package caching
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: 'frontend/package.json'
+ cache: 'pnpm'
+ cache-dependency-path: 'frontend/pnpm-lock.yaml'
+ - name: Playwright setup
+ working-directory: frontend
+ run: pnpm install && pnpm pretest
+ - name: Set up Playwright dependencies
+ working-directory: frontend
+ if: ${{ inputs.runs-on == 'self-hosted' }}
+ run: sudo pnpm exec playwright install-deps
+
+ - name: Integration tests (Playwright)
env:
TEST_SERVER_HOSTNAME: ${{ vars.TEST_SERVER_HOSTNAME }}
# this is not a typo, we need to use the lf domain because it has a cert that hg will validate
@@ -142,28 +187,18 @@ jobs:
TEST_RESUMABLE_HG_HOSTNAME: ${{ vars.TEST_RESUMABLE_HG_HOSTNAME }}
TEST_PROJECT_CODE: 'sena-3'
TEST_DEFAULT_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- # 1.5gb max heap size, must be in hex https://learn.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector#heap-limit
- # the self hosted runner only has 2gb so this should prevent it from running out of memory
- DOTNET_GCHeapHardLimit: '0x2CB417800'
-
- run: dotnet test --output ./bin --logger trx --results-directory ./test-results --filter Category=Integration
-
+ working-directory: frontend
+ run: pnpm test
- name: Password protect Playwright traces
id: password_protect_test_results
- if: ${{ always() && inputs.run-playwright }}
+ if: ${{ always() }}
shell: bash
env:
ZIP_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
run: 7z a ./playwright-traces.7z -mx=0 -mmt=off ./frontend/test-results -p"$ZIP_PASSWORD"
- - name: Publish unit test results
- uses: EnricoMi/publish-unit-test-result-action/composite@8885e273a4343cd7b48eaa72428dea0c3067ea98 # v2.14.0
- if: ${{ always() && !env.act }}
- with:
- check_name: Integration Tests ${{ inputs.runs-on }} for Mercurial ${{ inputs.hg-version }}
- files: ./test-results/*.trx
- name: Publish results to testspace
if: always()
- run: testspace "[.Net Integration]./test-results/*.trx" "[Playwright]./frontend/test-results/*.xml"
+ run: testspace "[Playwright]./frontend/test-results/*.xml"
- name: Upload playwright results
if: ${{ always() && steps.password_protect_test_results.outcome == 'success' }}
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/lexbox-api.yaml b/.github/workflows/lexbox-api.yaml
index aecbedc84..372ab865e 100644
--- a/.github/workflows/lexbox-api.yaml
+++ b/.github/workflows/lexbox-api.yaml
@@ -53,13 +53,14 @@ jobs:
- name: Dotnet build
run: dotnet build
- name: Unit tests
+ working-directory: backend/Testing
run: dotnet test --logger:"xunit;LogFileName={assembly}.results.xml" --results-directory ./test-results --filter "Category!=Integration" --blame-hang-timeout 10m
- name: Publish unit test results
uses: EnricoMi/publish-unit-test-result-action@8885e273a4343cd7b48eaa72428dea0c3067ea98 # v2.14.0
if: always()
with:
check_name: C# Unit Tests
- files: ./test-results/*.xml
+ files: ./backend/Testing/test-results/*.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
diff --git a/.gitignore b/.gitignore
index 2bea0cd70..32010732f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.DS_Store
bin/
obj/
+/data/
/packages/
riderModule.iml
/_ReSharper.Caches/
@@ -17,3 +18,4 @@ dump.sql
test-results/
**/*.sqlite
**/*.sqlite-*
+msal.cache
diff --git a/.gitmodules b/.gitmodules
index ff0f22e8f..59dd7daf6 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,4 +1,4 @@
[submodule "backend/harmony"]
path = backend/harmony
url = https://github.com/hahn-kev/harmony.git
- branch = add-crdt
+ branch = chore/performance-pass
diff --git a/.idea/.idea.LexBox/.idea/dataSources.xml b/.idea/.idea.LexBox/.idea/dataSources.xml
index b6ad8028d..5661a55a2 100644
--- a/.idea/.idea.LexBox/.idea/dataSources.xml
+++ b/.idea/.idea.LexBox/.idea/dataSources.xml
@@ -86,5 +86,19 @@
+
+ postgresql
+ true
+ false
+ org.postgresql.Driver
+ jdbc:postgresql://localhost:5436/lexbox
+
+
+
+
+
+
+ $ProjectFileDir$
+
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index cfb75b038..50341537b 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -29,5 +29,6 @@
},
"files.associations": {
"**/frontend/**/i18n/**/*.json": "json5", // Supports json5 syntax: https://svelte-intl-precompile.com/en/docs/getting-started
- }
+ },
+ "dotnet.defaultSolution": "LexBox.sln"
}
diff --git a/LexBox.sln b/LexBox.sln
index e27567273..6ec82a3cc 100644
--- a/LexBox.sln
+++ b/LexBox.sln
@@ -37,6 +37,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crdt", "backend\harmony\src
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crdt.Core", "backend\harmony\src\Crdt.Core\Crdt.Core.csproj", "{8B54FFB5-0BDF-403E-83CC-A3B3861EC507}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwDataMiniLcmBridge", "backend\FwDataMiniLcmBridge\FwDataMiniLcmBridge.csproj", "{279197B6-EC06-4DE0-94F8-625379C3AD83}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -95,6 +97,10 @@ Global
{8B54FFB5-0BDF-403E-83CC-A3B3861EC507}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8B54FFB5-0BDF-403E-83CC-A3B3861EC507}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8B54FFB5-0BDF-403E-83CC-A3B3861EC507}.Release|Any CPU.Build.0 = Release|Any CPU
+ {279197B6-EC06-4DE0-94F8-625379C3AD83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {279197B6-EC06-4DE0-94F8-625379C3AD83}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {279197B6-EC06-4DE0-94F8-625379C3AD83}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {279197B6-EC06-4DE0-94F8-625379C3AD83}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{E8BB768B-C3DC-4BE6-9B9F-82319E05AF86} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
@@ -104,5 +110,6 @@ Global
{7D874D9B-1CA9-49E9-8B03-91B5C324E938} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
{740C8FF5-8006-4047-8C52-53873C2DD7C4} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
{8B54FFB5-0BDF-403E-83CC-A3B3861EC507} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
+ {279197B6-EC06-4DE0-94F8-625379C3AD83} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
EndGlobalSection
EndGlobal
diff --git a/Taskfile.yml b/Taskfile.yml
index 42c02a7a6..63ac89079 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -5,6 +5,7 @@ version: '3'
vars:
TESTING_DIR: './backend/Testing'
HG_REPO_DIR: './hgweb/repos'
+ DATA_DIR: './data'
includes:
k8s:
taskfile: ./deployment/Taskfile.yml
@@ -35,24 +36,18 @@ tasks:
- echo "GOOGLE_OAUTH_CLIENT_ID=__REPLACE__.apps.googleusercontent.com" >> deployment/local-dev/local.env
- echo "GOOGLE_OAUTH_CLIENT_SECRET=__REPLACE__" >> deployment/local-dev/local.env
- kubectl --context=docker-desktop apply -f deployment/setup/namespace.yaml
+ - kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.0/cert-manager.yaml
+ - docker build -t local-dev-init data/
setup-win:
platforms: [ windows ]
cmds:
- - cmd: powershell "if (test-path {{.HG_REPO_DIR}}/sena-3) { rm -r {{.HG_REPO_DIR}}/sena-3 }"
- ignore_error: true
- silent: true
- - powershell -Command "Invoke-WebRequest 'https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS' -OutFile sena-3.zip"
- - powershell -Command "Expand-Archive sena-3.zip -DestinationPath {{.HG_REPO_DIR}}"
- - powershell rm sena-3.zip
+ - powershell -Command "Invoke-WebRequest 'https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS' -OutFile {{.DATA_DIR}}/sena-3.zip -Resume"
+ - powershell -Command "Invoke-WebRequest 'https://drive.usercontent.google.com/download?export=download&id=1Jk-eSDho8ATBMS-Kmfatwi-MWQth26ro&confirm=t' -OutFile {{.DATA_DIR}}/elawa.zip -Resume"
setup-unix:
platforms: [ linux, darwin ]
cmds:
- - cmd: rm -rf {{.HG_REPO_DIR}}/sena-3
- ignore_error: true
- silent: true
- - wget -O sena-3.zip 'https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS'
- - unzip -q sena-3.zip -d {{.HG_REPO_DIR}}/
- - rm sena-3.zip
+ - wget -c -O {{.DATA_DIR}}/sena-3.zip 'https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS'
+ - wget -c -O {{.DATA_DIR}}/elawa.zip 'https://drive.usercontent.google.com/download?export=download&id=1Jk-eSDho8ATBMS-Kmfatwi-MWQth26ro&confirm=t'
# k8s
up:
@@ -84,5 +79,8 @@ tasks:
deps: [ infra-up, api:only, k8s:infra-forward ]
interactive: true
+ local-web-app-for-develop:
+ deps: [ ui:viewer-dev, api:local-web-app-for-develop, ui:https-oauth-authority ]
+
local-web-app:
- deps: [ ui:viewer-dev, api:local-web-app ]
+ deps: [ ui:viewer-dev, api:local-web-app, ui:https-oauth-authority ]
diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
new file mode 100644
index 000000000..2f862442c
--- /dev/null
+++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
@@ -0,0 +1,465 @@
+using FwDataMiniLcmBridge.Api.UpdateProxy;
+using Microsoft.Extensions.Logging;
+using MiniLcm;
+using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Core.Text;
+using SIL.LCModel.Core.WritingSystems;
+using SIL.LCModel.DomainServices;
+using SIL.LCModel.Infrastructure;
+
+namespace FwDataMiniLcmBridge.Api;
+
+public class FwDataMiniLcmApi(LcmCache cache, bool onCloseSave, ILogger logger, FwDataProject project) : ILexboxApi, IDisposable
+{
+ public FwDataProject Project { get; } = project;
+
+ private readonly IWritingSystemContainer _writingSystemContainer =
+ cache.ServiceLocator.WritingSystems;
+
+ private readonly ILexEntryRepository _entriesRepository =
+ cache.ServiceLocator.GetInstance();
+
+ private readonly IRepository _senseRepository =
+ cache.ServiceLocator.GetInstance>();
+
+ private readonly IRepository _exampleSentenceRepository =
+ cache.ServiceLocator.GetInstance>();
+
+ private readonly ILexEntryFactory _lexEntryFactory = cache.ServiceLocator.GetInstance();
+ private readonly ILexSenseFactory _lexSenseFactory = cache.ServiceLocator.GetInstance();
+
+ private readonly ILexExampleSentenceFactory _lexExampleSentenceFactory =
+ cache.ServiceLocator.GetInstance();
+
+ private readonly IMoMorphTypeRepository _morphTypeRepository =
+ cache.ServiceLocator.GetInstance();
+
+ private readonly ICmTranslationFactory _cmTranslationFactory =
+ cache.ServiceLocator.GetInstance();
+
+ private readonly ICmPossibilityRepository _cmPossibilityRepository =
+ cache.ServiceLocator.GetInstance();
+
+ public void Dispose()
+ {
+ if (onCloseSave)
+ {
+ Save();
+ }
+ }
+
+ public void Save()
+ {
+ logger.LogInformation("Saving FW data file {Name}", cache.ProjectId.Name);
+ cache.ActionHandlerAccessor.Commit();
+ }
+
+ public int EntryCount => _entriesRepository.Count;
+
+ internal WritingSystemId GetWritingSystemId(int ws)
+ {
+ return cache.ServiceLocator.WritingSystemManager.Get(ws).Id;
+ }
+
+ internal int GetWritingSystemHandle(WritingSystemId ws, WritingSystemType? type = null)
+ {
+ var lcmWs = GetLcmWritingSystem(ws, type) ?? throw new NullReferenceException($"Unable to find writing system with id {ws}");
+ return lcmWs.Handle;
+ }
+
+ internal CoreWritingSystemDefinition? GetLcmWritingSystem(WritingSystemId ws, WritingSystemType? type = null)
+ {
+ if (ws == "default")
+ {
+ return type switch
+ {
+ WritingSystemType.Analysis => _writingSystemContainer.DefaultAnalysisWritingSystem,
+ WritingSystemType.Vernacular => _writingSystemContainer.DefaultVernacularWritingSystem,
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
+ };
+ }
+
+ var lcmWs = cache.ServiceLocator.WritingSystemManager.Get(ws.Code);
+ if (lcmWs is not null && type is not null)
+ {
+ var validWs = type switch
+ {
+ WritingSystemType.Analysis => _writingSystemContainer.AnalysisWritingSystems,
+ WritingSystemType.Vernacular => _writingSystemContainer.VernacularWritingSystems,
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
+ };
+ if (!validWs.Contains(lcmWs))
+ {
+ throw new InvalidOperationException($"Writing system {ws} is not of the requested type: {type}.");
+ }
+ }
+ return lcmWs;
+ }
+
+ public Task GetWritingSystems()
+ {
+ var currentVernacularWs = _writingSystemContainer
+ .CurrentVernacularWritingSystems
+ .Select(ws => ws.Id).ToHashSet();
+ var currentAnalysisWs = _writingSystemContainer
+ .CurrentAnalysisWritingSystems
+ .Select(ws => ws.Id).ToHashSet();
+ var writingSystems = new WritingSystems
+ {
+ Vernacular = cache.ServiceLocator.WritingSystems.VernacularWritingSystems.Select(ws => new WritingSystem
+ {
+ //todo determine current and create a property for that.
+ Id = ws.Id,
+ Name = ws.LanguageTag,
+ Abbreviation = ws.Abbreviation,
+ Font = ws.DefaultFontName,
+ Exemplars = ws.CharacterSets.FirstOrDefault(s => s.Type == "index")?.Characters.ToArray() ?? []
+ }).ToArray(),
+ Analysis = cache.ServiceLocator.WritingSystems.AnalysisWritingSystems.Select(ws => new WritingSystem
+ {
+ Id = ws.Id,
+ Name = ws.LanguageTag,
+ Abbreviation = ws.Abbreviation,
+ Font = ws.DefaultFontName,
+ Exemplars = ws.CharacterSets.FirstOrDefault(s => s.Type == "index")?.Characters.ToArray() ?? []
+ }).ToArray()
+ };
+ CompleteExemplars(writingSystems);
+ return Task.FromResult(writingSystems);
+ }
+
+ internal void CompleteExemplars(WritingSystems writingSystems)
+ {
+ var wsExemplars = writingSystems.Vernacular.Concat(writingSystems.Analysis)
+ .Distinct()
+ .ToDictionary(ws => ws, ws => ws.Exemplars.ToHashSet());
+ var wsExemplarsByHandle = wsExemplars.ToDictionary(kv => GetWritingSystemHandle(kv.Key.Id), kv => kv.Value);
+
+ foreach (var entry in _entriesRepository.AllInstances())
+ {
+ LcmHelpers.ContributeExemplars(entry.CitationForm, wsExemplarsByHandle);
+ LcmHelpers.ContributeExemplars(entry.LexemeFormOA.Form, wsExemplarsByHandle);
+ }
+
+ foreach (var ws in wsExemplars.Keys)
+ {
+ ws.Exemplars = [.. wsExemplars[ws].Order()];
+ }
+ }
+
+ public Task CreateWritingSystem(WritingSystemType type, WritingSystem writingSystem)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update)
+ {
+ throw new NotImplementedException();
+ }
+
+ private Entry FromLexEntry(ILexEntry entry)
+ {
+ return new Entry
+ {
+ Id = entry.Guid,
+ Note = FromLcmMultiString(entry.Comment),
+ LexemeForm = FromLcmMultiString(entry.LexemeFormOA.Form),
+ CitationForm = FromLcmMultiString(entry.CitationForm),
+ LiteralMeaning = FromLcmMultiString(entry.LiteralMeaning),
+ Senses = entry.AllSenses.Select(FromLexSense).ToList()
+ };
+ }
+
+ private Sense FromLexSense(ILexSense sense)
+ {
+ return new Sense
+ {
+ Id = sense.Guid,
+ Gloss = FromLcmMultiString(sense.Gloss),
+ Definition = FromLcmMultiString(sense.Definition),
+ PartOfSpeech = sense.SenseTypeRA?.Name.BestAnalysisVernacularAlternative.Text ?? string.Empty,
+ SemanticDomain = sense.SemanticDomainsRC.Select(s => s.OcmCodes).ToList(),
+ ExampleSentences = sense.ExamplesOS.Select(FromLexExampleSentence).ToList()
+ };
+ }
+
+ private ExampleSentence FromLexExampleSentence(ILexExampleSentence sentence)
+ {
+ var translation = sentence.TranslationsOC.FirstOrDefault()?.Translation;
+ return new ExampleSentence
+ {
+ Id = sentence.Guid,
+ Sentence = FromLcmMultiString(sentence.Example),
+ Reference = sentence.Reference.Text,
+ Translation = translation is null ? new MultiString() : FromLcmMultiString(translation),
+ };
+ }
+
+ private MultiString FromLcmMultiString(ITsMultiString multiString)
+ {
+ var result = new MultiString();
+ for (var i = 0; i < multiString.StringCount; i++)
+ {
+ var tsString = multiString.GetStringFromIndex(i, out var ws);
+ result.Values.Add(GetWritingSystemId(ws), tsString.Text);
+ }
+
+ return result;
+ }
+
+ public async IAsyncEnumerable GetEntries(QueryOptions? options = null)
+ {
+ await foreach (var entry in GetEntries(null, options))
+ {
+ yield return entry;
+ }
+ }
+
+ public async IAsyncEnumerable GetEntries(
+ Func? predicate = null, QueryOptions? options = null)
+ {
+ var entries = _entriesRepository.AllInstances();
+
+ options ??= QueryOptions.Default;
+ if (predicate is not null) entries = entries.Where(e => predicate(e));
+
+ if (options.Exemplar is not null)
+ {
+ var ws = GetWritingSystemHandle(options.Exemplar.WritingSystem, WritingSystemType.Vernacular);
+ entries = entries.Where(e => (e.CitationForm.get_String(ws).Text ?? e.LexemeFormOA.Form.get_String(ws).Text)?
+ .Trim(LcmHelpers.WhitespaceAndFormattingChars)
+ .StartsWith(options.Exemplar.Value, StringComparison.InvariantCultureIgnoreCase) ?? false);
+ }
+
+ var sortWs = GetWritingSystemHandle(options.Order.WritingSystem, WritingSystemType.Vernacular);
+ entries = entries.OrderBy(e => (e.CitationForm.get_String(sortWs).Text ?? e.LexemeFormOA.Form.get_String(sortWs).Text).Trim(LcmHelpers.WhitespaceChars))
+ .Skip(options.Offset)
+ .Take(options.Count);
+
+ foreach (var entry in entries)
+ {
+ yield return FromLexEntry(entry);
+ }
+ }
+
+ public IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null)
+ {
+ var entries = GetEntries(e =>
+ e.CitationForm.SearchValue(query) ||
+ e.LexemeFormOA.Form.SearchValue(query) ||
+ e.SensesOS.Any(s => s.Gloss.SearchValue(query)), options);
+ return entries;
+ }
+
+ public Task GetEntry(Guid id)
+ {
+ return Task.FromResult(FromLexEntry(_entriesRepository.GetObject(id)));
+ }
+
+ public async Task CreateEntry(Entry entry)
+ {
+ // TODO: The API requires a value and the UI assumes it has a value. How should we handle this?
+ // if (entry.Id != default) throw new NotSupportedException("Id must be empty");
+ Guid entryId = default;
+ UndoableUnitOfWorkHelper.Do("Create Entry",
+ "Remove entry",
+ cache.ServiceLocator.ActionHandler,
+ () =>
+ {
+ var rootMorphType = _morphTypeRepository.GetObject(MoMorphTypeTags.kguidMorphRoot);
+ var firstSense = entry.Senses.FirstOrDefault();
+ var lexEntry = _lexEntryFactory.Create(new LexEntryComponents
+ {
+ MorphType = rootMorphType,
+ LexemeFormAlternatives = MultiStringToTsStrings(entry.LexemeForm),
+ GlossAlternatives = MultiStringToTsStrings(firstSense?.Gloss),
+ GlossFeatures = [],
+ MSA = null
+ });
+ UpdateLcmMultiString(lexEntry.CitationForm, entry.CitationForm);
+ UpdateLcmMultiString(lexEntry.LiteralMeaning, entry.LiteralMeaning);
+ UpdateLcmMultiString(lexEntry.Comment, entry.Note);
+ if (firstSense is not null)
+ {
+ var lexSense = lexEntry.SensesOS.First();
+ ApplySenseToLexSense(firstSense, lexSense);
+ }
+
+ //first sense is already created
+ foreach (var sense in entry.Senses.Skip(1))
+ {
+ CreateSense(lexEntry, sense);
+ }
+
+ entryId = lexEntry.Guid;
+ });
+ if (entryId == default) throw new InvalidOperationException("Entry was not created");
+
+ return await GetEntry(entryId) ?? throw new InvalidOperationException("Entry was not found");
+ }
+
+ private IList MultiStringToTsStrings(MultiString? multiString)
+ {
+ if (multiString is null) return [];
+ var result = new List(multiString.Values.Count);
+ foreach (var (ws, value) in multiString.Values)
+ {
+ result.Add(TsStringUtils.MakeString(value, GetWritingSystemHandle(ws)));
+ }
+
+ return result;
+ }
+
+ private void UpdateLcmMultiString(ITsMultiString multiString, MultiString newMultiString)
+ {
+ foreach (var (ws, value) in newMultiString.Values)
+ {
+ var writingSystemHandle = GetWritingSystemHandle(ws);
+ multiString.set_String(writingSystemHandle, TsStringUtils.MakeString(value, writingSystemHandle));
+ }
+ }
+
+ public Task UpdateEntry(Guid id, UpdateObjectInput update)
+ {
+ var lexEntry = _entriesRepository.GetObject(id);
+ UndoableUnitOfWorkHelper.Do("Update Entry",
+ "Revert entry",
+ cache.ServiceLocator.ActionHandler,
+ () =>
+ {
+ var updateProxy = new UpdateEntryProxy(lexEntry, this);
+ update.Apply(updateProxy);
+ });
+ return Task.FromResult(FromLexEntry(lexEntry));
+ }
+
+ public Task DeleteEntry(Guid id)
+ {
+ UndoableUnitOfWorkHelper.Do("Delete Entry",
+ "Revert delete",
+ cache.ServiceLocator.ActionHandler,
+ () =>
+ {
+ _entriesRepository.GetObject(id).Delete();
+ });
+ return Task.CompletedTask;
+ }
+
+ internal void CreateSense(ILexEntry lexEntry, Sense sense)
+ {
+ var lexSense = _lexSenseFactory.Create(sense.Id, lexEntry);
+ ApplySenseToLexSense(sense, lexSense);
+ }
+
+ private void ApplySenseToLexSense(Sense sense, ILexSense lexSense)
+ {
+ UpdateLcmMultiString(lexSense.Gloss, sense.Gloss);
+ UpdateLcmMultiString(lexSense.Definition, sense.Definition);
+ foreach (var exampleSentence in sense.ExampleSentences)
+ {
+ CreateExampleSentence(lexSense, exampleSentence);
+ }
+ }
+
+ public Task CreateSense(Guid entryId, Sense sense)
+ {
+ if (sense.Id != default) sense.Id = Guid.NewGuid();
+ if (!_entriesRepository.TryGetObject(entryId, out var lexEntry))
+ throw new InvalidOperationException("Entry not found");
+ UndoableUnitOfWorkHelper.Do("Create Sense",
+ "Remove sense",
+ cache.ServiceLocator.ActionHandler,
+ () => CreateSense(lexEntry, sense));
+ return Task.FromResult(FromLexSense(_senseRepository.GetObject(sense.Id)));
+ }
+
+ public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update)
+ {
+ var lexSense = _senseRepository.GetObject(senseId);
+ if (lexSense.Owner.Guid != entryId) throw new InvalidOperationException("Sense does not belong to entry");
+ UndoableUnitOfWorkHelper.Do("Update Sense",
+ "Revert sense",
+ cache.ServiceLocator.ActionHandler,
+ () =>
+ {
+ var updateProxy = new UpdateSenseProxy(lexSense, this);
+ update.Apply(updateProxy);
+ });
+ return Task.FromResult(FromLexSense(lexSense));
+ }
+
+ public Task DeleteSense(Guid entryId, Guid senseId)
+ {
+ var lexSense = _senseRepository.GetObject(senseId);
+ if (lexSense.Owner.Guid != entryId) throw new InvalidOperationException("Sense does not belong to entry");
+ UndoableUnitOfWorkHelper.Do("Delete Sense",
+ "Revert delete",
+ cache.ServiceLocator.ActionHandler,
+ () => lexSense.Delete());
+ return Task.CompletedTask;
+ }
+
+ internal void CreateExampleSentence(ILexSense lexSense, ExampleSentence exampleSentence)
+ {
+ var lexExampleSentence = _lexExampleSentenceFactory.Create(exampleSentence.Id, lexSense);
+ UpdateLcmMultiString(lexExampleSentence.Example, exampleSentence.Sentence);
+ var freeTranslationType = _cmPossibilityRepository.GetObject(CmPossibilityTags.kguidTranFreeTranslation);
+ var translation = _cmTranslationFactory.Create(lexExampleSentence, freeTranslationType);
+ UpdateLcmMultiString(translation.Translation, exampleSentence.Translation);
+ lexExampleSentence.Reference = TsStringUtils.MakeString(exampleSentence.Reference,
+ lexExampleSentence.Reference.get_WritingSystem(0));
+ }
+
+ public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence)
+ {
+ if (exampleSentence.Id != default) exampleSentence.Id = Guid.NewGuid();
+ if (!_senseRepository.TryGetObject(senseId, out var lexSense))
+ throw new InvalidOperationException("Sense not found");
+ UndoableUnitOfWorkHelper.Do("Create Example Sentence",
+ "Remove example sentence",
+ cache.ServiceLocator.ActionHandler,
+ () => CreateExampleSentence(lexSense, exampleSentence));
+ return Task.FromResult(FromLexExampleSentence(_exampleSentenceRepository.GetObject(exampleSentence.Id)));
+ }
+
+ public Task UpdateExampleSentence(Guid entryId,
+ Guid senseId,
+ Guid exampleSentenceId,
+ UpdateObjectInput update)
+ {
+ var lexExampleSentence = _exampleSentenceRepository.GetObject(exampleSentenceId);
+ if (lexExampleSentence.Owner.Guid != senseId)
+ throw new InvalidOperationException("Example sentence does not belong to sense");
+ if (lexExampleSentence.Owner.Owner.Guid != entryId)
+ throw new InvalidOperationException("Example sentence does not belong to entry");
+ UndoableUnitOfWorkHelper.Do("Update Example Sentence",
+ "Revert example sentence",
+ cache.ServiceLocator.ActionHandler,
+ () =>
+ {
+ var updateProxy = new UpdateExampleSentenceProxy(lexExampleSentence, this);
+ update.Apply(updateProxy);
+ });
+ return Task.FromResult(FromLexExampleSentence(lexExampleSentence));
+ }
+
+ public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId)
+ {
+ var lexExampleSentence = _exampleSentenceRepository.GetObject(exampleSentenceId);
+ if (lexExampleSentence.Owner.Guid != senseId)
+ throw new InvalidOperationException("Example sentence does not belong to sense");
+ if (lexExampleSentence.Owner.Owner.Guid != entryId)
+ throw new InvalidOperationException("Example sentence does not belong to entry");
+ UndoableUnitOfWorkHelper.Do("Delete Example Sentence",
+ "Revert delete",
+ cache.ServiceLocator.ActionHandler,
+ () => lexExampleSentence.Delete());
+ return Task.CompletedTask;
+ }
+
+ public UpdateBuilder CreateUpdateBuilder() where T : class
+ {
+ return new UpdateBuilder();
+ }
+}
diff --git a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs
new file mode 100644
index 000000000..351022e8c
--- /dev/null
+++ b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs
@@ -0,0 +1,68 @@
+using SIL.LCModel.Core.KernelInterfaces;
+
+namespace FwDataMiniLcmBridge.Api;
+
+internal static class LcmHelpers
+{
+ internal static bool SearchValue(this ITsMultiString multiString, string value)
+ {
+ var valueLower = value.ToLowerInvariant();
+ for (var i = 0; i < multiString.StringCount; i++)
+ {
+ var tsString = multiString.GetStringFromIndex(i, out var _);
+ if (tsString.Text?.ToLowerInvariant().Contains(valueLower) is true)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ internal static readonly char[] WhitespaceChars =
+ [
+ '\u0009', // Tab
+ '\u000A', // Line Feed
+ '\u000D', // Carriage Return
+ '\u0020', // Space
+ '\u00A0', // Non-breaking Space
+ '\u2002', // En Space
+ '\u2003', // Em Space
+ '\u2004', // Three-Per-Em Space
+ '\u2005', // Four-Per-Em Space
+ '\u2006', // Six-Per-Em Space
+ '\u2007', // Figure Space
+ '\u2008', // Punctuation Space
+ '\u2009', // Thin Space
+ '\u200A', // Hair Space
+ '\u200B', // Zero Width Space
+ '\u200C', // Zero Width Non-Joiner
+ '\u200D', // Zero Width Joiner
+ '\u200E', // Left-to-Right Mark
+ '\u200F', // Right-to-Left Mark
+ '\u2028', // Line Separator
+ '\u2029', // Paragraph Separator
+ '\u202F', // Narrow No-Break Space
+ '\u205F', // Medium Mathematical Space
+ '\u3000', // Ideographic Space
+ '\uFEFF', // Zero Width No-Break Space / BOM
+ ];
+
+ internal static readonly char[] WhitespaceAndFormattingChars =
+ [
+ .. WhitespaceChars,
+ '\u0640', // Arabic Tatweel
+ ];
+
+ internal static void ContributeExemplars(ITsMultiString multiString, Dictionary> wsExemplars)
+ {
+ for (var i = 0; i < multiString.StringCount; i++)
+ {
+ var tsString = multiString.GetStringFromIndex(i, out var ws);
+ var value = tsString.Text?.Trim(WhitespaceAndFormattingChars);
+ if (value?.Any() is true && wsExemplars.TryGetValue(ws, out var exemplars))
+ {
+ exemplars.Add(value.First().ToString());
+ }
+ }
+ }
+}
diff --git a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs
new file mode 100644
index 000000000..9060ecf82
--- /dev/null
+++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs
@@ -0,0 +1,165 @@
+using System.Collections;
+using MiniLcm;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Core.Text;
+
+namespace FwDataMiniLcmBridge.Api.UpdateProxy;
+
+public class UpdateDictionaryProxy(ITsMultiString multiString, FwDataMiniLcmApi lexboxLcmApi)
+ : IDictionary, IDictionary
+{
+ public void Add(KeyValuePair item)
+ {
+ Add(item.Key, item.Value);
+ }
+
+ public void Add(WritingSystemId key, string value)
+ {
+ var writingSystemHandle = lexboxLcmApi.GetWritingSystemHandle(key);
+ multiString.set_String(writingSystemHandle, TsStringUtils.MakeString(value, writingSystemHandle));
+ }
+
+ public bool ContainsKey(WritingSystemId key)
+ {
+ if (multiString.StringCount == 0) return false;
+ var tsString = multiString.get_String(lexboxLcmApi.GetWritingSystemHandle(key));
+ return tsString.Length > 0;
+ }
+
+ public void Add(object key, object? value)
+ {
+ var valStr = value as string ?? throw new ArgumentException("unable to convert value to string", nameof(value));
+ if (key is WritingSystemId keyWs)
+ {
+ Add(keyWs, valStr);
+ }
+ else if (key is string keyStr)
+ {
+ Add(keyStr, valStr);
+ }
+ else
+ {
+ throw new ArgumentException("unable to convert key to writing system id", nameof(key));
+ }
+ }
+
+ public bool Contains(object key)
+ {
+ return ContainsKey(key as WritingSystemId? ?? key as string ??
+ throw new ArgumentException("unable to convert key to writing system id", nameof(key)));
+ }
+
+
+ public string this[WritingSystemId key]
+ {
+ get
+ {
+ var tsString = multiString.get_String(lexboxLcmApi.GetWritingSystemHandle(key));
+ return tsString.Text;
+ }
+ set
+ {
+ var writingSystemHandle = lexboxLcmApi.GetWritingSystemHandle(key);
+ multiString.set_String(writingSystemHandle, TsStringUtils.MakeString(value, writingSystemHandle));
+ }
+ }
+
+ public object? this[object key]
+ {
+ get =>
+ key switch
+ {
+ WritingSystemId keyWs => this[keyWs],
+ string keyStr => this[keyStr],
+ _ => throw new ArgumentException("unable to convert key to writing system id", nameof(key))
+ };
+ set
+ {
+ var valStr = value as string ??
+ throw new ArgumentException("unable to convert value to string", nameof(value));
+ if (key is WritingSystemId keyWs)
+ {
+ this[keyWs] = valStr;
+ }
+ else if (key is string keyStr)
+ {
+ this[keyStr] = valStr;
+ }
+ else
+ {
+ throw new ArgumentException("unable to convert key to writing system id", nameof(key));
+ }
+ }
+ }
+
+ IDictionaryEnumerator IDictionary.GetEnumerator()
+ {
+ throw new NotSupportedException();
+ }
+
+ public void Remove(object key)
+ {
+ }
+
+ public bool IsFixedSize => false;
+
+ public IEnumerator> GetEnumerator()
+ {
+ throw new NotSupportedException();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+ public void Clear()
+ {
+ throw new NotSupportedException();
+ }
+
+ public bool Contains(KeyValuePair item)
+ {
+ throw new NotSupportedException();
+ }
+
+ public void CopyTo(KeyValuePair[] array, int arrayIndex)
+ {
+ throw new NotSupportedException();
+ }
+
+ public bool Remove(KeyValuePair item)
+ {
+ throw new NotSupportedException();
+ }
+
+ public void CopyTo(Array array, int index)
+ {
+ }
+
+ public int Count => throw new NotSupportedException();
+
+ public bool IsSynchronized => false;
+
+ public object SyncRoot => this;
+
+ public bool IsReadOnly => false;
+
+ public bool Remove(WritingSystemId key)
+ {
+ throw new NotSupportedException();
+ }
+
+ public bool TryGetValue(WritingSystemId key, out string value)
+ {
+ throw new NotSupportedException();
+ }
+
+ public ICollection Keys => [];
+
+ ICollection IDictionary.Values => Array.Empty