Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ef9493a
Begin modeling pictures in MiniLcm
rmunn May 27, 2026
98d23b3
Incomplete implementation of MiniLcm picture APIs
rmunn May 28, 2026
54f139b
Handle MediaUri properties for Picture API
rmunn May 29, 2026
623c63b
Add DB migration for Pictures column in JSON
rmunn Jun 4, 2026
ed08c35
Tell EF Core about Pictures in DbContext
rmunn Jun 11, 2026
f049d4b
Add MiniLcmApiWriteNormalizationWrapper APi calls
rmunn Jun 11, 2026
18a947d
Attempt to get pictures to sync (not working yet)
rmunn Jun 11, 2026
d69f076
Actually get Pictures data from FW during sync
rmunn Jun 12, 2026
b2898c1
Actually use SenseId, we're going to need it
rmunn Jun 12, 2026
27d2acd
Move Pictures to its own DB table
rmunn Jun 12, 2026
759a337
Back to storing pictures as JSON fields on senses
rmunn Jun 15, 2026
66c3000
Add Picture to Reinforced.Typings config
rmunn Jun 18, 2026
3719d92
Add pictures list to Typescript demo data
rmunn Jun 18, 2026
914ff07
Picture changes should now be `EditChange<Sense>`
rmunn Jun 18, 2026
b618feb
Add `pictures: []` to a couple more frontend bits
rmunn Jun 18, 2026
be1fe2f
Update pictures with a dedicated change type
rmunn Jun 18, 2026
6844a51
Ensure patching MediaUri values does not normalize
rmunn Jun 18, 2026
30ae193
Have LfClassicMiniLcmApi actually find pictures
rmunn Jun 18, 2026
681e0b8
Give migration correct default for jsonb lists
rmunn Jun 18, 2026
76d2674
Normalization wrapper now normalizes Pictures too
rmunn Jun 18, 2026
fa043be
Have CreatePicture look up the pic after creating
rmunn Jun 18, 2026
0812b11
Don't update deleted pictures
rmunn Jun 19, 2026
bf04002
Fix default value of Pictures column
rmunn Jun 19, 2026
9e1ef96
Better ownership-validation logic for pictures
rmunn Jun 19, 2026
ae8010c
Skip reordering if picture already deleted
rmunn Jun 19, 2026
064b77e
Get PictureId from change object on creation
rmunn Jun 19, 2026
0891da0
Handle Order changes correctly in UpdatePicture
rmunn Jun 22, 2026
de80c5d
Update a bunch of verified.txt files with pictures
rmunn Jun 23, 2026
4312aaf
Picture can't be IObjectWithId
rmunn Jun 23, 2026
5387127
Teach AutoFaker how to create pictures
rmunn Jun 23, 2026
26a8902
Fix a couple more tests
rmunn Jun 24, 2026
b4dfc7a
Add new unit tests for Pictures
rmunn Jun 24, 2026
cc089f7
Merge branch 'develop' into feat/pictures
rmunn Jun 24, 2026
b464387
Add new SubmitUpdatePicture API
rmunn Jun 24, 2026
5246d03
Add TypeScript conversion of Picture
rmunn Jun 24, 2026
99d8e71
Fix DeletePicture sync test
rmunn Jun 24, 2026
94a3814
Do not sync the Order property in Pictures
rmunn Jun 24, 2026
f8ad64d
Remove completed TODO comments
rmunn Jun 25, 2026
eb7a7ac
Remove extraneous blank line
rmunn Jun 25, 2026
fdbc1e0
Slightly better change regression test data
rmunn Jun 25, 2026
5128681
Address review comments
rmunn Jun 29, 2026
5e9ae99
One more review-comment fix
rmunn Jun 29, 2026
e560749
Don't throw if no local media files found
rmunn Jun 29, 2026
47222d3
Address one final review comment
rmunn Jun 29, 2026
06d426e
Set up Picture order correctly in AutoFaker
rmunn Jun 30, 2026
bf18715
Merge branch 'develop' into feat/pictures
rmunn Jun 30, 2026
5cd730f
Fix up incorrect merge-conflict resolution
rmunn Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 178 additions & 1 deletion backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class FwDataMiniLcmApi(
{
private FwDataBridgeConfig Config => config.Value;
public const string AudioVisualFolder = "AudioVisual";
public const string PicturesFolder = "Pictures";
public LcmCache Cache
{
get
Expand All @@ -52,9 +53,11 @@ public LcmCache Cache
internal ILexEntryRepository EntriesRepository => Cache.ServiceLocator.GetInstance<ILexEntryRepository>();
internal IRepository<ILexSense> SenseRepository => Cache.ServiceLocator.GetInstance<IRepository<ILexSense>>();
private IRepository<ILexExampleSentence> ExampleSentenceRepository => Cache.ServiceLocator.GetInstance<IRepository<ILexExampleSentence>>();
private IRepository<ICmPicture> PictureRepository => Cache.ServiceLocator.GetInstance<IRepository<ICmPicture>>();
private ILexEntryFactory LexEntryFactory => Cache.ServiceLocator.GetInstance<ILexEntryFactory>();
private ILexSenseFactory LexSenseFactory => Cache.ServiceLocator.GetInstance<ILexSenseFactory>();
private ILexExampleSentenceFactory LexExampleSentenceFactory => Cache.ServiceLocator.GetInstance<ILexExampleSentenceFactory>();
private ICmPictureFactory LcmPictureFactory => Cache.ServiceLocator.GetInstance<ICmPictureFactory>();
private IMoMorphTypeRepository MorphTypeRepository => Cache.ServiceLocator.GetInstance<IMoMorphTypeRepository>();
private IPartOfSpeechRepository PartOfSpeechRepository => Cache.ServiceLocator.GetInstance<IPartOfSpeechRepository>();
private ILexEntryTypeRepository LexEntryTypeRepository => Cache.ServiceLocator.GetInstance<ILexEntryTypeRepository>();
Expand Down Expand Up @@ -795,6 +798,7 @@ private Sense FromLexSense(ILexSense sense)
Definition = FromLcmMultiString(sense.Definition),
PartOfSpeech = pos is null ? null : FromLcmPartOfSpeech(pos),
PartOfSpeechId = pos?.Guid,
Pictures = [.. sense.PicturesOS.Select(picture => FromLcmPicture(sense.Guid, picture))],
SemanticDomains = [.. sense.SemanticDomainsRC.Select(FromLcmSemanticDomain)],
ExampleSentences = [.. sense.ExamplesOS.Select(sentence => FromLexExampleSentence(sense.Guid, sentence))]
};
Expand All @@ -817,6 +821,27 @@ private ExampleSentence FromLexExampleSentence(Guid senseGuid, ILexExampleSenten
};
}

internal MediaUri MediaUriFromLcmPicture(ICmPicture picture)
{
var mediaFilePath = picture.PictureFileRA?.AbsoluteInternalPath;
if (!string.IsNullOrEmpty(mediaFilePath))
{
return mediaAdapter.MediaUriFromPath(mediaFilePath, Cache);
}
return MediaUri.NotFound;
}

private Picture FromLcmPicture(Guid senseGuid, ICmPicture picture)
{
return new Picture
{
Id = picture.Guid,
Caption = FromLcmMultiString(picture.Caption),
MediaUri = MediaUriFromLcmPicture(picture),
Order = picture.IndexInOwner + 1, // Order property in CRDT indexes from 1
};
}

private MultiString FromLcmMultiString(ITsMultiString? multiString)
{
if (multiString is null) return [];
Expand Down Expand Up @@ -1461,6 +1486,16 @@ private void ApplySenseToLexSense(Sense sense, ILexSense lexSense)
{
CreateExampleSentence(lexSense, exampleSentence);
}

if (sense.Pictures.Any())
{
List<Picture> pictures = [.. sense.Pictures];
pictures.Sort(Picture.ComparePictures);
foreach (var picture in pictures)
{
CreatePicture(lexSense, picture);
}
}
}

public Task<Sense?> GetSense(Guid id)
Expand Down Expand Up @@ -1780,6 +1815,148 @@ private static void ValidateOwnership(ILexExampleSentence lexExampleSentence, Gu
}
}

