Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
23a80cb
Add Pictures field to sense editor with image carousel
rmunn Jun 25, 2026
15214f1
Fix picture carousel event/typing and extract i18n strings
rmunn Jun 25, 2026
da91533
Guard against undefined sense.pictures; fix demo data + Playwright test
rmunn Jun 25, 2026
d89cb63
Add Picture CRUD API to MiniLcmJsInvokable
rmunn Jul 3, 2026
5e707d3
Correct URL for Pictures helpId
rmunn Jul 3, 2026
1fd212f
Implement the "+ Picture" button to upload and create pictures
rmunn Jul 3, 2026
20b0789
Fix UNIQUE constraint crash when a picture is first loaded
rmunn Jul 3, 2026
c6d63ce
Handle media download failures gracefully instead of throwing
rmunn Jul 3, 2026
933415d
Retry transient media download failures
rmunn Jul 3, 2026
056d35b
Tweak Picture field layout: left-justify image, right-align add button
rmunn Jul 3, 2026
1db16ea
Left-justify carousel controls under the picture
rmunn Jul 6, 2026
a87236c
Left-justify carousel controls under the picture
rmunn Jul 6, 2026
71181ba
Expose MovePicture to frontend API
rmunn Jul 6, 2026
4ffd9f9
Add Replace/Delete picture actions; keep "+ Picture" always visible
rmunn Jul 7, 2026
36b287e
Pause carousel autoplay while hovering/focusing Replace or Delete
rmunn Jul 7, 2026
ba73cf3
Implement movePicture in the demo API
rmunn Jul 7, 2026
7f875d5
Reuse the existing media file when an upload reports AlreadyExists
rmunn Jul 7, 2026
ced0680
Rebuild PictureCarousel on the shadcn-svelte carousel
rmunn Jul 8, 2026
f5e4bb4
Adjust MovePicture TypeScript API to allow null
rmunn Jul 8, 2026
071b5ab
Allow TIFF and BMP picture uploads
rmunn Jul 8, 2026
85f6042
Move picture Delete/Replace onto the image for touch support
rmunn Jul 8, 2026
eca1d1b
Simplify the multithreaded GetFileStream fix
rmunn Jul 8, 2026
2bb8942
Don't dispose manually; use using
rmunn Jul 8, 2026
d4fb1fa
Replace picture carousel with a wrapping flex gallery
rmunn Jul 9, 2026
42b09ee
Add an edit-picture dialog with caption editing
rmunn Jul 9, 2026
4240097
Add a Submit button to dismiss the edit-picture dialog
rmunn Jul 9, 2026
cd7380d
Buffer edit-picture changes until Submit; add Cancel
rmunn Jul 9, 2026
f04df64
Make sure failed media downloads can be retried
rmunn Jul 9, 2026
634fd83
Remove redundant code
rmunn Jul 9, 2026
8c09097
Don't dispose of response stream before reading it
rmunn Jul 9, 2026
f281bac
Remove download tasks after completion
rmunn Jul 9, 2026
de5b5ed
Handle "can't download while offline" gracefully
rmunn Jul 9, 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
36 changes: 36 additions & 0 deletions backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,42 @@ async Task<ExampleSentence> IMiniLcmWriteApi.UpdateExampleSentence(Guid entryId,
return result;
}

async Task<Picture> IMiniLcmWriteApi.CreatePicture(Guid entryId, Guid senseId, Picture picture, BetweenPosition? position)
{
await using var _ = BeginTrackingChanges();
var result = await _api.CreatePicture(entryId, senseId, picture, position);
NotifyEntryChanged(entryId);
return result;
}

async Task<Picture> IMiniLcmWriteApi.UpdatePicture(Guid entryId, Guid senseId, Guid pictureId, UpdateObjectInput<Picture> update)
{
await using var _ = BeginTrackingChanges();
var result = await _api.UpdatePicture(entryId, senseId, pictureId, update);
NotifyEntryChanged(entryId);
return result;
}

