Skip to content

Begin modeling pictures in MiniLcm#2300

Draft
rmunn wants to merge 11 commits into
developfrom
feat/pictures
Draft

Begin modeling pictures in MiniLcm#2300
rmunn wants to merge 11 commits into
developfrom
feat/pictures

Conversation

@rmunn

@rmunn rmunn commented May 27, 2026

Copy link
Copy Markdown
Contributor

WIP for #2232 and #2233.

MiniLcm API for picture support now contains the skeleton of what it needs to do, but one major feature is still lacking: correct MediaUri handling (which is the heart of the feature). It's not nearly as direct a translation from FW Classic to FW Lite, so I've implemented the easy stuff first. MediaUri handling will come next.

@rmunn rmunn self-assigned this May 27, 2026
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2b077548-32ce-414e-8817-ddd369beb7c6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/pictures

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added the 💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related label May 27, 2026
@rmunn rmunn linked an issue May 27, 2026 that may be closed by this pull request
@github-actions

github-actions Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

UI unit Tests

  1 files  ±0   62 suites  ±0   30s ⏱️ +3s
186 tests ±0  186 ✅ ±0  0 💤 ±0  0 ❌ ±0 
258 runs  ±0  258 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 759a337. ± Comparison against base commit fa14cfa.

♻️ This comment has been updated with latest results.

@argos-ci

argos-ci Bot commented May 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
default (Inspect) ✅ No changes detected - Jun 17, 2026, 7:54 AM

@github-actions github-actions Bot added the 📦 Lexbox issues related to any server side code, fw-headless included label May 28, 2026
@github-actions

github-actions Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

C# Unit Tests

165 tests   165 ✅  20s ⏱️
 23 suites    0 💤
  1 files      0 ❌

Results for commit 759a337.

♻️ This comment has been updated with latest results.

@rmunn

rmunn commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit - Please examine how MediaUri is handled in audio files, then suggest how to handle the MediaUri property in the Picture class.

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Search for MediaUri in the codebase ==="
rg -n "MediaUri" --type cs -C 3

echo ""
echo "=== Search for audio-related model files ==="
fd -e cs -i "audio" --exec echo {}

echo ""
echo "=== Search for Picture model files ==="
fd -e cs -i "picture" --exec echo {}

echo ""
echo "=== Search for ExampleSentence or audio model definitions ==="
rg -n "class.*Audio\|AudioFile\|Sound" --type cs -C 5

Length of output: 45536


🏁 Script executed:

#!/bin/bash
echo "=== Find MiniLcm model files ==="
fd -e cs . --search-path "MiniLcm" | head -60

echo ""
echo "=== Search for all MediaUri occurrences ==="
rg -rn "MediaUri" -C 5

Length of output: 57201


🏁 Script executed:

#!/bin/bash
echo "=== Search for PictureFileRA (LCM picture file property) ==="
rg -n "PictureFileRA\|InternalPath\|PictureFolder\|Pictures folder\|TypeToLinkedFolder" --type cs -C 3

echo ""
echo "=== Look at UpdatePictureProxy in full ==="
cat backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdatePictureProxy.cs

echo ""
echo "=== Look at CreatePictureChange in full ==="
cat backend/FwLite/LcmCrdt/Changes/CreatePictureChange.cs

echo ""
echo "=== Look at PictureSync in full ==="
cat backend/FwLite/MiniLcm/SyncHelpers/PictureSync.cs

echo ""
echo "=== Search for TypeToLinkedFolder and AudioVisualFolder constant ==="
rg -n "TypeToLinkedFolder\|AudioVisualFolder\|PictureFolder\|Pictures\b" --type cs -C 2

Length of output: 5543


🏁 Script executed:

#!/bin/bash
echo "=== Find AudioVisualFolder and TypeToLinkedFolder in FwDataMiniLcmApi.cs ==="
grep -n "AudioVisual\|TypeToLinked\|PictureFile\|InternalPath\|Pictures\|LinkedFiles\|CmFile\|cmFile" \
  backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | head -60

echo ""
echo "=== View lines around the folder/type mapping function ==="
grep -n "TypeToLinkedFolder\|AudioVisualFolder\|const string\|private.*Folder" \
  backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs

echo ""
echo "=== Grep for picture file path references in FW LCM ==="
rg -n "PictureFile\|CmFile\|InternalPath\|\.PictureFilePath\|picture\..*[Ff]ile\|\.ImageFile" \
  backend/FwLite/FwDataMiniLcmBridge/ -C 2

echo ""
echo "=== Check what LCM picture interface looks like ==="
rg -rn "PictureFileRA\|ICmPicture\|CmPicture" --type cs -C 3 | head -80

Length of output: 3346


🏁 Script executed:

#!/bin/bash
echo "=== LcmToPicture method context ==="
grep -n "LcmToPicture\|private.*Picture\|FromLcmPicture\|ToMiniLcmPicture\|ICmPicture" \
  backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs

echo ""
echo "=== View lines 795-830 of FwDataMiniLcmApi.cs (picture mapping) ==="
sed -n '795,835p' backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs

echo ""
echo "=== View lines 1940-1960 (TypeToLinkedFolder) ==="
sed -n '1940,1960p' backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs

echo ""
echo "=== Search for ICmPicture file-path related properties in LCM NuGet/dlls ==="
rg -rn "\.PictureFile\|picture\.\|ICmPicture\b" --type cs backend/FwLite/ -C 2 | head -60

echo ""
echo "=== Check the SIL LCModel ICmPicture or CmPicture in dlls/packages ==="
find . -name "*.cs" | xargs grep -l "ICmPicture" 2>/dev/null | head -10

echo ""
echo "=== View LfClassicMiniLcmApi picture mapping (line ~350) ==="
grep -n "picture\.\|Picture\|MediaUri\|CmFile\|InternalPath" backend/LfClassicData/LfClassicMiniLcmApi.cs | head -30

Length of output: 4911


🏁 Script executed:

#!/bin/bash
echo "=== Search for ICmFile usage anywhere ==="
rg -rn "ICmFile\|PictureFileRA\|InternalPath\|\.PicturePath\b" --type cs -C 3 | head -60

echo ""
echo "=== Look for how FW code sets picture file path - CreatePicture context ==="
sed -n '1810,1850p' backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs

echo ""
echo "=== Search for Pictures folder const or PictureFolder ==="
rg -rn "const.*[Pp]icture\|PictureFolder\b" --type cs -C 2

echo ""
echo "=== Look for nuget packages that contain LCModel ==="
find . -name "*.csproj" | xargs grep -l "LCModel\|liblcm\|SIL.LCModel" 2>/dev/null | head -5

echo ""
echo "=== Check any existing usages that access picture.PictureFile ==="
rg -rn "picture\." --type cs backend/FwLite/FwDataMiniLcmBridge/ -C 1 | head -40

Length of output: 5205



@rmunn Here's a comprehensive analysis of the audio MediaUri pattern and how to apply it to pictures.


How MediaUri works for audio files

The data model difference