private static void ValidateOwnership(ICmPicture lcmPicture, Guid entryId, Guid senseId)
{
if (lcmPicture.Owner is ILexSense sense)
{
if (sense.Guid != senseId) throw new InvalidOperationException("Picture does not belong to sense");
}
else
{
throw new InvalidOperationException("Picture does not belong to sense, it belongs to a " +
lcmPicture.Owner.ClassName);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

internal void InsertPicture(ILexSense lexSense, ICmPicture lcmPicture, BetweenPosition? between = null)
{
var previousPictureId = between?.Previous;
var nextPictureId = between?.Next;

var previousPicture = previousPictureId.HasValue ? lexSense.PicturesOS.FirstOrDefault(s => s.Guid == previousPictureId) : null;
if (previousPicture is not null)
{
var insertI = lexSense.PicturesOS.IndexOf(previousPicture) + 1;
// ILcmOwningSequence treats an insert as a move if the item is already in it
lexSense.PicturesOS.Insert(insertI, lcmPicture);
return;
}

var nextPicture = nextPictureId.HasValue ? lexSense.PicturesOS.FirstOrDefault(s => s.Guid == nextPictureId) : null;
if (nextPicture is not null)
{
var insertI = lexSense.PicturesOS.IndexOf(nextPicture);
// ILcmOwningSequence treats an insert as a move if the item is already in it
lexSense.PicturesOS.Insert(insertI, lcmPicture);
return;
}

lexSense.PicturesOS.Add(lcmPicture);
}

public Task<Picture?> GetPicture(Guid entryId, Guid senseId, Guid id)
{
PictureRepository.TryGetObject(id, out var lcmPicture);
ValidateOwnership(lcmPicture, entryId, senseId);
return Task.FromResult(lcmPicture is null ? null : FromLcmPicture(senseId, lcmPicture));
Comment thread
rmunn marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
}

internal void SetLcmPictureFile(ICmPicture lcmPicture, MediaUri mediaUri)
{
if (mediaUri == MediaUri.NotFound) return;
var fullPath = mediaAdapter.PathFromMediaUri(mediaUri, Cache);
if (fullPath is null) return;
// Passing 0 as writing system to UpdatePicture means "don't update caption, only file"
lcmPicture.UpdatePicture(fullPath, null, CmFolderTags.LocalPictures, 0);
}

public Task<Picture> CreatePicture(Guid entryId, Guid senseId, Picture picture, BetweenPosition? between = null)
{
if (picture.Id == default) picture.Id = Guid.NewGuid();
if (!SenseRepository.TryGetObject(senseId, out var lexSense))
throw new InvalidOperationException("Sense not found");
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create picture",
"Remove picture",
Cache.ServiceLocator.ActionHandler,
() => CreatePicture(lexSense, picture, between));
return Task.FromResult(
FromLcmPicture(senseId, PictureRepository.GetObject(picture.Id)));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

public Task<Picture> UpdatePicture(Guid entryId,
Guid senseId,
Guid pictureId,
UpdateObjectInput<Picture> update)
{
var lcmPicture = PictureRepository.GetObject(pictureId);
ValidateOwnership(lcmPicture, entryId, senseId);
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Update picture",
"Revert picture",
Cache.ServiceLocator.ActionHandler,
() =>
{
var updateProxy = new UpdatePictureProxy(lcmPicture, this);
update.Apply(updateProxy);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return Task.FromResult(FromLcmPicture(senseId, lcmPicture));
}

public async Task<Picture> UpdatePicture(Guid entryId,
Guid senseId,
Picture before,
Picture after,
IMiniLcmApi? api = null)
{
await Cache.DoUsingNewOrCurrentUOW("Update picture",
"Revert picture",
async () =>
{
await PictureSync.Sync(entryId, senseId, before, after, api ?? this);
});
return await GetPicture(entryId, senseId, after.Id) ?? throw new NullReferenceException("unable to find picture with id " + after.Id);
}

public Task MovePicture(Guid entryId, Guid senseId, Guid pictureId, BetweenPosition between)
{
if (!EntriesRepository.TryGetObject(entryId, out var lexEntry))
throw new InvalidOperationException("Entry not found");
if (!SenseRepository.TryGetObject(senseId, out var lexSense))
throw new InvalidOperationException("Sense not found");
if (!PictureRepository.TryGetObject(pictureId, out var lcmPicture))
throw new InvalidOperationException("Picture not found");

ValidateOwnership(lcmPicture, entryId, senseId);

UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Move picture",
"Move picture back",
Cache.ServiceLocator.ActionHandler,
() =>
{
InsertPicture(lexSense, lcmPicture, between);
});
return Task.CompletedTask;
}

public Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId)
{
var lcmPicture = PictureRepository.GetObject(pictureId);
ValidateOwnership(lcmPicture, entryId, senseId);
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete picture",
"Revert delete",
Cache.ServiceLocator.ActionHandler,
() => lcmPicture.Delete());
return Task.CompletedTask;
}