async Task<Picture> IMiniLcmWriteApi.UpdatePicture(Guid entryId, Guid senseId, Picture before, Picture after, IMiniLcmApi? api)
{
await using var _ = BeginTrackingChanges();
var result = await _api.UpdatePicture(entryId, senseId, before, after, api ?? this);
NotifyEntryChanged(entryId);
return result;
}

async Task IMiniLcmWriteApi.MovePicture(Guid entryId, Guid senseId, Guid pictureId, BetweenPosition position)
{
await _api.MovePicture(entryId, senseId, pictureId, position);
NotifyEntryChanged(entryId);
}

async Task IMiniLcmWriteApi.DeletePicture(Guid entryId, Guid senseId, Guid pictureId)
{
await _api.DeletePicture(entryId, senseId, pictureId);
NotifyEntryChanged(entryId);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
async Task<ComplexFormComponent> IMiniLcmWriteApi.CreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition<ComplexFormComponent>? position)
{
var result = await _api.CreateComplexFormComponent(complexFormComponent, position);
Expand Down
31 changes: 31 additions & 0 deletions backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,37 @@ public async Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid example
OnDataChanged();
}

[JSInvokable]
public async Task<Picture> CreatePicture(Guid entryId, Guid senseId, Picture picture)
{
var createdPicture = await _wrappedApi.CreatePicture(entryId, senseId, picture);
OnDataChanged();
return createdPicture;
}

[JSInvokable]
public async Task<Picture> UpdatePicture(Guid entryId, Guid senseId, Picture before, Picture after)
{
var updatedPicture = await _wrappedApi.UpdatePicture(entryId, senseId, before, after);
OnDataChanged();
return updatedPicture;
}

[JSInvokable]
// previousPictureId and nextPictureId represent the target position, where the picture should end up after being moved
public async Task MovePicture(Guid entryId, Guid senseId, Guid pictureId, Guid? previousPictureId = null, Guid? nextPictureId = null)
{
await _wrappedApi.MovePicture(entryId, senseId, pictureId, new MiniLcm.SyncHelpers.BetweenPosition(previousPictureId, nextPictureId));
OnDataChanged();
}

[JSInvokable]
public async Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId)
{
await _wrappedApi.DeletePicture(entryId, senseId, pictureId);
OnDataChanged();
}