In FieldWorks LCM:

  • Audio: Stored as a relative filename (e.g. "recording.wav") inside a MultiString on a writing system tagged with IsAudio. Path is under LinkedFilesRootDir/AudioVisual/.
  • Picture: Stored via ICmPicture.PictureFileRA, which is an ICmFile object whose InternalPath is relative to LinkedFilesRootDir. Files land in LinkedFilesRootDir/Pictures/ (already mapped in TypeToLinkedFolder for image/*).

The audio translation pipeline

Reading (FW → MiniLcm)ToMediaUri() in FwDataMiniLcmApi.cs:

// relative filename from MultiString → full path → MediaUri
var fullFilePath = Path.Join(Cache.LangProject.LinkedFilesRootDir, AudioVisualFolder, tsString);
return mediaAdapter.MediaUriFromPath(fullFilePath, Cache).ToString();

Writing (MiniLcm → FW)FromMediaUri() in FwDataMiniLcmApi.cs:

// MediaUri string → full path → relative path back to AudioVisual subfolder
var mediaUri = new MediaUri(mediaUriString);
var path = mediaAdapter.PathFromMediaUri(mediaUri, Cache);
return Path.GetRelativePath(Path.Join(Cache.LangProject.LinkedFilesRootDir, AudioVisualFolder), path);

Guard on writeUpdateDictionaryProxy.ShouldSet():

if (!wsId.IsAudio) return true;
return value != MediaUri.NotFoundString; // skip writing back NotFound URIs

Suggested implementation for Picture.MediaUri

The pattern is the same; the only difference is the FW storage mechanism (PictureFileRA/ICmFile instead of a MultiString value).

1. Reading — FromLcmPicture in FwDataMiniLcmApi.cs

private Picture FromLcmPicture(Guid senseGuid, ICmPicture picture)
{
    var mediaUri = MediaUri.NotFound;
    var internalPath = picture.PictureFileRA?.InternalPath;
    if (!string.IsNullOrEmpty(internalPath))
    {
        var fullPath = Path.Join(Cache.LangProject.LinkedFilesRootDir, internalPath);
        mediaUri = mediaAdapter.MediaUriFromPath(fullPath, Cache);
    }
    return new Picture
    {
        Id = picture.Guid,
        Caption = FromLcmMultiString(picture.Caption),
        // SenseId = senseGuid,
        MediaUri = mediaUri,
    };
}

2. Writing — CreatePicture in FwDataMiniLcmApi.cs

internal void CreatePicture(ILexSense lexSense, Picture picture, BetweenPosition? between = null)
{
    if (picture.Id == default) picture.Id = Guid.NewGuid();
    var lcmPicture = LcmPictureFactory.Create(picture.Id);
    UpdateLcmMultiString(lcmPicture.Caption, picture.Caption);
    SetLcmPictureFile(lcmPicture, picture.MediaUri);
    InsertPicture(lexSense, lcmPicture, between);
}

private void SetLcmPictureFile(ICmPicture lcmPicture, MediaUri mediaUri)
{
    if (mediaUri == MediaUri.NotFound) return;
    var fullPath = mediaAdapter.PathFromMediaUri(mediaUri, Cache);
    if (fullPath is null) return;
    // Reuse an existing ICmFile with the same path, or create a new one
    var relativePath = Path.GetRelativePath(Cache.LangProject.LinkedFilesRootDir, fullPath);
    var fileRepo = Cache.ServiceLocator.GetInstance<IRepository<ICmFile>>();
    var cmFile = fileRepo.AllInstances().FirstOrDefault(f => f.InternalPath == relativePath);
    if (cmFile is null)
    {
        cmFile = Cache.ServiceLocator.GetInstance<ICmFileFactory>().Create();
        Cache.LangProject.MediaFilesOA.FilesOC.Add(cmFile);
        cmFile.InternalPath = relativePath;
    }
    lcmPicture.PictureFileRA = cmFile;
}

Note: SetLcmPictureFile can be made internal so UpdatePictureProxy can call it directly.

3. Update proxy — UpdatePictureProxy.cs

public override MediaUri MediaUri
{
    get
    {
        var internalPath = picture.PictureFileRA?.InternalPath;
        if (string.IsNullOrEmpty(internalPath)) return MediaUri.NotFound;
        var fullPath = Path.Join(lexboxLcmApi.Cache.LangProject.LinkedFilesRootDir, internalPath);
        return lexboxLcmApi.mediaAdapter.MediaUriFromPath(fullPath, lexboxLcmApi.Cache);
    }
    set => lexboxLcmApi.SetLcmPictureFile(picture, value);
}

4. Sync diff — PictureSync.DiffToUpdate

public static UpdateObjectInput<Picture>? DiffToUpdate(Picture beforePicture, Picture afterPicture)
{
    JsonPatchDocument<Picture> patchDocument = new();
    patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff<Picture>(
        nameof(Picture.Caption),
        beforePicture.Caption,
        afterPicture.Caption));

    if (beforePicture.MediaUri != afterPicture.MediaUri)
        patchDocument.Replace(p => p.MediaUri, afterPicture.MediaUri);

    if (patchDocument.Operations.Count == 0) return null;
    return new UpdateObjectInput<Picture>(patchDocument);
}

5. CRDT change — CreatePictureChange.cs

public MediaUri MediaUri { get; set; }

public CreatePictureChange(Picture picture, Guid senseId)
    : base(picture.Id == Guid.Empty ? Guid.NewGuid() : picture.Id)
{
    picture.Id = EntityId;
    Order = picture.Order;
    Caption = picture.Caption;
    MediaUri = picture.MediaUri;
}

public override async ValueTask<Picture> NewEntity(Commit commit, IChangeContext context)
{
    return new Picture
    {
        Id = EntityId,
        Order = Order,
        Caption = Caption ?? new(),
        MediaUri = MediaUri,
        DeletedAt = null,
    };
}

6. LF Classic — LfClassicMiniLcmApi.ToPicture

LF Classic's Entities.Picture likely has an ImageFilePath (or similar) field storing a filename. The pattern would be the same: build the full path using the project's linked files root, then call through mediaAdapter:

private static MiniLcm.Models.Picture ToPicture(Guid senseId, Entities.Picture picture)
{
    return new MiniLcm.Models.Picture
    {
        Id = picture.Guid,
        Caption = ToRichMultiString(picture.Caption),
        MediaUri = !string.IsNullOrEmpty(picture.ImageFilePath)
            ? new MediaUri(picture.ImageFilePath)   // or via adapter if one exists for LF Classic
            : MediaUri.NotFound,
    };
}

LF Classic may need its own adapter (or a simpler direct-path approach), since it doesn't go through IMediaAdapter/LCM like the FW bridge does. You'll want to confirm what field on Entities.Picture holds the file reference.


Summary of the pattern

Step Audio Picture
FW storage MultiString[audioWs] = relative filename ICmPicture.PictureFileRA.InternalPath = relative path
Subfolder LinkedFilesRootDir/AudioVisual/ LinkedFilesRootDir/Pictures/
Read (FW→MiniLcm) mediaAdapter.MediaUriFromPath(fullPath) same
Write (MiniLcm→FW) mediaAdapter.PathFromMediaUri() → update string same, but find/create ICmFile, set PictureFileRA
NotFound guard UpdateDictionaryProxy.ShouldSet skips write SetLcmPictureFile returns early if MediaUri.NotFound

@rmunn

rmunn commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Current code is partly working, but I haven't yet told Entity Framework how to map a Picture object into the DB. Which results in this exception when I run a sync:

System.InvalidOperationException: The property 'Picture.MediaUri' could not be mapped because it is of type 'MediaUri', which is not a supported primitive type or a valid entity type. Either explicitly map this property, or ignore it using the '[NotMapped]' attribute or by using 'EntityTypeBuilder.Ignore' in 'OnModelCreating'.
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelValidator.ValidatePropertyMapping(IConventionTypeBase structuralType, IConventionModel model, IDiagnosticsLogger`1 logger)
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelValidator.ValidatePropertyMapping(IModel model, IDiagnosticsLogger`1 logger)
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelValidator.Validate(IModel model, IDiagnosticsLogger`1 logger)
   at Microsoft.EntityFrameworkCore.Infrastructure.RelationalModelValidator.Validate(IModel model, IDiagnosticsLogger`1 logger)
   at Microsoft.EntityFrameworkCore.Sqlite.Infrastructure.Internal.SqliteModelValidator.Validate(IModel model, IDiagnosticsLogger`1 logger)
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelRuntimeInitializer.Initialize(IModel model, Boolean designTime, IDiagnosticsLogger`1 validationLogger)
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelSource.CreateModel(DbContext context, ModelCreationDependencies modelCreationDependencies, Boolean designTime)
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelSource.GetModel(DbContext context, ModelCreationDependencies modelCreationDependencies, Boolean designTime)
   at Microsoft.EntityFrameworkCore.Internal.DbContextServices.CreateModel(Boolean designTime)
   at Microsoft.EntityFrameworkCore.Internal.DbContextServices.get_Model()
   at Microsoft.EntityFrameworkCore.Infrastructure.EntityFrameworkServicesBuilder.<>c.<TryAddCoreServices>b__8_4(IServiceProvider p)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine.<>c__DisplayClass2_0.<RealizeService>b__0(ServiceProviderEngineScope scope)
   at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService[T](IServiceProvider provider)
   at Microsoft.EntityFrameworkCore.Infrastructure.EntityFrameworkRelationalServicesBuilder.<>c.<TryAddCoreServices>b__3_3(IServiceProvider p)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine.<>c__DisplayClass2_0.<RealizeService>b__0(ServiceProviderEngineScope scope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
   at Microsoft.EntityFrameworkCore.Infrastructure.Internal.InfrastructureExtensions.GetService(IInfrastructure`1 accessor, Type serviceType)
   at Microsoft.EntityFrameworkCore.Infrastructure.Internal.InfrastructureExtensions.GetService[TService](IInfrastructure`1 accessor)
   at Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions.GetService[TService](IInfrastructure`1 accessor)
   at Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade.get_Dependencies()
   at Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade.Microsoft.EntityFrameworkCore.Storage.IDatabaseFacadeDependenciesAccessor.get_Dependencies()
   at Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetFacadeDependencies(DatabaseFacade databaseFacade)
   at Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.CloseConnectionAsync(DatabaseFacade databaseFacade)
   at LcmCrdt.CrdtProjectsService.CreateProject(CreateProjectRequest request) in /src/backend/FwLite/LcmCrdt/CrdtProjectsService.cs:line 181
   at LcmCrdt.CrdtProjectsService.CreateProject(CreateProjectRequest request) in /src/backend/FwLite/LcmCrdt/CrdtProjectsService.cs:line 194
   at LcmCrdt.CrdtProjectsService.CreateProject(CreateProjectRequest request) in /src/backend/FwLite/LcmCrdt/CrdtProjectsService.cs:line 194
   at FwHeadless.Services.SyncWorker.SetupCrdtProject(String crdtFile, IProjectLookupService projectLookupService, Guid projectId, CrdtProjectsService projectsService, String projectFolder, Guid fwProjectId, String lexboxUrl) in /src/backend/FwHeadless/Services/SyncHostedService.cs:line 371
   at FwHeadless.Services.SyncWorker.ExecuteSync(CancellationToken stoppingToken, Boolean onlyHarmony) in /src/backend/FwHeadless/Services/SyncHostedService.cs:line 188
   at FwHeadless.Services.SyncWorker.ExecuteSync(CancellationToken stoppingToken, Boolean onlyHarmony) in /src/backend/FwHeadless/Services/SyncHostedService.cs:line 276
   at FwHeadless.Services.SyncHostedService.ExecuteAsync(CancellationToken stoppingToken) in /src/backend/FwHeadless/Services/SyncHostedService.cs:line 33

@rmunn

rmunn commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit - Look at the exception in #2300 (comment) and look at the Entity Framework Core documentation. Summarize how to map the MediaUri property, and provide links to the documentation explaining how to map properties in EF Core.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Find MediaUri type definition ==="
rg -n "class MediaUri\|struct MediaUri\|record MediaUri" --type cs -C 5

echo ""
echo "=== Find existing EF Core value converters in LcmCrdt ==="
rg -n "ValueConverter\|HasConversion\|OnModelCreating\|EntityTypeBuilder\|modelBuilder" --type cs backend/FwLite/LcmCrdt/ -C 3 | head -100

echo ""
echo "=== Find DbContext in LcmCrdt ==="
fd -e cs . backend/FwLite/LcmCrdt/ | xargs grep -l "DbContext\|OnModelCreating" 2>/dev/null

echo ""
echo "=== Check if MediaUri implements any interface or has implicit conversion ==="
rg -n "implicit\|explicit\|operator\|ToString\|Parse\|TryParse" --type cs -l | xargs grep -l "MediaUri" 2>/dev/null

Length of output: 3199


🌐 Web query:

Entity Framework Core value converters custom type mapping documentation HasConversion

Length of output: 20413


🏁 Script executed:

#!/bin/bash
echo "=== MediaUri definition - search all file types ==="
rg -rn "MediaUri" --type cs | grep -i "class\|record\|struct" | head -20

echo ""
echo "=== Find the file that defines MediaUri ==="
rg -rn "MediaUri" --type cs -l

echo ""
echo "=== Read LcmCrdtDbContext.cs ==="
cat backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs

echo ""
echo "=== Read MiniLcm MediaUri source file ==="
fd -e cs "MediaUri" | head -5

Length of output: 6757


🏁 Script executed:

#!/bin/bash
echo "=== Read MediaUri.cs ==="
cat backend/FwLite/MiniLcm/Media/MediaUri.cs

Length of output: 1864



@rmunn — Great news: LcmCrdtDbContext already has the exact pattern you need. The WritingSystemIdConverter does WritingSystemId ↔ string in the same way MediaUri needs to be handled, since MediaUri serializes to/from a plain URI string via ToString() / new MediaUri(str).


The fix

Add a converter class and register it in ConfigureConventions inside LcmCrdtDbContext:

// In LcmCrdtDbContext.ConfigureConventions:
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
    builder.Properties<MultiString>()
        .HaveColumnType("jsonb")
        .HaveConversion<MultiStringDbConverter>();
    builder.Properties<RichString?>()
        .HaveColumnType("jsonb")
        .HaveConversion<RichStringDbConverter>();
    builder.Properties<RichMultiString>()
        .HaveColumnType("jsonb")
        .HaveConversion<RichMultiStringDbConverter>();
    builder.Properties<WritingSystemId>()
        .HaveConversion<WritingSystemIdConverter>();

    // Add this:
    builder.Properties<MediaUri>()
        .HaveConversion<MediaUriConverter>();
}

// Add this converter class alongside the others:
private class MediaUriConverter() : ValueConverter<MediaUri, string>(
    uri => uri.ToString(),
    str => new MediaUri(str));

HaveConversion in ConfigureConventions is the pre-convention (global) approach — it applies the converter to every MediaUri property across all entity types automatically, so you don't need to touch OnModelCreating for each entity.


Relevant EF Core documentation

Topic Link
Value Conversions (main doc — HasConversion, ValueConverter<TModel,TProvider>, pre-convention config) https://learn.microsoft.com/en-us/ef/core/modeling/value-conversions
Pre-convention model configuration (ConfigureConventions / HaveConversion) https://learn.microsoft.com/en-us/ef/core/modeling/bulk-configuration#pre-convention-configuration
PropertyBuilder.HasConversion API reference https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.metadata.builders.propertybuilder.hasconversion
ValueConverter<TModel,TProvider> API reference https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.storage.valueconversion.valueconverter-2

After adding the converter, you'll also need to create a new EF migration (dotnet ef migrations add AddMediaUriConverter or similar) so the schema change is tracked.

@rmunn

rmunn commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit - When I run task fw-lite-web, I am unable to login to the local Lexbox server inside the FW Lite Web application. Clicking on the "log in to see projects" button in the "Lexbox Local Server" category at http://localhost:5137/ tries to go to http://localhost:5137/api/auth/login/localhost:3050, which produces an error page. The error message is Microsoft.Identity.Client.MsalServiceException: Issuer validation failed for authority: https://localhost:3050/ . Issuer from OIDC endpoint does not match any expected pattern: http://localhost:3050/.

Please analyze the port-forwarding setup for the task fw-lite-web process, and determine why it's using HTTP on port 3050 to forward to the local lexbox server, instead of HTTPS on port 3050. Suggest a solution.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Find Taskfile for fw-lite-web ==="
fd -i "Taskfile" | head -20

echo ""
echo "=== Search for fw-lite-web in Taskfiles ==="
rg -n "fw-lite-web\|fw_lite_web" --type yaml -C 5

echo ""
echo "=== Search for port 3050 references ==="
rg -rn "3050" -C 3 | head -60

echo ""
echo "=== Search for port forwarding or devtunnel setup ==="
rg -rn "devtunnel\|port.forward\|portforward\|forward.*3050\|3050.*forward" -C 5 | head -60

Length of output: 28033


🏁 Script executed:

#!/bin/bash
echo "=== Search for OIDC/auth configuration with localhost ==="
rg -rn "localhost:3050\|localhost.*3050\|3050.*localhost" -C 3 | head -80

echo ""
echo "=== Search for OidcAuthority or Authority config ==="
rg -rn "Authority\|OidcAuthority\|OpenIdConnect\|oidc" --type cs -C 3 | head -80

echo ""
echo "=== Search for auth login endpoint ==="
rg -rn "api/auth/login\|AuthController\|login.*controller" --type cs -C 5 | head -60

Length of output: 637


🏁 Script executed:

#!/bin/bash
echo "=== Root Taskfile.yml - search for fw-lite-web or related ==="
grep -n "fw-lite-web\|fw_lite_web\|fw-lite\|fwlite\|5137\|3050\|https-proxy\|devtunnel\|forward" Taskfile.yml | head -60

echo ""
echo "=== backend/FwLite/Taskfile.yml - full content ==="
cat backend/FwLite/Taskfile.yml

echo ""
echo "=== frontend/https-proxy/vite.config.ts - full content ==="
cat frontend/https-proxy/vite.config.ts

echo ""
echo "=== backend/Taskfile.yml - search for fw-lite or 3050 or 5137 ==="
grep -n "fw-lite\|3050\|5137\|https-proxy\|devtunnel\|forward" backend/Taskfile.yml | head -40

Length of output: 10948


🏁 Script executed:

#!/bin/bash
echo "=== Search for 5137 in all files ==="
rg -rn "5137" -C 2 | head -60

echo ""
echo "=== Search for lexbox server URL config in appsettings or launchSettings ==="
fd -e json "appsettings\|launchSettings" backend/FwLite/ | head -20
fd -e json "appsettings\|launchSettings" backend/FwLite/ | xargs grep -l "localhost\|lexbox\|3050\|Authority\|oidc\|auth" 2>/dev/null | head -10

echo ""
echo "=== Search for auth login in FwLite web project ==="
rg -rn "login\|auth\|oidc\|Authority" backend/FwLite/FwLiteWeb/ -C 3 | head -80

echo ""
echo "=== Find FwLiteWeb project structure ==="
fd . backend/FwLite/FwLiteWeb/ | head -40

Length of output: 14226


🏁 Script executed:

#!/bin/bash
echo "=== AuthRoutes.cs ==="
cat backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs

echo ""
echo "=== Program.cs ==="
cat backend/FwLite/FwLiteWeb/Program.cs

echo ""
echo "=== appsettings.json files in FwLiteWeb ==="
fd -e json "appsettings" backend/FwLite/FwLiteWeb/ | xargs -I{} sh -c 'echo "--- {} ---"; cat {}'

echo ""
echo "=== frontend Taskfile.yml - fw-lite-infr task ==="
grep -n "fw-lite-infr\|https-proxy\|3050\|lexbox" frontend/Taskfile.yml -A 10 | head -60

echo ""
echo "=== FwLiteWebConfig.cs ==="
cat backend/FwLite/FwLiteWeb/FwLiteWebConfig.cs

echo ""
echo "=== ServerRedirectUrlProvider.cs ==="
cat backend/FwLite/FwLiteWeb/Services/ServerRedirectUrlProvider.cs

Length of output: 9618


🏁 Script executed:

#!/bin/bash
echo "=== Find AuthService and AuthConfig ==="
fd -e cs "AuthService\|AuthConfig\|OAuthService" backend/FwLite/ | head -10

echo ""
echo "=== AuthConfig.cs ==="
fd -e cs "AuthConfig" backend/FwLite/ | xargs cat

echo ""
echo "=== AuthService.cs ==="
fd -e cs "AuthService" backend/FwLite/ | xargs cat

echo ""
echo "=== OAuthService.cs ==="
fd -e cs "OAuthService" backend/FwLite/ | xargs cat

echo ""
echo "=== UrlContext.cs ==="
cat backend/FwLite/FwLiteWeb/UrlContext.cs

echo ""
echo "=== Search for ValidateAuthority or ValidIssuers or MSAL config ==="
rg -rn "ValidateAuthority\|ValidIssuers\|PublicClientApplicationBuilder\|WithAuthority\|OidcWellKnown\|openid-configuration" --type cs -C 5 | head -80

echo ""
echo "=== Root Taskfile lines 113-120 (fw-lite-web task definition) ==="
sed -n '100,130p' Taskfile.yml

Length of output: 22755


🏁 Script executed:

#!/bin/bash
echo "=== Find OAuthClientFactory and OAuthClient ==="
fd -e cs "OAuthClient" backend/FwLite/ | head -10

echo ""
echo "=== OAuthClientFactory.cs ==="
fd -e cs "OAuthClientFactory" backend/FwLite/ | xargs cat

echo ""
echo "=== OAuthClient.cs ==="
fd -e cs -x echo {} backend/FwLite/ | xargs grep -l "OAuthClient" | grep -v "Factory\|Service\|Test" | head -5 | xargs cat

echo ""
echo "=== Search for PublicClientApplication or WithAuthority in FwLiteShared ==="
rg -rn "PublicClientApplication\|WithAuthority\|BuildApplication\|IPublicClientApplication" --type cs backend/FwLite/ -C 5 | head -80

echo ""
echo "=== Search for ValidateIssuer or authority in appsettings for the local lexbox ==="
fd -e json "appsettings" backend/ | xargs grep -l "localhost\|3050\|3000\|authority\|Authority" 2>/dev/null | head -5

echo ""
echo "=== Check if local lexbox server config has ASPNETCORE_FORWARDEDHEADERS ==="
fd -i "docker-compose" . | xargs grep -l "lexbox\|3000\|3050" 2>/dev/null | head -5
fd "docker-compose" . | head -10

Length of output: 60547



@rmunn Here's the complete analysis.


Root Cause

The data flow is:

Browser → https://localhost:3050 (vite HTTPS proxy)
                  ↓ (forwards to)
          http://localhost:3000 (local lexbox server, plain HTTP)

The chain of events that causes the mismatch:

  1. OAuthClient.cs builds the MSAL application with .WithOidcAuthority("https://localhost:3050/") — because LexboxServer.Authority is the HTTPS proxy URL.
  2. MSAL fetches the OIDC discovery document from https://localhost:3050/.well-known/openid-configuration.
  3. The vite proxy (with changeOrigin: false) forwards this to http://localhost:3000/.well-known/openid-configuration, passing the browser's Host: localhost:3050 header through unchanged.
  4. The local lexbox server (ASP.NET Core on HTTP) builds its issuer URL as: own scheme (http://) + Host header (localhost:3050) → "issuer": "http://localhost:3050/".
  5. The x-forwarded-proto: https header IS sent by the vite proxy, but the lexbox server's ForwardedHeaders middleware is not configured to honor it in development.
  6. protocolRewrite: 'https' in the vite config only rewrites Location headers in redirects — it does not rewrite JSON response bodies. The OIDC discovery JSON is untouched.
  7. MSAL validates authority https://localhost:3050/ against issuer http://localhost:3050/mismatch → exception.

Fix Options

Option A — Fix in the vite proxy (self-contained, no server changes needed)

Intercept the OIDC discovery response and rewrite http://https:// in the JSON body. Add this to the configure callback in frontend/https-proxy/vite.config.ts:

import { gunzipSync } from 'zlib';

// Inside the lexboxServer ProxyOptions configure callback, after existing code:
proxy.on('proxyRes', (proxyRes, req, res) => {
    if (!req.url?.includes('openid-configuration')) return;

    const chunks: Buffer[] = [];
    const originalWrite = res.write.bind(res);
    const originalEnd = res.end.bind(res);

    // Intercept the streamed response
    proxyRes.on('data', (chunk: Buffer) => chunks.push(chunk));
    proxyRes.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf-8');
        const rewritten = body.replaceAll(
            /"issuer"\s*:\s*"http:\/\//g,
            '"issuer":"https://'
        );
        const newBody = Buffer.from(rewritten, 'utf-8');
        res.setHeader('Content-Length', newBody.length);
        originalWrite(newBody);
        originalEnd();
    });
});

Note: Vite uses http-proxy under the hood; you may need to set selfHandleResponse: true on the proxy options to take full control of the response stream, or use the simpler approach of hooking into proxyRes before headers are flushed.

A simpler alternative using vite's built-in transformer hook (if supported by your vite version):

// In vite.config.ts server.proxy options:
'/': {
    ...lexboxServer,
    // Add a response rewriter for OIDC discovery only
    rewrite: (path) => path,
}

Option B — Fix in the local lexbox server (correct architectural fix)

Enable ForwardedHeaders middleware in the lexbox API so it honors X-Forwarded-Proto: https. The easiest way in development is to set an environment variable in the dev docker-compose or k8s manifest:

environment:
  ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"

This makes ASP.NET Core automatically enable forwarded headers, so the lexbox server generates https://localhost:3050/ as the OIDC issuer when it receives X-Forwarded-Proto: https.

Or in backend/LexBoxApi/appsettings.Development.json:

{
  "ForwardedHeaders": {
    "ForwardedHeaders": "XForwardedFor, XForwardedProto"
  }
}

Option C — Fix in OAuthClient.cs (pragmatic dev workaround)

For localhost authorities specifically, skip MSAL's authority validation:

var builder = PublicClientApplicationBuilder.Create(options.Value.ClientId)
    .WithExperimentalFeatures()
    .WithLogging(loggerAdapter, hostEnvironment?.IsDevelopment() ?? false)
    .WithHttpClientFactory(new HttpClientFactoryAdapter(httpMessageHandlerFactory))
    .WithParentActivityOrWindow(() => options.Value.ParentActivityOrWindow)
    .WithOidcAuthority(lexboxServer.Authority.ToString());

// For localhost dev servers, the HTTPS proxy wraps HTTP, so the issuer will be http:// 
// even though the authority is https://. Skip validation in this case.
if (lexboxServer.Authority.IsLoopback)
    builder.WithValidateAuthority(false);

Recommendation

Option B is the most architecturally correct fix — the lexbox server should honor X-Forwarded-Proto, especially since the vite proxy already sends it. Setting ASPNETCORE_FORWARDEDHEADERS_ENABLED=true in the dev environment is a one-line change with no code modifications.

Option A is useful if you can't or don't want to touch the lexbox server dev config, since the vite proxy was designed specifically to handle this HTTPS wrapping concern.

Option C is a quick local workaround but slightly weakens MSAL's validation for all localhost servers, not just this specific scenario.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

C# FwHeadless Unit Tests

48 tests   48 ✅  17s ⏱️
 5 suites   0 💤
 1 files     0 ❌

Results for commit 759a337.

♻️ This comment has been updated with latest results.

@rmunn

rmunn commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit - Syncing pictures using commit 3a729a7 produced the following exception:

System.NotSupportedException: type JsonPatchChange`1 does not support NewEntity, because it inherits from EditChange, this means it must be called with a from an existing entity, not a newly generated one

at SIL.Harmony.Changes.EditChange`1.NewEntity(Commit commit, IChangeContext context) in backend/harmony/src/SIL.Harmony/Changes/EditChange.cs:line 8
   at SIL.Harmony.Changes.Change`1.SIL.Harmony.Changes.IChange.NewEntity(Commit commit, IChangeContext context) in backend/harmony/src/SIL.Harmony/Changes/Change.cs:line 51
   at SIL.Harmony.SnapshotWorker.ApplyCommitChanges(SortedSet`1 commits) in backend/harmony/src/SIL.Harmony/SnapshotWorker.cs:line 78
   at SIL.Harmony.SnapshotWorker.UpdateSnapshots(SortedSet`1 commits) in backend/harmony/src/SIL.Harmony/SnapshotWorker.cs:line 54
   at SIL.Harmony.DataModel.UpdateSnapshots(CrdtRepository repo, SortedSet`1 commitsToApply) in backend/harmony/src/SIL.Harmony/DataModel.cs:line 217
   at SIL.Harmony.DataModel.SIL.Harmony.ISyncable.AddRangeFromSync(IEnumerable`1 commits) in backend/harmony/src/SIL.Harmony/DataModel.cs:line 159
   at SIL.Harmony.DataModel.SIL.Harmony.ISyncable.AddRangeFromSync(IEnumerable`1 commits) in backend/harmony/src/SIL.Harmony/DataModel.cs:line 161
   at SIL.Harmony.DataModel.SIL.Harmony.ISyncable.AddRangeFromSync(IEnumerable`1 commits) in backend/harmony/src/SIL.Harmony/DataModel.cs:line 161
   at SIL.Harmony.SyncHelper.SyncWith(ISyncable localModel, ISyncable remoteModel, JsonSerializerOptions serializerOptions) in backend/harmony/src/SIL.Harmony/SyncHelper.cs:line 42
   at SIL.Harmony.DataModel.SyncWith(ISyncable remoteModel) in backend/harmony/src/SIL.Harmony/DataModel.cs:line 377
   at FwLiteShared.Sync.SyncService.ExecuteSync(Boolean skipNotifications) in backend/FwLite/FwLiteShared/Sync/SyncService.cs:line 89
   at FwLiteShared.Projects.CombinedProjectsService.<>c.<<DownloadProject>b__16_1>d.MoveNext() in backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs:line 202
--- End of stack trace from previous location ---
   at LcmCrdt.CrdtProjectsService.CreateProject(CreateProjectRequest request) in backend/FwLite/LcmCrdt/CrdtProjectsService.cs:line 175
   at LcmCrdt.CrdtProjectsService.CreateProject(CreateProjectRequest request) in backend/FwLite/LcmCrdt/CrdtProjectsService.cs:line 191
   at LcmCrdt.CrdtProjectsService.CreateProject(CreateProjectRequest request) in backend/FwLite/LcmCrdt/CrdtProjectsService.cs:line 194
   at LcmCrdt.CrdtProjectsService.CreateProject(CreateProjectRequest request) in backend/FwLite/LcmCrdt/CrdtProjectsService.cs:line 194
   at FwLiteShared.Projects.CombinedProjectsService.<>c__DisplayClass16_0.<<DownloadProject>b__0>d.MoveNext() in backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs:line 196
--- End of stack trace from previous location ---
   at FwLiteShared.Projects.CombinedProjectsService.DownloadProject(ProjectModel project) in backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs:line 196

If possible, analyse why NewEntity is being called on a JsonPatchChange<Picture> instead of a CreatePictureChange being used. Summarise the reason and suggest a solution.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== PictureSync.cs ==="
fd -e cs "PictureSync" | xargs cat

echo ""
echo "=== CreatePictureChange.cs ==="
fd -e cs "CreatePictureChange" | xargs cat

echo ""
echo "=== Search for where pictures are synced from FW to CRDT ==="
rg -n "CreatePicture\|AddPicture\|picture.*sync\|sync.*picture" --type cs -i backend/FwLite/ -C 3 | head -80

echo ""
echo "=== How other entity syncs look - e.g. EntrySync ==="
fd -e cs "EntrySync" backend/FwLite/ | xargs cat 2>/dev/null | head -100

echo ""
echo "=== How other entity syncs look - e.g. SenseSync ==="
fd -e cs "SenseSync" backend/FwLite/ | xargs cat 2>/dev/null | head -100

Length of output: 10820


🏁 Script executed:

#!/bin/bash
echo "=== Find CrdtMiniLcmApi picture-related methods ==="
fd -e cs "CrdtMiniLcmApi" backend/FwLite/ | xargs grep -n "Picture\|picture" | head -60

echo ""
echo "=== Find CreatePicture and UpdatePicture implementations in LcmCrdt ==="
rg -n "CreatePicture\|UpdatePicture\|DeletePicture\|MovePicture" --type cs backend/FwLite/LcmCrdt/ -C 5

echo ""
echo "=== Find how JsonPatchChange is created/used ==="
rg -n "JsonPatchChange\b" --type cs backend/FwLite/ -C 3

echo ""
echo "=== CrdtFwdataProjectSyncService - picture related sync code ==="
rg -n "Picture\|picture" --type cs backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs -C 3

echo ""
echo "=== Check UpdatePicture in LcmCrdt and what change type it creates ==="
rg -n "async.*UpdatePicture\|Task.*UpdatePicture" --type cs backend/FwLite/ -C 10

Length of output: 14639



@rmunn — Here's the analysis.

Root cause

CrdtMiniLcmApi has two UpdatePicture overloads:

// Overload 1 — takes an UpdateObjectInput, creates JsonPatchChange<Picture>
public async Task<Picture> UpdatePicture(Guid entryId, Guid senseId, Guid pictureId, UpdateObjectInput<Picture> update)
{
    var patchChange = new JsonPatchChange<Picture>(pictureId, jsonPatch);  // ← EditChange, no NewEntity support
    await AddChange(patchChange);
    ...
}

// Overload 2 — takes before/after Pictures, calls PictureSync.Sync (single-picture version)
public async Task<Picture> UpdatePicture(Guid entryId, Guid senseId, Picture before, Picture after, ...)
{
    await PictureSync.Sync(entryId, senseId, before, after, api ?? this);  // ← ONLY calls UpdatePicture, NEVER CreatePicture
    ...
}

The single-picture PictureSync.Sync overload only ever calls api.UpdatePicture(...). It never calls api.CreatePicture. So if this overload is invoked for a picture that doesn't yet exist in the CRDT, it commits a JsonPatchChange<Picture> for an entity that has no CreatePictureChange — and when Harmony's SnapshotWorker tries to apply it, it calls NewEntity on the JsonPatchChange, which throws.

The sync code on the FW headless server is taking a path that calls the "before/after Picture" UpdatePicture overload (or uses PictureSync.Sync single-picture overload) for pictures that are new to the CRDT — treating them as updates rather than creates.


Where to look

The FW headless sync must have new code (in this PR) in either SenseSync.cs or CrdtFwdataProjectSyncService.cs that processes pictures. The bug will be that for senses containing pictures, instead of going through the collection-based PictureSync.Sync:

// CORRECT — uses DiffOrderable, calls CreatePicture for new pictures
await PictureSync.Sync(entryId, senseId, beforeSense.Pictures, afterSense.Pictures, api);

…it's going through something like:

// WRONG — single-picture overload, always emits JsonPatchChange, never CreatePicture
foreach (var (before, after) in matchedPictures)
    await api.UpdatePicture(entryId, senseId, before, after);
// or
await PictureSync.Sync(entryId, senseId, beforePicture, afterPicture, api);

Suggested fix

Option A — In SenseSync.Sync, add the collection-based picture sync (the same pattern already used for ExampleSentenceSync):

// In SenseSync.Sync, alongside the ExampleSentenceSync call:
changes += await PictureSync.Sync(entryId,
    beforeSense.Id,
    beforeSense.Pictures,
    afterSense.Pictures,
    api);

Option B — If the FW sync has a custom loop in CrdtFwdataProjectSyncService, replace any per-picture UpdatePicture call with a call to the collection overload of PictureSync.Sync, so DiffOrderable can correctly route to CreatePicture (Add) vs UpdatePicture (Replace).

The key invariant to enforce: api.CreatePicture must be called for any picture that doesn't yet exist in the CRDT, and api.UpdatePicture must only be called for pictures that already have a CreatePictureChange in the commit log.


🧠 Learnings used
Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-06-03T12:11:28.780Z
Learning: Applies to backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs : In FwLiteProjectSync/CrdtFwdataProjectSyncService.cs, regenerate ProjectSnapshot from CRDT after sync (see issue `#1912`)

Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 2192
File: backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs:125-127
Timestamp: 2026-03-11T04:58:21.676Z
Learning: In sillsdev/languageforge-lexbox backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs SyncInternal, the two-pass sync pattern is intentional and lossless: pass 1 (e.g., line 126 for MorphTypeData) updates the live CRDT from FW data using the project snapshot as "before"; pass 2 (e.g., line 127) reads the *already-updated* live CRDT and pushes it to FW. This means a null value in the CRDT snapshot cannot destructively wipe an FW value — it will be reconciled to the FW value in pass 1 before pass 2 ever runs. A null reaching FW in pass 2 only happens when the user intentionally deleted it in CRDT, which is correct bidirectional sync behavior.

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-06-03T12:11:28.780Z
Learning: Applies to backend/FwLite/FwLiteProjectSync/*.cs : Explicitly call fwdataApi.Save() after applying changes to FwData in sync operations

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwHeadless/AGENTS.md:0-0
Timestamp: 2025-12-05T10:28:05.034Z
Learning: Compare CRDT and FwData states manually when debugging sync failures; if ProjectSnapshot is incorrect, sync results will be incorrect

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwHeadless/AGENTS.md:0-0
Timestamp: 2025-12-05T10:28:05.034Z
Learning: The most dangerous sync logic is located in `backend/FwLite/FwLiteProjectSync/` with the core algorithm in `CrdtFwdataProjectSyncService.cs`; refer to `backend/FwLite/AGENTS.md` for detailed sync documentation

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-06-03T12:11:28.780Z
Learning: Applies to backend/FwLite/FwLiteProjectSync.Tests/**/*Tests.cs : Add sync round-trip tests in FwLiteProjectSync.Tests/ for new sync features to verify bidirectional consistency

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-06-03T12:11:28.780Z
Learning: Applies to backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs : Use two-phase sync for complex forms in FwLiteProjectSync: SyncWithoutComplexFormsAndComponents first, then SyncComplexFormsAndComponents

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwHeadless/AGENTS.md:0-0
Timestamp: 2025-12-05T10:28:05.034Z
Learning: Applies to backend/FwHeadless/Services/CrdtSyncService.cs : Always check `syncResults.IsSynced`, `syncResults.MissingFromLocal`, and `syncResults.MissingFromRemote` after calling `dataModel.SyncWith()` in CrdtSyncService; if sync fails silently, users lose data

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-06-03T12:11:28.780Z
Learning: Applies to backend/FwLite/LcmCrdt/Changes/*.cs : Profile CRDT change classes with realistic data (1000+ entries) before committing if adding complex operations

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-06-03T12:11:28.780Z
Learning: Applies to backend/FwLite/LcmCrdt/Changes/*.cs : In Harmony Change classes, check if referenced entities are deleted (DeletedAt is not null) before processing

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-06-03T12:11:28.780Z
Learning: Applies to backend/FwLite/LcmCrdt/Changes/*.cs : For new simple field changes in CRDT, use JsonPatchChange<T> instead of creating dedicated change classes

Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: backend/FwLite/AGENTS.md:0-0
Timestamp: 2026-06-03T12:11:28.780Z
Learning: Applies to backend/FwLite/MiniLcm.Tests/**/*Tests.cs : Add test cases in MiniLcm.Tests/ for base model behavior when adding new model features

rmunn added 3 commits June 11, 2026 13:58
Compiles, but some logic (most notably MediaUri handling) is incomplete
Next up will be writing tests to make sure we can properly two-way sync
a picture from FW Classic, i.e. that the MediaUri handling is correct.
@rmunn

rmunn commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on top of current develop; hopefully this will let me test syncing now.

@rmunn

rmunn commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Huh. Look commit 2ce7ec8 and, specifically, at the DB migration. Why did it add the HomographNumber property? I might have had a local DB that's not up-to-date. I might have to delete that migration and create a new one.

@rmunn

rmunn commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Force-pushed to get rid of commit 2ce7ec8 so the migration won't add a duplicate HomographNumber column.

rmunn added 3 commits June 11, 2026 15:53
MiniLcmApiWriteNormalizationWrapper is new on develop branch since I
started this PR, so I didn't have the Picture CRUD API calls for it.
@rmunn

rmunn commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit - Look at the latest commit. When I sync a project that has pictures in the .fwdata file, the CRDT version of the project comes out with pictures = [] on the Sense object. Analyze the reasons for this and suggest a solution.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Latest commit info ==="
git log --oneline -5

echo ""
echo "=== SenseSync.cs ==="
fd -e cs "SenseSync" backend/FwLite/ | xargs cat

echo ""
echo "=== PictureSync.cs ==="
fd -e cs "PictureSync" backend/FwLite/ | xargs cat

echo ""
echo "=== CrdtFwdataProjectSyncService.cs - picture-related lines ==="
rg -n "Picture\|picture" --type cs backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs -C 3

echo ""
echo "=== FwDataMiniLcmApi.cs - GetPictures / picture mapping ==="
rg -n "GetPictures\|GetSensePictures\|Pictures\|FromLcmPicture" --type cs backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs -C 3

echo ""
echo "=== CrdtMiniLcmApi.cs - GetPictures / picture ==="
rg -n "GetPictures\|Pictures\|Picture" --type cs backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs -C 3 | head -80

Length of output: 6711


🏁 Script executed:

#!/bin/bash
echo "=== FwDataMiniLcmApi - Sense mapping (ToMiniLcmSense / LcmToSense) ==="
rg -n "ToMiniLcmSense\|LcmToSense\|FromLcmSense\|Sense\b.*=>.*new Sense\|new Sense\b" \
  --type cs backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs -C 8

echo ""
echo "=== FwDataMiniLcmApi - all lines mentioning Sense OR Picture (case insensitive) ==="
grep -in "picture\|\.Pictures\b\|GetPictures" \
  backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | head -40

echo ""
echo "=== CrdtMiniLcmApi - how Sense is returned / Pictures loaded ==="
grep -n "Picture\|\.Pictures\b\|GetPictures\|IncludePictures\|Include.*Picture" \
  backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | head -40

echo ""
echo "=== CrdtFwdataProjectSyncService.cs - Sense sync block (search for SenseSync) ==="
grep -n "SenseSync\|GetSenses\|GetEntries\|beforeSense\|afterSense\|Sense\b" \
  backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs | head -60

echo ""
echo "=== Check if IMiniLcmApi interface has GetPictures ==="
rg -n "GetPictures\|Pictures\b" --type cs backend/FwLite/MiniLcm/IMiniLcmApi.cs -C 2

echo ""
echo "=== Check Sense model - does it include Pictures ==="
rg -n "Pictures\b" --type cs backend/FwLite/MiniLcm/Models/Sense.cs -C 3

Length of output: 7429


🏁 Script executed:

#!/bin/bash
echo "=== FwDataMiniLcmApi - find the sense→MiniLcm mapping (lines around FromLcmSense/ToSense) ==="
grep -n "Sense\|sense\b" backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | \
  grep -iv "ExampleSentence\|HasSense\|#" | head -60

echo ""
echo "=== FwDataMiniLcmApi - lines 780-850 (around FromLcmPicture / sense mapping) ==="
sed -n '780,860p' backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs

echo ""
echo "=== FwDataMiniLcmApi - search for ILexSense or PicturesOS usage ==="
grep -n "PicturesOS\|ILexSense\|lexSense\.\|new Sense\b\|return new Sense" \
  backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | head -40

echo ""
echo "=== CrdtFwdataProjectSyncService.cs - full content ==="
cat backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs

echo ""
echo "=== CrdtMiniLcmApi - how Sense is loaded (with Pictures EF include) ==="
grep -n "Include\|Sense\b\|sense\b" backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | \
  grep -i "include\|query\|dbset\|context\|from\|where\|join" | head -30

Length of output: 21784



@rmunn — Found it. The root cause is a single missing line in FwDataMiniLcmApi.cs.

Root Cause

FromLexSense (Line 777) builds a Sense from an LCM ILexSense and correctly populates ExampleSentences from sense.ExamplesOS, but does not populate Pictures from sense.PicturesOS:

// FwDataMiniLcmApi.cs - FromLexSense (lines 780–791)
var s = new Sense
{
    Id = sense.Guid,
    EntryId = sense.Entry.Guid,
    Gloss = FromLcmMultiString(sense.Gloss),
    Definition = FromLcmMultiString(sense.Definition),
    PartOfSpeech = pos is null ? null : FromLcmPartOfSpeech(pos),
    PartOfSpeechId = pos?.Guid,
    SemanticDomains = [.. sense.SemanticDomainsRC.Select(FromLcmSemanticDomain)],
    ExampleSentences = [.. sense.ExamplesOS.Select(sentence => FromLexExampleSentence(sense.Guid, sentence))]
    // ← Pictures missing here!
};

This means every Sense returned by GetAllEntries() has Pictures = []. When SyncInternal runs:

EntrySync.SyncFull(projectSnapshot.Entries, currentFwDataEntries, crdtApi)
                                              ^^^^ Pictures = [] on all senses

SenseSync.SyncPictureSync.Sync receives before=[], after=[] → no diff → no CreatePicture calls → CRDT stays empty.


The Fix

Add one line to FromLexSense in FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs:

var s = new Sense
{
    Id = sense.Guid,
    EntryId = sense.Entry.Guid,
    Gloss = FromLcmMultiString(sense.Gloss),
    Definition = FromLcmMultiString(sense.Definition),
    PartOfSpeech = pos is null ? null : FromLcmPartOfSpeech(pos),
    PartOfSpeechId = pos?.Guid,
    SemanticDomains = [.. sense.SemanticDomainsRC.Select(FromLcmSemanticDomain)],
    ExampleSentences = [.. sense.ExamplesOS.Select(sentence => FromLexExampleSentence(sense.Guid, sentence))],
    Pictures = [.. sense.PicturesOS.Select(pic => FromLcmPicture(sense.Guid, pic))]   // ← add this
};

This mirrors the exact pattern already used for ExampleSentences/ExamplesOS and will make both the import path (via MiniLcmImport) and the sync path (via EntrySync.SyncFull) correctly carry picture data through to the CRDT.

@rmunn

rmunn commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit - I added the necessary line to FromLexSense. Now when syncing pictures, there's the following error:

System.ArgumentException: Entity type 'Picture' is defined with a 2-part composite key, but 1 values were passed to the 'Find' method.
  at Microsoft.EntityFrameworkCore.Internal.EntityFinder`1.Microsoft.EntityFrameworkCore.Internal.IEntityFinder.FindAsync(Object[] keyValues, CancellationToken cancellationToken)
  at SIL.Harmony.Db.CrdtRepository.GetEntityEntry(Type entityType, Guid entityId) in /src/backend/harmony/src/SIL.Harmony/Db/CrdtRepository.cs:line 372
  at SIL.Harmony.Db.CrdtRepository.ProjectSnapshot(ObjectSnapshot objectSnapshot) in /src/backend/harmony/src/SIL.Harmony/Db/CrdtRepository.cs:line 341
  at SIL.Harmony.Db.CrdtRepository.AddSnapshots(IEnumerable`1 snapshots) in /src/backend/harmony/src/SIL.Harmony/Db/CrdtRepository.cs:line 319
  at SIL.Harmony.SnapshotWorker.UpdateSnapshots(SortedSet`1 commits) in /src/backend/harmony/src/SIL.Harmony/SnapshotWorker.cs:line 55
  at SIL.Harmony.DataModel.UpdateSnapshots(CrdtRepository repo, SortedSet`1 commitsToApply) in /src/backend/harmony/src/SIL.Harmony/DataModel.cs:line 217
  at SIL.Harmony.DataModel.AddManyChanges(Guid clientId, IEnumerable`1 changes, Func`1 commitMetadata, Int32 changesPerCommitMax) in /src/backend/harmony/src/SIL.Harmony/DataModel.cs:line 85
  at SIL.Harmony.DataModel.AddManyChanges(Guid clientId, IEnumerable`1 changes, Func`1 commitMetadata, Int32 changesPerCommitMax) in /src/backend/harmony/src/SIL.Harmony/DataModel.cs:line 87
  at SIL.Harmony.DataModel.AddManyChanges(Guid clientId, IEnumerable`1 changes, Func`1 commitMetadata, Int32 changesPerCommitMax) in /src/backend/harmony/src/SIL.Harmony/DataModel.cs:line 87
  at LcmCrdt.CrdtMiniLcmApi.AddChanges(IEnumerable`1 changes)
  at LcmCrdt.CrdtMiniLcmApi.BulkCreateEntries(IAsyncEnumerable`1 entries)
  at LcmCrdt.CrdtMiniLcmApi.BulkCreateEntries(IAsyncEnumerable`1 entries)
  at LcmCrdt.CrdtMiniLcmApi.BulkCreateEntries(IAsyncEnumerable`1 entries)
  at FwLiteProjectSync.Import.ResumableImportApi.MiniLcm.IMiniLcmWriteApi.BulkCreateEntries(IAsyncEnumerable`1 entries) in /src/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs:line 103
  at FwLiteProjectSync.MiniLcmImport.ImportProject(IMiniLcmApi importTo, IMiniLcmApi importFrom, Int32 entryCount) in /src/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs:line 83
  at FwLiteProjectSync.CrdtFwdataProjectSyncService.ImportInternal(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi, Int32 entryCount) in /src/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs:line 111
  at FwLiteProjectSync.CrdtFwdataProjectSyncService.SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi, Boolean dryRun, ProjectSnapshot projectSnapshot) in /src/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs:line 93
  at FwLiteProjectSync.CrdtFwdataProjectSyncService.Import(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataApi, Boolean dryRun) in /src/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs:line 39
  at FwHeadless.Services.SyncWorker.ExecuteSync(CancellationToken stoppingToken, Boolean onlyHarmony) in /src/backend/FwHeadless/Services/SyncHostedService.cs:line 214
  at FwHeadless.Services.SyncWorker.ExecuteSync(CancellationToken stoppingToken, Boolean onlyHarmony) in /src/backend/FwHeadless/Services/SyncHostedService.cs:line 276
  at FwHeadless.Services.SyncHostedService.ExecuteAsync(CancellationToken stoppingToken) in /src/backend/FwHeadless/Services/SyncHostedService.cs:line 33

Analyze the cause of the error and recommend a fix.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== LcmCrdtDbContext - Picture and ExampleSentence configuration ==="
grep -n "Picture\|ExampleSentence\|HasKey\|composite\|OrderedWith\|IOrderableCrdt\|IObjectBase" \
  backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs | head -80

echo ""
echo "=== Full LcmCrdtDbContext OnModelCreating ==="
cat backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs

echo ""
echo "=== Picture model definition ==="
fd -e cs "Picture" backend/FwLite/MiniLcm/ | xargs cat 2>/dev/null
fd -e cs "Picture" backend/FwLite/LcmCrdt/ | xargs cat 2>/dev/null

echo ""
echo "=== ExampleSentence model definition (for comparison) ==="
fd -e cs "ExampleSentence" backend/FwLite/MiniLcm/Models/ | xargs cat 2>/dev/null
fd -e cs "ExampleSentence" backend/FwLite/LcmCrdt/Objects/ | xargs cat 2>/dev/null

echo ""
echo "=== Harmony IOrderableCrdt / IObjectBase ==="
fd -e cs "IObjectBase\|IOrderable\|ObjectBase" backend/harmony/ | xargs grep -l "HasKey\|IObjectBase\|IOrderable" 2>/dev/null | head -5 | xargs cat 2>/dev/null | head -80

Length of output: 45402


🏁 Script executed:

Length of output: 91



Here are the results from shell you requested:

Shell block treated as final reply content because the matching action tag was not present.

@rmunn

rmunn commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit - Try again. Your last attempt at analyzing the error message ran into an internal error and went nowhere. Focus on the error message "Entity type 'Picture' is defined with a 2-part composite key, but 1 values were passed to the 'Find' method." Why does EF Core think that Picture has a 2-part composite key?

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== LcmCrdtDbContext - OnModelCreating and ConfigureConventions ==="
cat backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs

echo ""
echo "=== Picture model in MiniLcm ==="
fd -e cs "Picture" backend/FwLite/MiniLcm/Models/ | xargs cat 2>/dev/null

echo ""
echo "=== Picture object in LcmCrdt ==="
fd -e cs "Picture" backend/FwLite/LcmCrdt/Objects/ | xargs cat 2>/dev/null

echo ""
echo "=== ExampleSentence model in MiniLcm (comparison) ==="
fd -e cs "ExampleSentence" backend/FwLite/MiniLcm/Models/ | xargs cat 2>/dev/null

echo ""
echo "=== IOrderableCrdt / IOrderable in Harmony or LcmCrdt ==="
rg -n "IOrderableCrdt\|IOrderable\b" --type cs backend/FwLite/ -C 3 | head -60

echo ""
echo "=== How Harmony configures composite keys for orderable entities ==="
rg -n "HasKey\|composite\|OrderedWith\|IOrderableCrdt\|\.Order\b" --type cs backend/harmony/src/ -C 3 | head -80

Length of output: 9678



@rmunn — Found it. There are two conflicting EF Core configurations for Picture.

Root Cause

In OnModelCreating, Picture is configured as an EF Core owned entity of Sense:

senseModel.OwnsMany(s => s.Pictures, pic => { pic.ToJson(); });

When you use OwnsMany, EF Core automatically creates a composite primary key for the owned type: [Sense.Id, Picture.Id]. That's the 2-part key.

Meanwhile, CreatePictureChange registers Picture as a top-level Harmony CRDT entity. When Harmony's CrdtRepository.GetEntityEntry calls dbContext.Find(typeof(Picture), singleGuid), EF Core rejects it because it needs 2 key values but only received 1.

There's also a second conflict: ConfigureConventions has a PictureDbConverter treating Picture as a scalar JSON value — directly contradicting the OwnsMany owned-entity configuration.

The Fix

Picture should be structured exactly like ExampleSentence: a standalone top-level CRDT entity with a SenseId foreign key — not an owned type.

1. MiniLcm/Models/Picture.cs — uncomment SenseId and implement it properly:

public class Picture : IObjectWithId<Picture>, IOrderable
{
    public virtual Guid Id { get; set; }
    public double Order { get; set; }
    public virtual Guid SenseId { get; set; }   // ← uncomment this
    public virtual MediaUri MediaUri { get; set; }
    public virtual RichMultiString Caption { get; set; } = [];
    public DateTimeOffset? DeletedAt { get; set; }

    public Guid[] GetReferences() => [SenseId];  // ← fix this

    public void RemoveReference(Guid id, DateTimeOffset time)
    {
        if (id == SenseId) DeletedAt = time;     // ← fix this
    }
    // ...
}

2. LcmCrdt/LcmCrdtDbContext.cs — remove the two conflicting Picture configurations and add a proper DbSet:

// Add this alongside the other IQueryable properties:
public IQueryable<Picture> Pictures => Set<Picture>().AsNoTracking();

// In OnModelCreating — REMOVE this line:
// senseModel.OwnsMany(s => s.Pictures, pic => { pic.ToJson(); });

// In ConfigureConventions — REMOVE these lines:
// builder.Properties<Picture>()
//     .HaveColumnType("jsonb")
//     .HaveConversion<PictureDbConverter>();

// And REMOVE the PictureDbConverter class entirely.

3. LcmCrdt/Changes/CreatePictureChange.cs — make sure SenseId is stored and used:

public Guid SenseId { get; init; }

public CreatePictureChange(Picture picture, Guid senseId) : base(...)
{
    SenseId = senseId;
    // ...
}

public override async ValueTask<Picture> NewEntity(Commit commit, IChangeContext context)
{
    return new Picture
    {
        Id = EntityId,
        SenseId = SenseId,   // ← must be set
        Order = Order,
        Caption = Caption ?? new(),
        MediaUri = MediaUri,
    };
}

4. Add a new EF migration after making these changes:

dotnet ef migrations add AddPictureAsTopLevelEntity

Why ExampleSentence works and Picture doesn't: ExampleSentence has its own SenseId property and is a top-level entity with no OwnsMany. The Picture was accidentally set up as an owned entity (which EF Core stores as JSON inside the Senses table), but then Harmony also tried to treat it as a first-class entity — those two models are irreconcilable.

@rmunn

rmunn commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@hahn-kev - Could you look at what Code Rabbit just said in #2300 (comment)? I'd like to hear your opinion. We had decided to make Pictures a JSON field off of Senses, but Code Rabbit seems to think that that won't work. I think it can, but perhaps we need to add the SenseId field into Pictures (which I had decided not to add since a JSON field on the sense itself doesn't need to store the GUID of the thing it's already attached to) in order to reconcile EF Core's view of the world with Harmony's.

But that's only if Code Rabbit is right here. What's your take? Do I need to make Pictures a separate DB entity since we have a CRDT CRUD API for them?

rmunn added 3 commits June 12, 2026 11:47
One missing line was responsible for the `Pictures = []` results we got.
Since Harmony calls `dbContext.Find(typeof(Picture), picId)`, EF Core
needs Pictures to be its own table. So we can't make it a JSON column
after all, and it will need SenseId as a foreign key.
@rmunn

rmunn commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit - When a sync is run, pictures from FwData are not getting into CRDT, and the CRDT Sense object always has Pictures as an empty list. Furthermore, the empty Pictures list from CRDT is being synced into FwData, resulting in the pictures from FwData being deleted. Examine the sync code and determine the cause, then explain what changes would be needed so that pictures from FwData can get into CRDT.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Latest git log ==="
git log --oneline -5

echo ""
echo "=== LcmCrdtDbContext.cs ==="
cat backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs

echo ""
echo "=== Picture model in MiniLcm ==="
fd -e cs "Picture.cs" backend/FwLite/MiniLcm/ | xargs cat 2>/dev/null

echo ""
echo "=== Picture object in LcmCrdt/Objects ==="
fd -e cs "Picture.cs" backend/FwLite/LcmCrdt/ | xargs cat 2>/dev/null

echo ""
echo "=== CreatePictureChange.cs ==="
fd -e cs "CreatePictureChange" backend/FwLite/ | xargs cat 2>/dev/null

echo ""
echo "=== CrdtMiniLcmApi.cs - GetSenses/GetEntries/Pictures ==="
grep -n "Picture\|GetSense\|GetEntry\|GetAll\|Include\|OwnsMany\|OwnsOne" \
  backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | head -80

echo ""
echo "=== SenseSync.cs ==="
fd -e cs "SenseSync" backend/FwLite/ | xargs cat 2>/dev/null

echo ""
echo "=== PictureSync.cs ==="
fd -e cs "PictureSync" backend/FwLite/ | xargs cat 2>/dev/null

echo ""
echo "=== FwDataMiniLcmApi.cs - FromLexSense / PicturesOS ==="
grep -n "PicturesOS\|FromLcmPicture\|Pictures\b" \
  backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | head -40

Length of output: 19597


🏁 Script executed:

#!/bin/bash
echo "=== CrdtMiniLcmApi.cs - DeletePicture, MovePicture, UpdatePicture full context ==="
sed -n '860,915p' backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs

echo ""
echo "=== CrdtMiniLcmApi.cs - BulkCreateEntries / AddChanges for Pictures ==="
sed -n '490,520p' backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs

echo ""
echo "=== CrdtConfig / registered change types for Picture ==="
fd -e cs "CrdtConfig\|LcmCrdtKernel\|AddCrdt\|RegisterChanges" backend/FwLite/LcmCrdt/ | head -5 | xargs cat 2>/dev/null

echo ""
echo "=== Search for all CRDT change registrations ==="
rg -n "RegisterChange\|AddChange.*type\|ISelfNamedType\|typeof.*Change\b" --type cs backend/FwLite/LcmCrdt/ | grep -v "//\|Test\|spec" | head -60

echo ""
echo "=== How Harmony handles OwnsMany with snapshots - look at CrdtRepository ProjectSnapshot ==="
fd -e cs "CrdtRepository" backend/harmony/ | xargs grep -n "ProjectSnapshot\|OwnsMany\|OwnedNavigation\|Find\b\|EntityEntry" 2>/dev/null | head -40

echo ""
echo "=== ProjectSnapshot in LcmCrdt ==="
fd -e cs "ProjectSnapshot" backend/FwLite/ | xargs cat 2>/dev/null | head -120

echo ""
echo "=== CrdtFwdataProjectSyncService.cs - how projectSnapshot is built / how Senses are read for sync ==="
cat backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs

Length of output: 18669


🏁 Script executed:

#!/bin/bash
echo "=== Harmony CrdtRepository.cs - full file ==="
cat backend/harmony/src/SIL.Harmony/Db/CrdtRepository.cs

echo ""
echo "=== Harmony SnapshotWorker.cs - full file ==="
cat backend/harmony/src/SIL.Harmony/SnapshotWorker.cs

echo ""
echo "=== Search for how Harmony entity types are registered ==="
rg -n "IObjectBase\|AddCrdt\|UseCrdt\|object.*type\|entityType\|RegisterEntity" --type cs backend/FwLite/LcmCrdt/ -C 3 | head -60

echo ""
echo "=== LcmCrdt - LcmCrdtKernel / ServiceExtensions / CrdtSetup ==="
fd -e cs . backend/FwLite/LcmCrdt/ | xargs grep -l "UseCrdt\|AddCrdt\|CrdtConfig\|RegisterChange" 2>/dev/null | head -5 | xargs cat 2>/dev/null | head -120

echo ""
echo "=== CrdtConfig setup for LcmCrdt ==="
rg -n "CrdtConfig\|new CrdtConfig\|AddSingleton.*CrdtConfig\|Configure.*CrdtConfig" --type cs backend/FwLite/LcmCrdt/ -C 5 | head -60

echo ""
echo "=== Check if Picture is registered as an entity type in Harmony for LcmCrdt ==="
rg -n "Picture\b" --type cs backend/FwLite/LcmCrdt/LcmCrdtKernel.cs 2>/dev/null

echo ""
echo "=== LcmCrdtKernel.cs ==="
cat backend/FwLite/LcmCrdt/LcmCrdtKernel.cs 2>/dev/null || fd -e cs "LcmCrdtKernel\|Kernel" backend/FwLite/LcmCrdt/ | head -3 | xargs cat 2>/dev/null

Length of output: 26784


🏁 Script executed:

#!/bin/bash
echo "=== LcmCrdt Sense model ==="
fd -e cs "Sense.cs" backend/FwLite/LcmCrdt/ | xargs cat 2>/dev/null
fd -e cs "Sense.cs" backend/FwLite/MiniLcm/ | xargs cat 2>/dev/null

echo ""
echo "=== Latest EF Core migration for LcmCrdt ==="
fd -e cs . backend/FwLite/LcmCrdt/Migrations/ | sort | tail -3 | xargs cat 2>/dev/null

echo ""
echo "=== Check for Pictures column in any migration ==="
rg -rn "Picture\|Pictures" backend/FwLite/LcmCrdt/Migrations/ | head -40

echo ""
echo "=== Sense table snapshot in EF migration - does Pictures column exist? ==="
rg -n "Pictures\|Senses\b" backend/FwLite/LcmCrdt/Migrations/ | head -30

echo ""
echo "=== MediaUri class definition ==="
fd -e cs "MediaUri" backend/FwLite/ | xargs cat 2>/dev/null

echo ""
echo "=== Check what PictureDbConverter does ==="
grep -n "PictureDbConverter\|Picture.*Converter" backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs

Length of output: 67396



@rmunn — Found the root cause. Here's the full analysis.

Root Cause

The Pictures collection is stored using OwnsMany(...ToJson()) configured in LcmCrdtDbContext.OnModelCreating, but every other JSON-in-column property on Sense (like SemanticDomains, Gloss, Definition) is configured via a simple HasConversion in the ObjectTypeListBuilder.Add<Sense> builder callback in LcmCrdtKernel.ConfigureCrdt.

Harmony's entity projection (which keeps the EF Core Senses table in sync with the ObjectSnapshot store) handles properties registered inside ObjectTypeListBuilder.Add<Sense> correctly. The OwnsMany owned-navigation approach configured outside of that builder does not get correctly projected when Harmony calls the EF Core update, so the Pictures JSON column stays null/empty even after CreateSensePictureChange is applied. That's why reading back via crdtApi.GetAllEntries() returns Pictures = [].


Fix 1 — Convert Pictures to a simple JSON property (like SemanticDomains)

LcmCrdtKernel.csConfigureCrdt, inside .Add<Sense>(builder => {...}):

.Add<Sense>(builder =>
{
    // ... existing configuration ...
    builder.Property(s => s.SemanticDomains)
        .HasColumnType("jsonb")
        .HasConversion(list => JsonSerializer.Serialize(list, (JsonSerializerOptions?)null),
            json => JsonSerializer.Deserialize<List<SemanticDomain>>(json, (JsonSerializerOptions?)null) ?? new());

    // ADD THIS — same pattern as SemanticDomains:
    builder.Property(s => s.Pictures)
        .HasColumnType("jsonb")
        .HasConversion(
            list => JsonSerializer.Serialize(list, (JsonSerializerOptions?)null),
            json => JsonSerializer.Deserialize<List<Picture>>(json, (JsonSerializerOptions?)null) ?? new());
})

LcmCrdtDbContext.cs — remove the OwnsMany from OnModelCreating:

// REMOVE this line:
// senseModel.OwnsMany(s => s.Pictures, pic => { pic.ToJson(); });

LcmCrdtDbContext.cs — remove PictureDbConverter from ConfigureConventions:

// REMOVE these two lines — they conflict and are no longer needed:
// builder.Properties<Picture>()
//     .HaveColumnType("jsonb")
//     .HaveConversion<PictureDbConverter>();

Then delete PictureDbConverter and create a new migration:

dotnet ef migrations add PicturesAsSimpleJsonOnSense

Fix 2 — DeletePicture, MovePicture, UpdatePicture use wrong change types

Picture is NOT in ObjectTypeListBuilder, so it is not a standalone Harmony entity. DeleteChange<Picture>, SetOrderChange<Picture>, and JsonPatchChange<Picture> all assume it is one — they will fail silently or throw.

You need EditChange<Sense>-style changes for all three, similar to CreateSensePictureChange.

UpdateSensePictureChange (equivalent of JsonPatchChange but targeting the Sense):

public class UpdateSensePictureChange : EditChange<Sense>, ISelfNamedType<UpdateSensePictureChange>
{
    public Guid PictureId { get; set; }
    public JsonPatchDocument<Picture> Patch { get; set; }

    public UpdateSensePictureChange(Guid senseId, Guid pictureId, JsonPatchDocument<Picture> patch) : base(senseId)
    {
        PictureId = pictureId;
        Patch = patch;
    }

    public override ValueTask ApplyChange(Sense entity, IChangeContext context)
    {
        var pic = entity.Pictures.FirstOrDefault(p => p.Id == PictureId);
        if (pic is null) return ValueTask.CompletedTask;
        Patch.ApplyTo(pic);
        return ValueTask.CompletedTask;
    }
}

DeleteSensePictureChange:

public class DeleteSensePictureChange : EditChange<Sense>, ISelfNamedType<DeleteSensePictureChange>
{
    public Guid PictureId { get; set; }

    public DeleteSensePictureChange(Guid senseId, Guid pictureId) : base(senseId)
    {
        PictureId = pictureId;
    }

    public override ValueTask ApplyChange(Sense entity, IChangeContext context)
    {
        entity.Pictures = [..entity.Pictures.Where(p => p.Id != PictureId)];
        return ValueTask.CompletedTask;
    }
}

MoveSensePictureChange:

public class MoveSensePictureChange : EditChange<Sense>, ISelfNamedType<MoveSensePictureChange>
{
    public Guid PictureId { get; set; }
    public BetweenPosition Between { get; set; }

    public MoveSensePictureChange(Guid senseId, Guid pictureId, BetweenPosition between) : base(senseId)
    {
        PictureId = pictureId;
        Between = between;
    }

    public override ValueTask ApplyChange(Sense entity, IChangeContext context)
    {
        var pic = entity.Pictures.FirstOrDefault(p => p.Id == PictureId);
        if (pic is null) return ValueTask.CompletedTask;
        pic.Order = OrderPicker.PickOrder(entity.Pictures.Where(p => p.Id != PictureId).ToList(), Between);
        entity.Pictures.Sort((a, b) => a.Order == b.Order ? a.Id.CompareTo(b.Id) : a.Order.CompareTo(b.Order));
        return ValueTask.CompletedTask;
    }
}

Then update CrdtMiniLcmApi to use these instead of the standalone-entity variants. You'll need the senseId in DeletePicture and MovePicture — it's already a parameter.

Also remove JsonPatchChange<Picture>, DeleteChange<Picture>, and SetOrderChange<Picture> from the ChangeTypeListBuilder, and add the new change types.


Fix 3 — Picture.Copy() doesn't copy Order

public Picture Copy()
{
    return new Picture
    {
        Id = Id,
        Order = Order,   // ← this line is missing
        MediaUri = MediaUri,
        Caption = Caption.Copy(),
        DeletedAt = DeletedAt,
    };
}

Summary

Issue Cause Fix
Pictures = [] after sync OwnsMany...ToJson() outside Harmony's ObjectTypeListBuilder not projected correctly Move to HasConversion JSON scalar inside Add<Sense> builder
DeletePicture / MovePicture broken Use DeleteChange<Picture> / SetOrderChange<Picture> with non-standalone entity Replace with EditChange<Sense>-based changes
UpdatePicture broken Uses JsonPatchChange<Picture> same reason Replace with UpdateSensePictureChange
Picture.Copy() loses ordering Order omitted Add Order = Order to Copy()

Also means SenseId on Picture objects is completely redundant
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related 📦 Lexbox issues related to any server side code, fw-headless included

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Model images in FW-Lite

1 participant