public Task<ReadFileResponse> GetFileStream(MediaUri mediaUri)
{
if (mediaUri == MediaUri.NotFound) return Task.FromResult(new ReadFileResponse(ReadFileResult.NotFound));
Expand Down Expand Up @@ -1830,7 +2007,7 @@ private string TypeToLinkedFolder(string mimeType)
{
{ } s when s.StartsWith("audio/") => AudioVisualFolder,
{ } s when s.StartsWith("video/") => AudioVisualFolder,
{ } s when s.StartsWith("image/") => "Pictures",
{ } s when s.StartsWith("image/") => PicturesFolder,
_ => "Others"
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using MiniLcm.Media;
using MiniLcm.Models;
using SIL.LCModel;

namespace FwDataMiniLcmBridge.Api.UpdateProxy;

public class UpdatePictureProxy(ICmPicture picture, FwDataMiniLcmApi lexboxLcmApi) : Picture
{
public override Guid Id
{
get => picture.Guid;
set => throw new NotImplementedException();
}

public override RichMultiString Caption
{
get => new UpdateRichMultiStringProxy(picture.Caption, lexboxLcmApi);
set => throw new NotImplementedException();
}

public override MediaUri MediaUri
{
get => lexboxLcmApi.MediaUriFromLcmPicture(picture);
set => lexboxLcmApi.SetLcmPictureFile(picture, value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,10 @@ public override List<ExampleSentence> ExampleSentences
get => throw new NotImplementedException();
set => throw new NotImplementedException();
}

public override List<Picture> Pictures
{
get => throw new NotImplementedException();
set => throw new NotImplementedException();
}
Comment thread
rmunn marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ private Dictionary<Guid, string> Paths(LcmCache cache)
internal static Dictionary<Guid, string> BuildPathsDictionary(string root, ILogger logger)
{
var paths = new Dictionary<Guid, string>();
if (!Directory.Exists(root)) return paths;
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
var fileId = PathToUri(file).FileId;
Expand Down
3 changes: 2 additions & 1 deletion backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,8 @@ public async Task CanSyncRandomEntries(ApiType? roundTripApiType)
.For(e => e.Senses).Exclude(s => s.Order)
.For(e => e.Components).Exclude(c => c.Order)
.For(e => e.ComplexForms).Exclude(c => c.Order)
.For(e => e.Senses).For(s => s.ExampleSentences).Exclude(e => e.Order);
.For(e => e.Senses).For(s => s.ExampleSentences).Exclude(e => e.Order)
.For(e => e.Senses).For(s => s.Pictures).Exclude(e => e.Order);
// ComplexFormHeadword/ComponentHeadword are derived live from the referenced entry on read,
// so they don't round-trip the randomly-generated values here; their behaviour is asserted in
// ComplexFormComponentTestsBase (see ComplexFormComponentHeadwords_UpdateWhenReferencedEntriesChange).
Expand Down
Loading
Loading