[JSInvokable]
public async Task<ReadFileResponseJs?> GetFileStream(string mediaUri)
{
Expand Down
96 changes: 72 additions & 24 deletions backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Concurrent;
using System.Net;
using Microsoft.Extensions.Options;
using MiniLcm.Project;
using Refit;
Expand Down Expand Up @@ -48,19 +50,46 @@ public async Task DeleteResource(Guid fileId)
/// <param name="fileId">media file Id</param>
/// <returns></returns>
/// <exception cref="FileNotFoundException"></exception>
// Coalesces concurrent downloads of the same file into one shared task. Without this, two
// requests for a not-yet-cached file (e.g. the UI loading a picture more than once at first
// render) both see no local resource and both call DownloadResource, whose AddLocalResource
// inserts a LocalResource keyed by the file id — the second insert then fails with
// "UNIQUE constraint failed: LocalResource.Id". The first caller starts the task; others
// await the same one. Keyed by file id (a unique Guid), so different files still download in
// parallel.
private static readonly ConcurrentDictionary<Guid, Task<LocalResource>> DownloadTasks = new();

public async Task<ReadFileResponse> GetFileStream(Guid fileId)
{
var localResource = await resourceService.GetLocalResource(fileId);
if (localResource is null)
{
var connectionStatus = await httpClientProvider.ConnectionStatus();
if (connectionStatus == ConnectionStatus.Online)
var downloadTask = DownloadTasks.GetOrAdd(fileId, async _ =>
{
// Check again if we have it locally in case another thread completed before the GetOrAdd factory ran
return await resourceService.GetLocalResource(fileId) ?? await resourceService.DownloadResource(fileId, this);
});
Comment on lines +67 to +71

@coderabbitai coderabbitai Bot Jul 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does ConcurrentDictionary GetOrAdd valueFactory run only once under concurrent access

💡 Result:

No, the valueFactory delegate in ConcurrentDictionary.GetOrAdd is not guaranteed to run only once under concurrent access [1][2][3]. Because the dictionary invokes the valueFactory outside of its internal locks to prevent performance bottlenecks and potential deadlocks (such as those caused by executing unknown or blocking code while holding a lock), it is possible for multiple threads to execute the valueFactory simultaneously for the same key [1][2]. In such scenarios, while only one of the resulting values will ultimately be stored in the dictionary, the factory may be called multiple times [1][4]. If you require the factory to execute exactly once, a common pattern is to store a Lazy instance in the ConcurrentDictionary instead of the value itself, utilizing the built-in thread safety of the Lazy type to ensure the initialization logic runs only once [3][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the referenced lines
sed -n '1,220p' backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs

# Find the resource service implementation and related methods
rg -n "GetLocalResource|DownloadResource|DownloadTasks|TryRemove|LocalResource.Id|UNIQUE constraint failed" backend/FwLite -S

Repository: sillsdev/languageforge-lexbox

Length of output: 11756


GetOrAdd can still start this download twice. ConcurrentDictionary.GetOrAdd may invoke the async factory more than once under contention, so two callers can both see GetLocalResource(fileId) == null and both call DownloadResource. That still leaves the LocalResource.Id unique-constraint race. Wrap the in-flight work in Lazy<Task<LocalResource>> (or another single-flight primitive) so only one download starts per file id.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs` around lines 67 - 71,
The DownloadTasks.GetOrAdd usage in LcmMediaService.DownloadTask creation can
still start multiple downloads for the same fileId under contention. Replace the
async factory with a single-flight pattern in the LcmMediaService path, such as
storing Lazy<Task<LocalResource>> (or equivalent) so only one
GetLocalResource/DownloadResource sequence runs per file id and all callers
await the same in-flight task.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's a proposed diff to address the issue. Would this work? Is there a simpler way to implement this?

diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs
index 1566db6b..d95dfb60 100644
--- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs
+++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs
@@ -64,11 +64,7 @@ public class LcmMediaService(
         var localResource = await resourceService.GetLocalResource(fileId);
         if (localResource is null)
         {
-            var downloadTask = DownloadTasks.GetOrAdd(fileId, async _ =>
-            {
-                // Check again if we have it locally in case another thread completed before the GetOrAdd factory ran
-                return await resourceService.GetLocalResource(fileId) ?? await resourceService.DownloadResource(fileId, this);
-            });
+            var downloadTask = GetOrStartDownload(fileId);
             try
             {
                 localResource = await downloadTask;
@@ -98,6 +94,36 @@ public class LcmMediaService(
         return new(File.OpenRead(localResource.LocalPath), Path.GetFileName(localResource.LocalPath));
     }

+    // Single-flight download starter. Uses the *value* overload of GetOrAdd, not the factory
+    // overload: ConcurrentDictionary may invoke a factory more than once under contention, and an
+    // async factory with side effects would start a separate DownloadResource on each invocation —
+    // the losing tasks still run and race to AddLocalResource, reintroducing the duplicate-key
+    // insert. Handing GetOrAdd a not-yet-started TaskCompletionSource.Task means only the caller
+    // whose task actually got stored kicks off the download; everyone else awaits that same task.
+    private Task<LocalResource> GetOrStartDownload(Guid fileId)
+    {
+        var tcs = new TaskCompletionSource<LocalResource>(TaskCreationOptions.RunContinuationsAsynchronously);
+        var task = DownloadTasks.GetOrAdd(fileId, tcs.Task);
+        if (task == tcs.Task)
+            _ = RunDownloadAsync(fileId, tcs);
+        return task;
+    }
+
+    private async Task RunDownloadAsync(Guid fileId, TaskCompletionSource<LocalResource> tcs)
+    {
+        try
+        {
+            // Check again if we have it locally in case another thread committed it before we started.
+            var resource = await resourceService.GetLocalResource(fileId)
+                           ?? await resourceService.DownloadResource(fileId, this);
+            tcs.SetResult(resource);
+        }
+        catch (Exception e)
+        {
+            tcs.SetException(e);
+        }
+    }
+
     // Media files are proxied (lexbox -> FwHeadless). A cold request — e.g. the first one after the
     // backend starts, before its code paths are JIT-warmed — can exceed the proxy's timeout and come
     // back as 504 (or 502/503) even though the backend keeps working and warms up. Retrying a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description images show four states of the Pictures field in the definition/sense editor: a single fried-kale-greens photo displayed inline, an empty state with a green "+ Picture" button, and a two-slide carousel (smiling man / noodle soup) with prev/next arrows and dot indicators — these are illustrative and not directly relevant to this concurrency question.

@rmunn Your proposed diff works and correctly fixes the race. The key insight is that you switched from the factory overload of GetOrAdd(TKey, Func<TKey,TValue>) — which the docs confirm can invoke the factory more than once under contention — to the value overload GetOrAdd(TKey, TValue), which is atomic: only one TaskCompletionSource.Task instance can win and be stored. Guarding the actual download start with if (task == tcs.Task) ensures only the winner calls RunDownloadAsync, so DownloadResource/AddLocalResource genuinely runs once per file id. Using TaskCreationOptions.RunContinuationsAsynchronously is also a good defensive touch to avoid surprising synchronous continuations. This looks correct to me.

If you want something a bit less code, Lazy<Task<T>> gives you the same single-flight guarantee with less plumbing, since Lazy<T> (with the default ExecutionAndPublication thread-safety mode) guarantees its factory runs exactly once even if ConcurrentDictionary.GetOrAdd's own factory is invoked multiple times — because only one Lazy instance ends up stored, and only that instance's .Value triggers the inner delegate:

private static readonly ConcurrentDictionary<Guid, Lazy<Task<LocalResource>>> DownloadTasks = new();

private Task<LocalResource> GetOrStartDownload(Guid fileId)
{
    var lazy = DownloadTasks.GetOrAdd(fileId, _ => new Lazy<Task<LocalResource>>(
        () => DownloadAsync(fileId),
        LazyThreadSafetyMode.ExecutionAndPublication));
    return lazy.Value;
}

private async Task<LocalResource> DownloadAsync(Guid fileId)
{
    return await resourceService.GetLocalResource(fileId) ?? await resourceService.DownloadResource(fileId, this);
}

This avoids manually wiring up a TaskCompletionSource and exception propagation — awaiting lazy.Value naturally surfaces any exception thrown inside DownloadAsync to every awaiter, and the finally block's TryRemove (keyed by the Lazy reference or just plain removal) still lets a later call retry after failure.

Either approach solves the double-download problem; the Lazy<Task<T>> version is marginally simpler since it needs one fewer method and no manual exception plumbing, but your TaskCompletionSource version is equally correct and arguably clearer about why it's safe (an explicit it must be my task check rather than relying on Lazy's internal locking semantics). Either is a good fix for the original concern.


✏️ Learnings added
Learnt from: rmunn
Repo: sillsdev/languageforge-lexbox PR: 2412
File: backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs:67-71
Timestamp: 2026-07-09T10:44:42.563Z
Learning: In backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs, single-flight download coalescing for GetFileStream must not rely on ConcurrentDictionary.GetOrAdd's factory overload (Func<TKey,TValue>), since .NET does not guarantee that factory runs only once under concurrent access — multiple threads can invoke it and race on AddLocalResource, causing a "UNIQUE constraint failed: LocalResource.Id" error. Use either the GetOrAdd value overload with a pre-created TaskCompletionSource<LocalResource> (only start the download if the returned task reference equals the caller's own TaskCompletionSource.Task) or a ConcurrentDictionary<Guid, Lazy<Task<LocalResource>>> with LazyThreadSafetyMode.ExecutionAndPublication, to guarantee the download logic runs exactly once per file id.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

try
{
localResource = await resourceService.DownloadResource(fileId, this);
localResource = await downloadTask;
}
else
catch
{
return new ReadFileResponse(ReadFileResult.Offline);
// Did the download fail because we're offline? Return an explicit "we're offline" result
// so that the frontend can display some appropriate UI. If we're online and yet the download
// failed, then that's a real error that should be rethrown.
if (await httpClientProvider.ConnectionStatus() == ConnectionStatus.Offline)
{
return new ReadFileResponse(ReadFileResult.Offline);
}
throw;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
finally
{
// Once the download completes, we need to remove the task from the ConcurrentDictionary
// so that a new download can be attempted later by a different thread. That way if the local
// file is ever deleted (say, by being replaced with a different picture) a new download can start.
DownloadTasks.TryRemove(new KeyValuePair<Guid, Task<LocalResource>>(fileId, downloadTask));
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
//todo, consider trying to download the file again, maybe the cache was cleared
Expand All @@ -69,16 +98,19 @@ public async Task<ReadFileResponse> GetFileStream(Guid fileId)
return new(File.OpenRead(localResource.LocalPath), Path.GetFileName(localResource.LocalPath));
}

private async Task<(Stream? stream, string? filename)> RequestMediaFile(Guid fileId)
{
var mediaClient = await MediaServerClient();
var response = await mediaClient.DownloadFile(fileId);
if (!response.IsSuccessStatusCode)
{
throw new Exception($"Failed to download file {fileId}: {response.StatusCode} {response.ReasonPhrase}");
}
return (await response.Content.ReadAsStreamAsync(), response.Content.Headers.ContentDisposition?.FileName?.Replace("\"", ""));
}
// Media files are proxied (lexbox -> FwHeadless). A cold request — e.g. the first one after the
// backend starts, before its code paths are JIT-warmed — can exceed the proxy's timeout and come
// back as 504 (or 502/503) even though the backend keeps working and warms up. Retrying a
// transient failure a few times usually hits the now-warm backend and succeeds. A failed fetch
// never reaches AddLocalResource, so retrying cannot reintroduce the duplicate-key insert.
private const int MaxDownloadAttempts = 3;
private static readonly HashSet<HttpStatusCode> TransientDownloadStatusCodes =
[
HttpStatusCode.RequestTimeout, // 408
HttpStatusCode.BadGateway, // 502
HttpStatusCode.ServiceUnavailable, // 503
HttpStatusCode.GatewayTimeout, // 504
];

private async Task<IMediaServerClient> MediaServerClient()
{
Expand All @@ -91,16 +123,32 @@ async Task<DownloadResult> IRemoteResourceService.DownloadResource(string remote
{
var projectResourceCachePath = ProjectResourceCachePath;
Directory.CreateDirectory(projectResourceCachePath);
var (stream, filename) = await RequestMediaFile(new Guid(remoteId));
if (stream is null) throw new FileNotFoundException(remoteId);
await using (stream)
var mediaClient = await MediaServerClient();
var fileId = new Guid(remoteId);
for (var attempt = 1; ; attempt++)
{
filename = Path.GetFileName(filename);
var localPath = Path.Combine(projectResourceCachePath, filename ?? remoteId);
localPath = EnsureUnique(localPath);
await using var localFile = File.Create(localPath);
await stream.CopyToAsync(localFile);
return new DownloadResult(localPath);
using var response = await mediaClient.DownloadFile(fileId);
if (response.IsSuccessStatusCode)
{
await using var stream = await response.Content.ReadAsStreamAsync();
if (stream is null) throw new FileNotFoundException(remoteId);
var filename = response.Content.Headers.ContentDisposition?.FileName?.Replace("\"", "");
filename = Path.GetFileName(filename);
var localPath = Path.Combine(projectResourceCachePath, filename ?? remoteId);
localPath = EnsureUnique(localPath);
await using var localFile = File.Create(localPath);
await stream.CopyToAsync(localFile);
return new DownloadResult(localPath);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

var statusCode = response.StatusCode;
var reasonPhrase = response.ReasonPhrase;
if (attempt >= MaxDownloadAttempts || !TransientDownloadStatusCodes.Contains(statusCode))
{
throw new Exception($"Failed to download file {fileId}: {statusCode} {reasonPhrase}");
}

await Task.Delay(TimeSpan.FromMilliseconds(200));
}
}

Expand Down
28 changes: 0 additions & 28 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion frontend/viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@
"@vitest/ui": "catalog:",
"bits-ui": "2.16.4",
"clsx": "^2.1.1",
"embla-carousel-svelte": "^8.6.0",
"eslint": "catalog:",
"eslint-config-prettier": "^10.1.8",
"eslint-output": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {ICreateEntryOptions} from '../../MiniLcm/ICreateEntryOptions';
import type {IComplexFormComponent} from '../../MiniLcm/Models/IComplexFormComponent';
import type {ISense} from '../../MiniLcm/Models/ISense';
import type {IExampleSentence} from '../../MiniLcm/Models/IExampleSentence';
import type {IPicture} from '../../MiniLcm/Models/IPicture';
import type {IReadFileResponseJs} from './IReadFileResponseJs';
import type {IUploadFileResponse} from '../../MiniLcm/Media/IUploadFileResponse';
import type {ILcmFileMetadata} from '../../MiniLcm/Media/ILcmFileMetadata';
Expand Down Expand Up @@ -75,6 +76,10 @@ export interface IMiniLcmJsInvokable
createExampleSentence(entryId: string, senseId: string, exampleSentence: IExampleSentence) : Promise<IExampleSentence>;
updateExampleSentence(entryId: string, senseId: string, before: IExampleSentence, after: IExampleSentence) : Promise<IExampleSentence>;
deleteExampleSentence(entryId: string, senseId: string, exampleSentenceId: string) : Promise<void>;
createPicture(entryId: string, senseId: string, picture: IPicture) : Promise<IPicture>;
updatePicture(entryId: string, senseId: string, before: IPicture, after: IPicture) : Promise<IPicture>;
movePicture(entryId: string, senseId: string, pictureId: string, previousPictureId?: string, nextPictureId?: string) : Promise<void>;
deletePicture(entryId: string, senseId: string, pictureId: string) : Promise<void>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
getFileStream(mediaUri: string) : Promise<IReadFileResponseJs>;
saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata) : Promise<IUploadFileResponse>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,5 @@ export interface IPicture
order: number;
mediaUri: string;
caption: IRichMultiString;
deletedAt?: string;
}
/* eslint-enable */
1 change: 1 addition & 0 deletions frontend/viewer/src/lib/dotnet-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from './generated-types/MiniLcm/Models/ITranslation';
export * from './generated-types/MiniLcm/Models/IMorphType';
export * from './generated-types/MiniLcm/Models/IObjectWithId';
export * from './generated-types/MiniLcm/Models/IPartOfSpeech';
export * from './generated-types/MiniLcm/Models/IPicture';
export * from './generated-types/MiniLcm/Models/IProjectIdentifier';
export * from './generated-types/MiniLcm/Models/IPublication';
export * from './generated-types/MiniLcm/Models/IRichSpan';
Expand Down
Loading
Loading