From d872aad48eb127ecf88cea34659e419fb6ce424d Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 11:43:39 +0700 Subject: [PATCH 01/40] move MediaUri into a media folder --- backend/FwHeadless/LexboxFwDataMediaAdapter.cs | 1 + backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs | 1 + backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 1 + .../Api/UpdateProxy/UpdateDictionaryProxy.cs | 1 + backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs | 1 + backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs | 1 + backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs | 1 + .../FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs | 1 + backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 1 + backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs | 1 + backend/FwLite/MiniLcm/IMiniLcmReadApi.cs | 1 + backend/FwLite/MiniLcm/{ => Media}/MediaUri.cs | 2 +- backend/FwLite/MiniLcm/{Models => Media}/ReadFileResponse.cs | 2 +- backend/Testing/FwHeadless/MediaFileServiceTests.cs | 1 + 14 files changed, 14 insertions(+), 2 deletions(-) rename backend/FwLite/MiniLcm/{ => Media}/MediaUri.cs (97%) rename backend/FwLite/MiniLcm/{Models => Media}/ReadFileResponse.cs (96%) diff --git a/backend/FwHeadless/LexboxFwDataMediaAdapter.cs b/backend/FwHeadless/LexboxFwDataMediaAdapter.cs index 3e3b08c8c7..8b8aa3bd84 100644 --- a/backend/FwHeadless/LexboxFwDataMediaAdapter.cs +++ b/backend/FwHeadless/LexboxFwDataMediaAdapter.cs @@ -4,6 +4,7 @@ using LexCore.Exceptions; using Microsoft.Extensions.Options; using MiniLcm; +using MiniLcm.Media; using SIL.LCModel; namespace FwHeadless; diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs index d69799d287..a8c2c69614 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs @@ -1,6 +1,7 @@ using FwDataMiniLcmBridge.Api; using FwDataMiniLcmBridge.Media; using FwDataMiniLcmBridge.Tests.Fixtures; +using MiniLcm.Media; using MiniLcm.Models; using SIL.LCModel.Infrastructure; diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 93e4d08c21..669ed6b409 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; using MiniLcm; using MiniLcm.Exceptions; +using MiniLcm.Media; using MiniLcm.Models; using MiniLcm.SyncHelpers; using MiniLcm.Validators; diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs index 2bed4c81eb..132ea9aee2 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Diagnostics.CodeAnalysis; using MiniLcm; +using MiniLcm.Media; using MiniLcm.Models; using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Core.Text; diff --git a/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs b/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs index d45193f005..eee31261b1 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs @@ -1,4 +1,5 @@ using MiniLcm; +using MiniLcm.Media; using SIL.LCModel; namespace FwDataMiniLcmBridge.Media; diff --git a/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs b/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs index 9fc4f5dc47..464e557254 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Caching.Memory; using MiniLcm; using MiniLcm.Exceptions; +using MiniLcm.Media; using SIL.LCModel; using UUIDNext; diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index 8bc71aff90..ccf214961f 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -2,6 +2,7 @@ using LcmCrdt; using Microsoft.JSInterop; using MiniLcm; +using MiniLcm.Media; using MiniLcm.Models; using MiniLcm.Validators; using Reinforced.Typings.Attributes; diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 859214c167..5e9f78ee8c 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -24,6 +24,7 @@ using System.Runtime.CompilerServices; using FwLiteShared.AppUpdate; using FwLiteShared.Sync; +using MiniLcm.Media; namespace FwLiteShared.TypeGen; diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 277adc8457..18e9e44a20 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -17,6 +17,7 @@ using MiniLcm.SyncHelpers; using SIL.Harmony.Core; using MiniLcm.Culture; +using MiniLcm.Media; using SystemTextJsonPatch; namespace LcmCrdt; diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 02a4044118..923c584d30 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -5,6 +5,7 @@ using SIL.Harmony.Core; using SIL.Harmony.Resource; using LcmCrdt.RemoteSync; +using MiniLcm.Media; namespace LcmCrdt.MediaServer; diff --git a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs index c7c895ec1d..a6c4865271 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using System.Text.Json.Serialization; using MiniLcm.Filtering; +using MiniLcm.Media; using MiniLcm.Models; namespace MiniLcm; diff --git a/backend/FwLite/MiniLcm/MediaUri.cs b/backend/FwLite/MiniLcm/Media/MediaUri.cs similarity index 97% rename from backend/FwLite/MiniLcm/MediaUri.cs rename to backend/FwLite/MiniLcm/Media/MediaUri.cs index d48e917eb8..5638662d26 100644 --- a/backend/FwLite/MiniLcm/MediaUri.cs +++ b/backend/FwLite/MiniLcm/Media/MediaUri.cs @@ -1,4 +1,4 @@ -namespace MiniLcm; +namespace MiniLcm.Media; public record struct MediaUri { diff --git a/backend/FwLite/MiniLcm/Models/ReadFileResponse.cs b/backend/FwLite/MiniLcm/Media/ReadFileResponse.cs similarity index 96% rename from backend/FwLite/MiniLcm/Models/ReadFileResponse.cs rename to backend/FwLite/MiniLcm/Media/ReadFileResponse.cs index c68f1eb510..84e10703af 100644 --- a/backend/FwLite/MiniLcm/Models/ReadFileResponse.cs +++ b/backend/FwLite/MiniLcm/Media/ReadFileResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MiniLcm.Models; +namespace MiniLcm.Media; public record ReadFileResponse { diff --git a/backend/Testing/FwHeadless/MediaFileServiceTests.cs b/backend/Testing/FwHeadless/MediaFileServiceTests.cs index 42a7ad7e07..3062fccd4d 100644 --- a/backend/Testing/FwHeadless/MediaFileServiceTests.cs +++ b/backend/Testing/FwHeadless/MediaFileServiceTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using MiniLcm; +using MiniLcm.Media; using SIL.LCModel; using Testing.Fixtures; From d38032da5085792fbf9a9675503642f187078505 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 11:44:15 +0700 Subject: [PATCH 02/40] add a no-op save file implementation --- backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs | 12 +++++++ backend/FwLite/MiniLcm/Media/FileMetadata.cs | 7 +++++ backend/FwLite/MiniLcm/Media/MediaFile.cs | 3 ++ .../MiniLcm/Media/UploadFileResponse.cs | 31 +++++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 backend/FwLite/MiniLcm/Media/FileMetadata.cs create mode 100644 backend/FwLite/MiniLcm/Media/MediaFile.cs create mode 100644 backend/FwLite/MiniLcm/Media/UploadFileResponse.cs diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index 05688d706e..ae0d1c27fc 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using MiniLcm.Media; using MiniLcm.Models; using MiniLcm.SyncHelpers; using SystemTextJsonPatch; @@ -121,6 +122,17 @@ async Task BulkCreateEntries(IAsyncEnumerable entries) await this.CreateEntry(entry); } } + + /// + /// Saves a media file and processes the provided stream. + /// + /// The data stream of the media file to be saved. + /// The metadata and URI of the media file to save. + /// Returns an indicating the result of the operation. + Task SaveFile(Stream stream, MediaFile mediaFile) + { + return Task.FromResult(new UploadFileResponse(UploadFileResult.NotSupported)); + } } /// diff --git a/backend/FwLite/MiniLcm/Media/FileMetadata.cs b/backend/FwLite/MiniLcm/Media/FileMetadata.cs new file mode 100644 index 0000000000..0d86668b21 --- /dev/null +++ b/backend/FwLite/MiniLcm/Media/FileMetadata.cs @@ -0,0 +1,7 @@ +namespace MiniLcm.Media; + +public record LcmFileMetadata( + string Filename, + string? MimeType = null, + string? Author = null, + DateTimeOffset? UploadDate = null); diff --git a/backend/FwLite/MiniLcm/Media/MediaFile.cs b/backend/FwLite/MiniLcm/Media/MediaFile.cs new file mode 100644 index 0000000000..aa701fb821 --- /dev/null +++ b/backend/FwLite/MiniLcm/Media/MediaFile.cs @@ -0,0 +1,3 @@ +namespace MiniLcm.Media; + +public record MediaFile(MediaUri Uri, LcmFileMetadata Metadata); diff --git a/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs b/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs new file mode 100644 index 0000000000..8ba9c77431 --- /dev/null +++ b/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace MiniLcm.Media; + +public record UploadFileResponse +{ + public UploadFileResponse(UploadFileResult result) + { + if (result == UploadFileResult.Error) throw new ArgumentException("Error result must have an error message"); + Result = result; + } + + public UploadFileResponse(string errorMessage) + { + Result = UploadFileResult.Error; + ErrorMessage = errorMessage; + } + + public UploadFileResult Result { get; } + public string? ErrorMessage { get; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UploadFileResult +{ + SavedLocally, + SavedToLexbox, + TooBig, + NotSupported, + Error +} From 1306f963c1c2b393d79629f4ec9ed72ccea2f7d8 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 12:09:04 +0700 Subject: [PATCH 03/40] setup typegen --- .../TypeGen/ReinforcedFwLiteTypingConfig.cs | 10 ++++++++- backend/FwLite/MiniLcm/Media/MediaFile.cs | 15 ++++++++++++- backend/FwLite/MiniLcm/Media/MediaUri.cs | 21 ++++++++++++++++++- .../MiniLcm/Media/UploadFileResponse.cs | 1 + .../Services/IMiniLcmJsInvokable.ts | 3 +++ .../Services/IReadFileResponseJs.ts | 2 +- .../MiniLcm/Media/ILcmFileMetadata.ts | 13 ++++++++++++ .../MiniLcm/Media/IMediaFile.ts | 15 +++++++++++++ .../MiniLcm/Media/IUploadFileResponse.ts | 13 ++++++++++++ .../MiniLcm/Media/MediaFileType.ts | 14 +++++++++++++ .../{Models => Media}/ReadFileResult.ts | 0 .../MiniLcm/Media/UploadFileResult.ts | 14 +++++++++++++ 12 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/ILcmFileMetadata.ts create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IMediaFile.ts create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse.ts create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/MediaFileType.ts rename frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/{Models => Media}/ReadFileResult.ts (100%) create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/UploadFileResult.ts diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 5e9f78ee8c..b99c00256f 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -25,6 +25,7 @@ using FwLiteShared.AppUpdate; using FwLiteShared.Sync; using MiniLcm.Media; +using MediaFile = MiniLcm.Media.MediaFile; namespace FwLiteShared.TypeGen; @@ -64,6 +65,7 @@ public static void Configure(ConfigurationBuilder builder) private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) { builder.Substitute(typeof(WritingSystemId), new RtSimpleTypeName("string")); + builder.Substitute(typeof(MediaUri), new RtSimpleTypeName("string")); builder.ExportAsThirdParty().WithName("IMultiString").Imports([ new() { From = "$lib/dotnet-types/i-multi-string", Target = "type {IMultiString}" } ]); @@ -79,6 +81,9 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) typeof(IObjectWithId), typeof(RichString), typeof(RichTextObjectData), + + typeof(MediaFile), + typeof(LcmFileMetadata) ], exportBuilder => exportBuilder.WithPublicNonStaticProperties(exportBuilder => { @@ -101,6 +106,8 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) ]); builder.ExportAsEnum(); builder.ExportAsEnum().UseString(); + builder.ExportAsEnum().UseString(); + builder.ExportAsEnum().UseString(); builder.ExportAsInterface() .FlattenHierarchy() .WithPublicProperties() @@ -112,7 +119,8 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) typeof(SortOptions), typeof(ExemplarOptions), typeof(EntryFilter), - typeof(MiniLcmJsInvokable.ReadFileResponseJs) + typeof(MiniLcmJsInvokable.ReadFileResponseJs), + typeof(UploadFileResponse) ], exportBuilder => exportBuilder.WithPublicNonStaticProperties(propExportBuilder => { diff --git a/backend/FwLite/MiniLcm/Media/MediaFile.cs b/backend/FwLite/MiniLcm/Media/MediaFile.cs index aa701fb821..599737b563 100644 --- a/backend/FwLite/MiniLcm/Media/MediaFile.cs +++ b/backend/FwLite/MiniLcm/Media/MediaFile.cs @@ -1,3 +1,16 @@ +using System.Text.Json.Serialization; + namespace MiniLcm.Media; -public record MediaFile(MediaUri Uri, LcmFileMetadata Metadata); +public record MediaFile(MediaUri Uri, LcmFileMetadata Metadata, MediaFileType Type); + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MediaFileType +{ + Other, + Pdf, + Text, + Audio, + Image, + Video, +} diff --git a/backend/FwLite/MiniLcm/Media/MediaUri.cs b/backend/FwLite/MiniLcm/Media/MediaUri.cs index 5638662d26..e69a63f7a7 100644 --- a/backend/FwLite/MiniLcm/Media/MediaUri.cs +++ b/backend/FwLite/MiniLcm/Media/MediaUri.cs @@ -1,6 +1,10 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + namespace MiniLcm.Media; -public record struct MediaUri +[JsonConverter(typeof(MediaUriJsonConverter))] +public readonly record struct MediaUri { public static readonly MediaUri NotFound = new MediaUri(Guid.Empty, "not-found"); public static readonly string NotFoundString = NotFound.ToString(); @@ -37,3 +41,18 @@ public override string ToString() public Guid FileId { get; init; } public string Authority { get; init; } } + +public class MediaUriJsonConverter : JsonConverter +{ + public override MediaUri Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var uri = reader.GetString(); + if (uri is null) return MediaUri.NotFound; + return new MediaUri(uri); + } + + public override void Write(Utf8JsonWriter writer, MediaUri value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs b/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs index 8ba9c77431..364fe09932 100644 --- a/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs +++ b/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs @@ -27,5 +27,6 @@ public enum UploadFileResult SavedToLexbox, TooBig, NotSupported, + AlreadyExists, Error } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts index 4b5d692506..189d40a8cf 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts @@ -18,6 +18,8 @@ import type {IComplexFormComponent} from '../../MiniLcm/Models/IComplexFormCompo import type {ISense} from '../../MiniLcm/Models/ISense'; import type {IExampleSentence} from '../../MiniLcm/Models/IExampleSentence'; import type {IReadFileResponseJs} from './IReadFileResponseJs'; +import type {IUploadFileResponse} from '../../MiniLcm/Media/IUploadFileResponse'; +import type {IMediaFile} from '../../MiniLcm/Media/IMediaFile'; export interface IMiniLcmJsInvokable { @@ -63,5 +65,6 @@ export interface IMiniLcmJsInvokable updateExampleSentence(entryId: string, senseId: string, before: IExampleSentence, after: IExampleSentence) : Promise; deleteExampleSentence(entryId: string, senseId: string, exampleSentenceId: string) : Promise; getFileStream(mediaUri: string) : Promise; + saveFile(streamReference: unknown, mediaFile: IMediaFile) : Promise; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IReadFileResponseJs.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IReadFileResponseJs.ts index 295986e51d..b34659c974 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IReadFileResponseJs.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IReadFileResponseJs.ts @@ -3,7 +3,7 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. -import type {ReadFileResult} from '../../MiniLcm/Models/ReadFileResult'; +import type {ReadFileResult} from '../../MiniLcm/Media/ReadFileResult'; export interface IReadFileResponseJs { diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/ILcmFileMetadata.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/ILcmFileMetadata.ts new file mode 100644 index 0000000000..e9766f6fbf --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/ILcmFileMetadata.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +export interface ILcmFileMetadata +{ + filename: string; + mimeType?: string; + author?: string; + uploadDate?: string; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IMediaFile.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IMediaFile.ts new file mode 100644 index 0000000000..e5e0e382f3 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IMediaFile.ts @@ -0,0 +1,15 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import type {ILcmFileMetadata} from './ILcmFileMetadata'; +import type {MediaFileType} from './MediaFileType'; + +export interface IMediaFile +{ + uri: string; + metadata: ILcmFileMetadata; + type: MediaFileType; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse.ts new file mode 100644 index 0000000000..62d7780acb --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import type {UploadFileResult} from './UploadFileResult'; + +export interface IUploadFileResponse +{ + result: UploadFileResult; + errorMessage?: string; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/MediaFileType.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/MediaFileType.ts new file mode 100644 index 0000000000..10a72780ba --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/MediaFileType.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +export enum MediaFileType { + Other = "Other", + Pdf = "Pdf", + Text = "Text", + Audio = "Audio", + Image = "Image", + Video = "Video" +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/ReadFileResult.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult.ts similarity index 100% rename from frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/ReadFileResult.ts rename to frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult.ts diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/UploadFileResult.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/UploadFileResult.ts new file mode 100644 index 0000000000..f8d298bf18 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/UploadFileResult.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +export enum UploadFileResult { + SavedLocally = "SavedLocally", + SavedToLexbox = "SavedToLexbox", + TooBig = "TooBig", + NotSupported = "NotSupported", + AlreadyExists = "AlreadyExists", + Error = "Error" +} +/* eslint-enable */ From f2b5a96c49e61ad46f03d560e259d56e687eb733 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 14:33:43 +0700 Subject: [PATCH 04/40] ensure blobs are correctly transformed into JSStreamReferences --- .../lib/services/service-provider-dotnet.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/frontend/viewer/src/lib/services/service-provider-dotnet.ts b/frontend/viewer/src/lib/services/service-provider-dotnet.ts index 42d512a15f..be4e41e51e 100644 --- a/frontend/viewer/src/lib/services/service-provider-dotnet.ts +++ b/frontend/viewer/src/lib/services/service-provider-dotnet.ts @@ -1,7 +1,15 @@ /* eslint-disable @typescript-eslint/naming-convention */ import './service-declaration'; -import { DotNet } from '@microsoft/dotnet-js-interop'; +//do not import as a value, we need to use the object defined on window +import type {DotNet} from '@microsoft/dotnet-js-interop'; import {type LexboxServiceRegistry, SERVICE_KEYS, type ServiceKey} from './service-provider'; + +declare global { + interface Window { + DotNet: typeof DotNet; + } +} + export class DotNetServiceProvider { private services: LexboxServiceRegistry; @@ -31,7 +39,7 @@ export class DotNetServiceProvider { } private isDotnetObject(service: object): service is DotNet.DotNetObject { - return service instanceof DotNet.DotNetObject || 'invokeMethodAsync' in service; + return service instanceof window.DotNet.DotNetObject || 'invokeMethodAsync' in service; } private validateAllServices() { @@ -54,6 +62,7 @@ export function wrapInProxy(dotnetObject: DotNet.DotNetObj const dotnetMethodName = uppercaseFirstLetter(prop); return async function proxyHandler(...args: unknown[]) { console.debug(`[Dotnet Proxy] Calling ${serviceName} method ${dotnetMethodName}`, args); + args = transformArgs(args); const result = await target.invokeMethodAsync(dotnetMethodName, ...args); console.debug(`[Dotnet Proxy] ${serviceName} method ${dotnetMethodName} returned`, result); return result; @@ -66,6 +75,18 @@ function uppercaseFirstLetter(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } +function transformArgs(args: unknown[]): unknown[] { + return args.map(arg => { + return transformBlob(arg); + }); +} + +function transformBlob(result: unknown): unknown { + if (result instanceof Blob) return window.DotNet.createJSStreamReference(result); + if (result instanceof ArrayBuffer) return window.DotNet.createJSStreamReference(result); + return result; +} + export function setupDotnetServiceProvider() { if (globalThis.window.lexbox?.DotNetServiceProvider) return; const lexbox = {DotNetServiceProvider: new DotNetServiceProvider()}; From a40a59bfae68af4f3ac47dc66bbb4a777b18b378 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 14:39:36 +0700 Subject: [PATCH 05/40] change save to not expect a media uri, and type IJsStreamReference --- .../TypeGen/ReinforcedFwLiteTypingConfig.cs | 2 +- backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs | 10 +++++----- backend/FwLite/MiniLcm/Media/FileMetadata.cs | 2 +- backend/FwLite/MiniLcm/Media/MediaFile.cs | 13 +------------ backend/FwLite/MiniLcm/Media/UploadFileResponse.cs | 8 ++++++++ .../FwLiteShared/Services/IMiniLcmJsInvokable.ts | 4 ++-- .../MiniLcm/Media/ILcmFileMetadata.ts | 2 +- .../generated-types/MiniLcm/Media/IMediaFile.ts | 2 -- .../MiniLcm/Media/IUploadFileResponse.ts | 1 + 9 files changed, 20 insertions(+), 24 deletions(-) diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index b99c00256f..7794069514 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -55,6 +55,7 @@ public static void Configure(ConfigurationBuilder builder) exportBuilder => exportBuilder.WithName("DotNet.DotNetObject").Imports([ new() { From = "@microsoft/dotnet-js-interop", Target = "type {DotNet}" } ])); + builder.Substitute(typeof(IJSStreamReference), new RtSimpleTypeName("Blob | ArrayBuffer | Uint8Array")); builder.Substitute(typeof(DotNetStreamReference), new RtSimpleTypeName("{stream: () => Promise, arrayBuffer: () => Promise}")); builder.ExportAsInterface(); @@ -107,7 +108,6 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) builder.ExportAsEnum(); builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(); - builder.ExportAsEnum().UseString(); builder.ExportAsInterface() .FlattenHierarchy() .WithPublicProperties() diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index ae0d1c27fc..dd33a7afbb 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -124,12 +124,12 @@ async Task BulkCreateEntries(IAsyncEnumerable entries) } /// - /// Saves a media file and processes the provided stream. + /// Saves a media file using the provided data stream and metadata. /// - /// The data stream of the media file to be saved. - /// The metadata and URI of the media file to save. - /// Returns an indicating the result of the operation. - Task SaveFile(Stream stream, MediaFile mediaFile) + /// The stream containing the media file data to be saved. + /// Metadata associated with the media file, including details like filename and upload information. + /// An indicating the outcome of the save operation. + Task SaveFile(Stream stream, LcmFileMetadata metadata) { return Task.FromResult(new UploadFileResponse(UploadFileResult.NotSupported)); } diff --git a/backend/FwLite/MiniLcm/Media/FileMetadata.cs b/backend/FwLite/MiniLcm/Media/FileMetadata.cs index 0d86668b21..5cf90fde48 100644 --- a/backend/FwLite/MiniLcm/Media/FileMetadata.cs +++ b/backend/FwLite/MiniLcm/Media/FileMetadata.cs @@ -2,6 +2,6 @@ namespace MiniLcm.Media; public record LcmFileMetadata( string Filename, - string? MimeType = null, + string MimeType, string? Author = null, DateTimeOffset? UploadDate = null); diff --git a/backend/FwLite/MiniLcm/Media/MediaFile.cs b/backend/FwLite/MiniLcm/Media/MediaFile.cs index 599737b563..dc81026bbb 100644 --- a/backend/FwLite/MiniLcm/Media/MediaFile.cs +++ b/backend/FwLite/MiniLcm/Media/MediaFile.cs @@ -2,15 +2,4 @@ namespace MiniLcm.Media; -public record MediaFile(MediaUri Uri, LcmFileMetadata Metadata, MediaFileType Type); - -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum MediaFileType -{ - Other, - Pdf, - Text, - Audio, - Image, - Video, -} +public record MediaFile(MediaUri Uri, LcmFileMetadata Metadata); diff --git a/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs b/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs index 364fe09932..7dad66ff68 100644 --- a/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs +++ b/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs @@ -4,8 +4,15 @@ namespace MiniLcm.Media; public record UploadFileResponse { + public UploadFileResponse(MediaUri mediaUri, bool savedToLexbox) + { + MediaUri = mediaUri; + Result = savedToLexbox ? UploadFileResult.SavedToLexbox : UploadFileResult.SavedLocally; + } + public UploadFileResponse(UploadFileResult result) { + if (result == UploadFileResult.SavedLocally || result == UploadFileResult.SavedToLexbox) throw new ArgumentException("Success results must have a media uri"); if (result == UploadFileResult.Error) throw new ArgumentException("Error result must have an error message"); Result = result; } @@ -18,6 +25,7 @@ public UploadFileResponse(string errorMessage) public UploadFileResult Result { get; } public string? ErrorMessage { get; } + public MediaUri? MediaUri { get; } } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts index 189d40a8cf..dc65c37f63 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts @@ -19,7 +19,7 @@ import type {ISense} from '../../MiniLcm/Models/ISense'; import type {IExampleSentence} from '../../MiniLcm/Models/IExampleSentence'; import type {IReadFileResponseJs} from './IReadFileResponseJs'; import type {IUploadFileResponse} from '../../MiniLcm/Media/IUploadFileResponse'; -import type {IMediaFile} from '../../MiniLcm/Media/IMediaFile'; +import type {ILcmFileMetadata} from '../../MiniLcm/Media/ILcmFileMetadata'; export interface IMiniLcmJsInvokable { @@ -65,6 +65,6 @@ export interface IMiniLcmJsInvokable updateExampleSentence(entryId: string, senseId: string, before: IExampleSentence, after: IExampleSentence) : Promise; deleteExampleSentence(entryId: string, senseId: string, exampleSentenceId: string) : Promise; getFileStream(mediaUri: string) : Promise; - saveFile(streamReference: unknown, mediaFile: IMediaFile) : Promise; + saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata) : Promise; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/ILcmFileMetadata.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/ILcmFileMetadata.ts index e9766f6fbf..6c3668ee58 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/ILcmFileMetadata.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/ILcmFileMetadata.ts @@ -6,7 +6,7 @@ export interface ILcmFileMetadata { filename: string; - mimeType?: string; + mimeType: string; author?: string; uploadDate?: string; } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IMediaFile.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IMediaFile.ts index e5e0e382f3..c03487ce96 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IMediaFile.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IMediaFile.ts @@ -4,12 +4,10 @@ // the code is regenerated. import type {ILcmFileMetadata} from './ILcmFileMetadata'; -import type {MediaFileType} from './MediaFileType'; export interface IMediaFile { uri: string; metadata: ILcmFileMetadata; - type: MediaFileType; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse.ts index 62d7780acb..6c9af0123e 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse.ts @@ -9,5 +9,6 @@ export interface IUploadFileResponse { result: UploadFileResult; errorMessage?: string; + mediaUri?: string; } /* eslint-enable */ From 19829d6635db5f559c353e564587f1cea82b5e28 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 14:51:29 +0700 Subject: [PATCH 06/40] handle saving files locally when using FwData --- .../Api/FwDataMiniLcmApi.cs | 45 +++++++++++++++++++ .../Services/MiniLcmJsInvokable.cs | 20 +++++++++ .../lib/components/audio/AudioDialog.svelte | 30 ++++++++++--- .../field-editors/audio-input.svelte | 20 +++++---- .../field-editors/multi-ws-input.svelte | 2 +- .../field-editors/rich-multi-ws-input.svelte | 14 +++++- 6 files changed, 115 insertions(+), 16 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 669ed6b409..dee4ab31b9 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -1541,4 +1541,49 @@ public Task GetFileStream(MediaUri mediaUri) if (!File.Exists(fullPath)) return Task.FromResult(new ReadFileResponse(ReadFileResult.NotFound)); return Task.FromResult(new ReadFileResponse(File.OpenRead(fullPath), Path.GetFileName(fullPath))); } + + public async Task SaveFile(Stream stream, LcmFileMetadata metadata) + { + var pathRelativeToRoot = Path.Combine(TypeToLinkedFolder(metadata.MimeType), Path.GetFileName(metadata.Filename)); + var fullPath = Path.Combine(Cache.LangProject.LinkedFilesRootDir, pathRelativeToRoot); + if (File.Exists(fullPath)) return new UploadFileResponse(UploadFileResult.AlreadyExists); + var directory = Path.GetDirectoryName(fullPath); + if (directory is not null) + { + try + { + Directory.CreateDirectory(directory); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create directory {Directory} for file {Filename}", directory, metadata.Filename); + return new UploadFileResponse($"Failed to create directory: {ex.Message}"); + } + } + + try + { + await using var fileStream = File.OpenWrite(fullPath); + await stream.CopyToAsync(fileStream); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save file {Filename} to {Path}", metadata.Filename, fullPath); + return new UploadFileResponse($"Failed to save file: {ex.Message}"); + } + + var mediaUri = mediaAdapter.MediaUriFromPath(pathRelativeToRoot, Cache); + return new UploadFileResponse(mediaUri, false); + } + + private string TypeToLinkedFolder(string mimeType) + { + return mimeType switch + { + { } s when s.StartsWith("audio/") => AudioVisualFolder, + {} s when s.StartsWith("video/") => AudioVisualFolder, + { } s when s.StartsWith("image/") => "Pictures", + _ => "Others" + }; + } } diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index ccf214961f..8324354e28 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -1,5 +1,6 @@ using FwLiteShared.Sync; using LcmCrdt; +using Microsoft.Extensions.Logging; using Microsoft.JSInterop; using MiniLcm; using MiniLcm.Media; @@ -13,6 +14,7 @@ public class MiniLcmJsInvokable( IMiniLcmApi api, BackgroundSyncService backgroundSyncService, IProjectIdentifier project, + ILogger logger, MiniLcmApiNotifyWrapperFactory notificationWrapperFactory, MiniLcmApiValidationWrapperFactory validationWrapperFactory) : IDisposable { @@ -344,6 +346,24 @@ public record ReadFileResponseJs( string? FileName, ReadFileResult Result, string? ErrorMessage); + public const int TenMbFileLimit = 10 * 1024 * 1024; + + [JSInvokable] + public async Task SaveFile(IJSStreamReference streamReference, LcmFileMetadata metadata) + { + if (streamReference.Length > TenMbFileLimit) return new(UploadFileResult.TooBig); + await using var stream = await streamReference.OpenReadStreamAsync(TenMbFileLimit); + var result = await _wrappedApi.SaveFile(stream, metadata); + try + { + await streamReference.DisposeAsync(); + } + catch (Exception e) + { + logger.LogError(e, "Error disposing stream reference"); + } + return result; + } public void Dispose() { diff --git a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte index f4641915ff..fcda1f7e49 100644 --- a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte +++ b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte @@ -9,18 +9,22 @@ import AudioProvider from './audio-provider.svelte'; import AudioEditor from './audio-editor.svelte'; import Loading from '$lib/components/Loading.svelte'; + import {useLexboxApi} from '$lib/services/service-provider'; + import {UploadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/UploadFileResult'; + import {AppNotification} from '$lib/notifications/notifications'; let open = $state(false); useBackHandler({addToStack: () => open, onBack: () => open = false, key: 'audio-dialog'}); const dialogsService = useDialogsService(); dialogsService.invokeAudioDialog = getAudio; + const lexboxApi = useLexboxApi(); let submitting = $state(false); let selectedFile = $state(); let audio = $state(); let requester: { - resolve: (value: string | undefined) => void + resolve: (mediaUri: string | undefined) => void } | undefined; @@ -67,11 +71,27 @@ } async function uploadAudio() { - if (!audio) throw new Error('No file selected'); + if (!audio) throw new Error($t`No file selected`); const name = (selectedFile?.name ?? audio.type); - const id = `audio-${name}-${Date.now()}`; - await delay(1000); - return id; + const response = await lexboxApi.saveFile(audio, {filename: name, mimeType: audio.type}); + switch (response.result) { + case UploadFileResult.SavedLocally: + AppNotification.display($t`Audio saved locally`, 'success'); + break; + case UploadFileResult.SavedToLexbox: + AppNotification.display($t`Audio saved and uploaded to Lexbox`, 'success'); + break; + case UploadFileResult.TooBig: + throw new Error($t`File too big`); + case UploadFileResult.NotSupported: + throw new Error($t`File saving not supported`); + case UploadFileResult.AlreadyExists: + throw new Error($t`File already exists`); + case UploadFileResult.Error: + throw new Error(response.errorMessage ?? $t`Unknown error`); + } + + return response.mediaUri; } async function onFileSelected(file: File) { diff --git a/frontend/viewer/src/lib/components/field-editors/audio-input.svelte b/frontend/viewer/src/lib/components/field-editors/audio-input.svelte index 29f275743d..5b0391b740 100644 --- a/frontend/viewer/src/lib/components/field-editors/audio-input.svelte +++ b/frontend/viewer/src/lib/components/field-editors/audio-input.svelte @@ -46,7 +46,7 @@ import {Slider} from '$lib/components/ui/slider'; import {formatDuration, normalizeDuration} from '$lib/components/ui/format'; import {t} from 'svelte-i18n-lingui'; - import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Models/ReadFileResult'; + import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult'; import {useDialogsService} from '$lib/services/dialogs-service'; import {isDev} from '$lib/layout/DevContent.svelte'; import * as ResponsiveMenu from '$lib/components/responsive-menu'; @@ -55,9 +55,11 @@ let { loader = defaultLoader, audioId = $bindable(), + onchange = () => {}, }: { loader?: (audioId: string) => Promise, audioId: string | undefined, + onchange?: (audioId: string | undefined) => void; } = $props(); const projectContext = useProjectContext(); @@ -185,17 +187,19 @@ const result = await dialogService.getAudio(); if (result) { audioId = result; - await tick(); // let the audio element be created - // todo, the audio ID is fake - // await load(); + onchange(audioId) } } function onRemoveAudio() { - audioId = undefined; - if (audio && audio.src) { - URL.revokeObjectURL(audio.src); - audio.src = ''; + try { + audioId = undefined; + onchange(audioId); + } finally { + if (audio && audio.src) { + URL.revokeObjectURL(audio.src); + audio.src = ''; + } } } diff --git a/frontend/viewer/src/lib/components/field-editors/multi-ws-input.svelte b/frontend/viewer/src/lib/components/field-editors/multi-ws-input.svelte index 239f0864f9..eba883e7f1 100644 --- a/frontend/viewer/src/lib/components/field-editors/multi-ws-input.svelte +++ b/frontend/viewer/src/lib/components/field-editors/multi-ws-input.svelte @@ -50,7 +50,7 @@ autocapitalize="off" onchange={() => onchange?.(ws.wsId, value[ws.wsId], value)} /> {:else} - + onchange?.(ws.wsId, value[ws.wsId], value)}/> {/if} {/each} diff --git a/frontend/viewer/src/lib/components/field-editors/rich-multi-ws-input.svelte b/frontend/viewer/src/lib/components/field-editors/rich-multi-ws-input.svelte index 9c3f3a8eb1..f51237803b 100644 --- a/frontend/viewer/src/lib/components/field-editors/rich-multi-ws-input.svelte +++ b/frontend/viewer/src/lib/components/field-editors/rich-multi-ws-input.svelte @@ -20,7 +20,7 @@ value: IRichMultiString; readonly?: boolean; writingSystems: ReadonlyArray>; - onchange?: (wsId: string, value: IRichString, values: IRichMultiString) => void; + onchange?: (wsId: string, value: IRichString | undefined, values: IRichMultiString) => void; autofocus?: boolean; } = $props(); @@ -42,6 +42,16 @@ return richString?.spans[0].text; } + function setAudioId(audioId: string | undefined, wsId: string) { + let richString = audioId === undefined ? undefined : {spans: [{text: audioId ?? '', ws: wsId}]}; + if (richString) { + value[wsId] = richString; + } else { + delete value[wsId]; + } + onchange?.(wsId, richString, value); + } + const rootId = $props.id(); @@ -65,7 +75,7 @@ aria-label={ws.abbreviation} /> {:else} - + getAudioId(value[ws.wsId]), audioId => setAudioId(audioId, ws.wsId)}/> {/if} {/each} From c86d7e2195115b001df549f9cb3e34b327b7d316 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 15:03:57 +0700 Subject: [PATCH 07/40] update the local media path cache with newly created files --- .../FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs b/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs index 464e557254..c55aed772c 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs @@ -23,7 +23,7 @@ private Dictionary Paths(LcmCache cache) return Directory .EnumerateFiles(cache.LangProject.LinkedFilesRootDir, "*", SearchOption.AllDirectories) .Select(file => Path.GetRelativePath(cache.LangProject.LinkedFilesRootDir, file)) - .ToDictionary(file => MediaUriFromPath(file, cache).FileId, file => file); + .ToDictionary(file => PathToUri(file).FileId, file => file); }) ?? throw new Exception("Failed to get paths"); } @@ -31,6 +31,14 @@ private Dictionary Paths(LcmCache cache) public MediaUri MediaUriFromPath(string path, LcmCache cache) { if (!File.Exists(Path.Combine(cache.LangProject.LinkedFilesRootDir, path))) return MediaUri.NotFound; + var uri = PathToUri(path); + //this may be a new file, so we need to add it to the cache + Paths(cache)[uri.FileId] = path; + return uri; + } + + private static MediaUri PathToUri(string path) + { return new MediaUri(NewGuidV5(path), LocalMediaAuthority); } From 166f96b245ff983640c8cb55de931f856c2845c9 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 15:15:17 +0700 Subject: [PATCH 08/40] store recordings as a file with a generated name --- .../src/lib/components/audio/AudioDialog.svelte | 12 +++++++----- .../src/lib/components/audio/audio-editor.svelte | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte index fcda1f7e49..06104baf6e 100644 --- a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte +++ b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte @@ -71,8 +71,8 @@ } async function uploadAudio() { - if (!audio) throw new Error($t`No file selected`); - const name = (selectedFile?.name ?? audio.type); + if (!audio || !selectedFile) throw new Error($t`No file selected`); + const name = selectedFile.name; const response = await lexboxApi.saveFile(audio, {filename: name, mimeType: audio.type}); switch (response.result) { case UploadFileResult.SavedLocally: @@ -100,7 +100,9 @@ } async function onRecordingComplete(blob: Blob) { - selectedFile = undefined; + let fileExt = blob.type.split('/').pop(); + let fileName = `recording-${Date.now()}.${fileExt ?? 'bin'}`; + selectedFile = new File([blob], fileName, {type: blob.type}); if (!open) return; audio = await processAudio(blob); } @@ -125,14 +127,14 @@ {$t`Add audio`} - {#if !audio} + {#if !audio || !selectedFile} {#if loading} {:else} {/if} {:else} - + diff --git a/frontend/viewer/src/lib/components/audio/audio-editor.svelte b/frontend/viewer/src/lib/components/audio/audio-editor.svelte index 8a6bd20077..7979859aa8 100644 --- a/frontend/viewer/src/lib/components/audio/audio-editor.svelte +++ b/frontend/viewer/src/lib/components/audio/audio-editor.svelte @@ -9,14 +9,14 @@ type Props = { audio: Blob; + name: string onDiscard: () => void; }; - let { audio, onDiscard }: Props = $props(); + let { audio, name, onDiscard }: Props = $props(); let audioApi = $state(); let playing = $state(false); - const name = $derived(audio instanceof File ? audio.name : undefined); let duration = $state(null); const mb = $derived((audio.size / 1024 / 1024).toFixed(2)); const formatedDuration = $derived(duration ? formatDigitalDuration({ seconds: duration }) : 'unknown'); From e1932d75d098ed217f049733a33512fa1dc2b147 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 15:34:15 +0700 Subject: [PATCH 09/40] prevent reporting an infinite duration which can crash the browser due to how the slider works --- .../src/lib/components/field-editors/audio-input.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/viewer/src/lib/components/field-editors/audio-input.svelte b/frontend/viewer/src/lib/components/field-editors/audio-input.svelte index 5b0391b740..d5eba6fe87 100644 --- a/frontend/viewer/src/lib/components/field-editors/audio-input.svelte +++ b/frontend/viewer/src/lib/components/field-editors/audio-input.svelte @@ -19,7 +19,9 @@ get duration() { this.#durationSub(); - return this.audio.duration; + let duration = this.audio.duration; + if (duration === Infinity) duration = NaN; + return duration; } } From 05098cd8fe0a01f330430543fc470faa294ec384 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 15:51:52 +0700 Subject: [PATCH 10/40] generate a filename based on the time and guess the extension by mimetype --- .../lib/components/audio/AudioDialog.svelte | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte index 06104baf6e..3465f66c09 100644 --- a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte +++ b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte @@ -72,8 +72,7 @@ async function uploadAudio() { if (!audio || !selectedFile) throw new Error($t`No file selected`); - const name = selectedFile.name; - const response = await lexboxApi.saveFile(audio, {filename: name, mimeType: audio.type}); + const response = await lexboxApi.saveFile(audio, {filename: selectedFile.name, mimeType: audio.type}); switch (response.result) { case UploadFileResult.SavedLocally: AppNotification.display($t`Audio saved locally`, 'success'); @@ -100,13 +99,38 @@ } async function onRecordingComplete(blob: Blob) { - let fileExt = blob.type.split('/').pop(); - let fileName = `recording-${Date.now()}.${fileExt ?? 'bin'}`; - selectedFile = new File([blob], fileName, {type: blob.type}); + let fileExt = mimeTypeToFileExtension(blob.type); + selectedFile = new File([blob], `recording-${Date.now()}.${fileExt}`, {type: blob.type}); if (!open) return; audio = await processAudio(blob); } + function mimeTypeToFileExtension(mimeType: string) { + if (mimeType.startsWith('audio/')) { + const baseType = mimeType.split(';')[0]; + switch (baseType) { + case 'audio/mpeg': + case 'audio/mp3': + return 'mp3'; + case 'audio/wav': + case 'audio/wave': + case 'audio/x-wav': + return 'wav'; + case 'audio/ogg': + return 'ogg'; + case 'audio/webm': + return 'webm'; + case 'audio/aac': + return 'aac'; + case 'audio/m4a': + return 'm4a'; + default: + return 'audio'; + } + } + return 'bin'; + } + function onDiscard() { audio = undefined; selectedFile = undefined; From 1b24ab24f39acda1c80b1a7091c07f56c4e13fa9 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 16:14:57 +0700 Subject: [PATCH 11/40] save files locally using harmony resources --- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 14 ++++++++++++ .../LcmCrdt/MediaServer/LcmMediaService.cs | 22 ++++++++++++++++++- backend/harmony | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 18e9e44a20..aa76cb51a2 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -700,6 +700,20 @@ public async Task GetFileStream(MediaUri mediaUri) return await lcmMediaService.GetFileStream(mediaUri.FileId); } + public async Task SaveFile(Stream stream, LcmFileMetadata metadata) + { + try + { + var result = await lcmMediaService.SaveFile(stream, metadata); + return new UploadFileResponse(new MediaUri(result.Id, ProjectData.ServerId ?? "lexbox.org"), result.Remote); + } + catch (Exception e) + { + logger.LogError(e, "Failed to save file {Filename}", metadata.Filename); + return new UploadFileResponse(e.Message); + } + } + public void Dispose() { } diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 923c584d30..77366e2af5 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -94,8 +94,28 @@ async Task IRemoteResourceService.DownloadResource(string remote public string ProjectResourceCachePath => Path.Combine(options.Value.LocalResourceCachePath, currentProjectService.Project.Name); - public Task UploadResource(Guid resourceId, string localPath) + + Task IRemoteResourceService.UploadResource(Guid resourceId, string localPath) { throw new NotImplementedException(); } + + public async Task SaveFile(Stream stream, LcmFileMetadata metadata) + { + var projectResourceCachePath = ProjectResourceCachePath; + Directory.CreateDirectory(projectResourceCachePath); + var localPath = Path.Combine(projectResourceCachePath, metadata.Filename); + await using var localFile = File.OpenWrite(localPath); + await stream.CopyToAsync(localFile); + + IRemoteResourceService? remoteResourceService = null; + if (await httpClientProvider.ConnectionStatus() == ConnectionStatus.Online) remoteResourceService = this; + var resource = await resourceService.AddLocalResource( + localPath, + currentProjectService.ProjectData.ClientId, + resourceService: remoteResourceService + ); + + return resource; + } } diff --git a/backend/harmony b/backend/harmony index 6cd0630a2b..10f23e2a37 160000 --- a/backend/harmony +++ b/backend/harmony @@ -1 +1 @@ -Subproject commit 6cd0630a2be7bc4010a1ebc1c7e826054768b553 +Subproject commit 10f23e2a37f5dd8f967c0de3eb4321069a3df4b7 From d3078ec3f8a92221bd6230fdb1df1928160cde87 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 16:18:29 +0700 Subject: [PATCH 12/40] show an error for files which are too big --- .../viewer/src/lib/components/audio/AudioDialog.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte index 3465f66c09..01cea62b0d 100644 --- a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte +++ b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte @@ -22,6 +22,7 @@ let submitting = $state(false); let selectedFile = $state(); let audio = $state(); + const tooBig = $derived((audio?.size ?? 0) > 10 * 1034 * 1024); let requester: { resolve: (mediaUri: string | undefined) => void @@ -159,10 +160,12 @@ {/if} {:else} - + {#if tooBig} +

{$t`File too big`}

+ {/if} - From 1a769663f186d08f62f1a95a8f086d2f5f96277c Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 17:15:37 +0700 Subject: [PATCH 13/40] handle file saving and upload to lexbox --- .../LcmCrdt/MediaServer/IMediaServerClient.cs | 10 ++++++ .../LcmCrdt/MediaServer/LcmMediaService.cs | 32 +++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs b/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs index 6b38c24535..10443aea5c 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs @@ -7,4 +7,14 @@ public interface IMediaServerClient { [Get("/api/media/{fileId}")] Task DownloadFile(Guid fileId); + + [Post("/api/media")] + [Multipart] + Task UploadFile(MultipartItem file, + [Query] Guid projectId, + string fileId,//using a string because Refit doesn't handle a Guid properly + string? author = null, + string? filename = null); } + +public record MediaUploadFileResponse(Guid Guid); diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 77366e2af5..4c338795ca 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -69,12 +69,22 @@ public async Task GetFileStream(Guid fileId) private async Task<(Stream? stream, string? filename)> RequestMediaFile(Guid fileId) { - var httpClient = await httpClientProvider.GetHttpClient(); - var mediaClient = refitFactory.Service(httpClient); + 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("\"", "")); } + private async Task MediaServerClient() + { + var httpClient = await httpClientProvider.GetHttpClient(); + var mediaClient = refitFactory.Service(httpClient); + return mediaClient; + } + async Task IRemoteResourceService.DownloadResource(string remoteId, string localResourceCachePath) { var projectResourceCachePath = ProjectResourceCachePath; @@ -95,9 +105,16 @@ async Task IRemoteResourceService.DownloadResource(string remote Path.Combine(options.Value.LocalResourceCachePath, currentProjectService.Project.Name); - Task IRemoteResourceService.UploadResource(Guid resourceId, string localPath) + async Task IRemoteResourceService.UploadResource(Guid resourceId, string localPath) { - throw new NotImplementedException(); + var mediaClient = await MediaServerClient(); + var fileName = Path.GetFileName(localPath); + await mediaClient.UploadFile( + new FileInfoPart(new FileInfo(localPath), fileName), + projectId: currentProjectService.ProjectData.Id, + fileId: resourceId.ToString("D"), + filename: fileName); + return new UploadResult(resourceId.ToString("N")); } public async Task SaveFile(Stream stream, LcmFileMetadata metadata) @@ -105,8 +122,11 @@ public async Task SaveFile(Stream stream, LcmFileMetadata metad var projectResourceCachePath = ProjectResourceCachePath; Directory.CreateDirectory(projectResourceCachePath); var localPath = Path.Combine(projectResourceCachePath, metadata.Filename); - await using var localFile = File.OpenWrite(localPath); - await stream.CopyToAsync(localFile); + //must scope just to the copy, otherwise we can't upload the file to the server + await using (var localFile = File.OpenWrite(localPath)) + { + await stream.CopyToAsync(localFile); + } IRemoteResourceService? remoteResourceService = null; if (await httpClientProvider.ConnectionStatus() == ConnectionStatus.Online) remoteResourceService = this; From cb45b25528682c4cf7adae556832508b61aed92d Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 21 Jul 2025 17:15:56 +0700 Subject: [PATCH 14/40] expose a media files gql endpoint --- backend/LexBoxApi/GraphQL/LexQueries.cs | 10 +++ frontend/schema.graphql | 103 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs index edb7a20d19..c273a30082 100644 --- a/backend/LexBoxApi/GraphQL/LexQueries.cs +++ b/backend/LexBoxApi/GraphQL/LexQueries.cs @@ -268,6 +268,16 @@ public IQueryable UsersICanSee(UserService userService, LoggedInContext lo return org; } + [UseOffsetPaging] + [UseProjection] + [UseFiltering] + [UseSorting] + [AdminRequired] + public IQueryable MediaFiles(LexBoxDbContext context) + { + return context.Files; + } + [UseOffsetPaging] [UseProjection] [UseFiltering] diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 729a256d3c..47176f66ec 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -179,6 +179,17 @@ type FLExWsId { isDefault: Boolean! } +type FileMetadata { + sha256Hash: String + sizeInBytes: Int + fileFormat: String + mimeType: String + author: String + uploadDate: DateTime + license: MediaFileLicense + extraFields: [KeyValuePairOfStringAndObject!]! +} + type FlexProjectMetadata { projectId: UUID! lexEntryCount: Int @@ -200,6 +211,10 @@ type IsAdminResponse { value: Boolean! } +type KeyValuePairOfStringAndObject { + key: String! +} + type LastMemberCantLeaveError implements Error { message: String! } @@ -245,6 +260,24 @@ type MeDto { locale: String! } +type MediaFile { + filename: String! + projectId: UUID! + metadata: FileMetadata + id: UUID! + createdDate: DateTime! + updatedDate: DateTime! +} + +"A segment of a collection." +type MediaFilesCollectionSegment { + "Information to aid in pagination." + pageInfo: CollectionSegmentInfo! + "A flattened list of the items." + items: [MediaFile!] + totalCount: Int! @cost(weight: "10") +} + type Mutation { createOrganization(input: CreateOrganizationInput!): CreateOrganizationPayload! @cost(weight: "10") deleteOrg(input: DeleteOrgInput!): DeleteOrgPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") @@ -472,6 +505,7 @@ type Query { myOrgs(where: OrganizationFilterInput @cost(weight: "10") orderBy: [OrganizationSortInput!] @cost(weight: "10")): [Organization!]! @cost(weight: "10") usersICanSee(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersICanSeeCollectionSegment @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10") orgById(orgId: UUID!): OrgById @cost(weight: "10") + mediaFiles(skip: Int take: Int where: MediaFileFilterInput @cost(weight: "10") orderBy: [MediaFileSortInput!] @cost(weight: "10")): MediaFilesCollectionSegment @authorize(policy: "AdminRequiredPolicy") @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10") users(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersCollectionSegment @authorize(policy: "AdminRequiredPolicy") @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10") me: MeDto @cost(weight: "10") orgMemberById(orgId: UUID! userId: UUID!): OrgMemberDto @cost(weight: "10") @@ -857,6 +891,29 @@ input FeatureFlagOperationFilterInput { nin: [FeatureFlag!] @cost(weight: "10") } +input FileMetadataFilterInput { + and: [FileMetadataFilterInput!] + or: [FileMetadataFilterInput!] + sha256Hash: StringOperationFilterInput + sizeInBytes: IntOperationFilterInput + fileFormat: StringOperationFilterInput + mimeType: StringOperationFilterInput + author: StringOperationFilterInput + uploadDate: DateTimeOperationFilterInput + license: NullableOfMediaFileLicenseOperationFilterInput + extraFields: ListFilterInputTypeOfKeyValuePairOfStringAndObjectFilterInput +} + +input FileMetadataSortInput { + sha256Hash: SortEnumType @cost(weight: "10") + sizeInBytes: SortEnumType @cost(weight: "10") + fileFormat: SortEnumType @cost(weight: "10") + mimeType: SortEnumType @cost(weight: "10") + author: SortEnumType @cost(weight: "10") + uploadDate: SortEnumType @cost(weight: "10") + license: SortEnumType @cost(weight: "10") +} + input FlexProjectMetadataFilterInput { and: [FlexProjectMetadataFilterInput!] or: [FlexProjectMetadataFilterInput!] @@ -889,6 +946,12 @@ input IntOperationFilterInput { nlte: Int @cost(weight: "10") } +input KeyValuePairOfStringAndObjectFilterInput { + and: [KeyValuePairOfStringAndObjectFilterInput!] + or: [KeyValuePairOfStringAndObjectFilterInput!] + key: StringOperationFilterInput +} + input LeaveOrgInput { orgId: UUID! } @@ -911,6 +974,13 @@ input ListFilterInputTypeOfFLExWsIdFilterInput { any: Boolean @cost(weight: "10") } +input ListFilterInputTypeOfKeyValuePairOfStringAndObjectFilterInput { + all: KeyValuePairOfStringAndObjectFilterInput @cost(weight: "10") + none: KeyValuePairOfStringAndObjectFilterInput @cost(weight: "10") + some: KeyValuePairOfStringAndObjectFilterInput @cost(weight: "10") + any: Boolean @cost(weight: "10") +} + input ListFilterInputTypeOfOrgMemberFilterInput { all: OrgMemberFilterInput @cost(weight: "10") none: OrgMemberFilterInput @cost(weight: "10") @@ -939,6 +1009,33 @@ input ListFilterInputTypeOfProjectUsersFilterInput { any: Boolean @cost(weight: "10") } +input MediaFileFilterInput { + and: [MediaFileFilterInput!] + or: [MediaFileFilterInput!] + filename: StringOperationFilterInput + projectId: UuidOperationFilterInput + metadata: FileMetadataFilterInput + id: UuidOperationFilterInput + createdDate: DateTimeOperationFilterInput + updatedDate: DateTimeOperationFilterInput +} + +input MediaFileSortInput { + filename: SortEnumType @cost(weight: "10") + projectId: SortEnumType @cost(weight: "10") + metadata: FileMetadataSortInput @cost(weight: "10") + id: SortEnumType @cost(weight: "10") + createdDate: SortEnumType @cost(weight: "10") + updatedDate: SortEnumType @cost(weight: "10") +} + +input NullableOfMediaFileLicenseOperationFilterInput { + eq: MediaFileLicense @cost(weight: "10") + neq: MediaFileLicense @cost(weight: "10") + in: [MediaFileLicense] @cost(weight: "10") + nin: [MediaFileLicense] @cost(weight: "10") +} + input OrgMemberFilterInput { and: [OrgMemberFilterInput!] or: [OrgMemberFilterInput!] @@ -1252,6 +1349,12 @@ enum LexboxAuthScope { SEND_AND_RECEIVE_REFRESH } +enum MediaFileLicense { + CREATIVE_COMMONS + CREATIVE_COMMONS_SHARE_ALIKE + OTHER +} + enum OrgRole { UNKNOWN ADMIN From 49b2cd510692fae32fb7a219511ed698f35ff73b Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 22 Jul 2025 09:49:38 +0700 Subject: [PATCH 15/40] attempt to upload pending media files on sync --- backend/FwLite/FwLiteShared/Sync/SyncService.cs | 16 ++++++++++++++++ .../LcmCrdt/MediaServer/LcmMediaService.cs | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/backend/FwLite/FwLiteShared/Sync/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs index ffc38c1848..ba0611fbbf 100644 --- a/backend/FwLite/FwLiteShared/Sync/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -4,6 +4,7 @@ using FwLiteShared.Projects; using LexCore.Sync; using LcmCrdt; +using LcmCrdt.MediaServer; using LcmCrdt.RemoteSync; using LcmCrdt.Utils; using Microsoft.EntityFrameworkCore; @@ -26,6 +27,7 @@ public class SyncService( ProjectEventBus changeEventBus, LexboxProjectService lexboxProjectService, IMiniLcmApi lexboxApi, + LcmMediaService lcmMediaService, IOptions authOptions, ILogger logger, IDbContextFactory dbContextFactory) @@ -80,6 +82,8 @@ public async Task ExecuteSync(bool skipNotifications = false) UpdateSyncStatus(SyncStatus.Offline); return new SyncResults([], [], false); } + + await UploadPendingMedia(); var syncDate = DateTimeOffset.UtcNow;//create sync date first to ensure it's consistent and not based on how long it takes to sync var syncResults = await dataModel.SyncWith(remoteModel); if (!syncResults.IsSynced) @@ -139,6 +143,18 @@ public async Task GetSyncStatus() return new PendingCommits(localChanges.Value, remoteChanges); } + public async Task UploadPendingMedia() + { + try + { + await lcmMediaService.UploadPendingResources(); + } + catch (Exception e) + { + logger.LogError(e, "Failed to upload pending media"); + } + } + private void UpdateSyncStatus(SyncStatus status) { changeEventBus.PublishEvent(currentProjectService.Project, new SyncEvent(status)); diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 4c338795ca..7bb62d6031 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -138,4 +138,11 @@ public async Task SaveFile(Stream stream, LcmFileMetadata metad return resource; } + + public async Task UploadPendingResources() + { + if (await httpClientProvider.ConnectionStatus() != ConnectionStatus.Online) return false; + await resourceService.UploadPendingResources(currentProjectService.ProjectData.ClientId, this); + return true; + } } From ae326636bca5f7f8039340389965f55568030f4b Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 22 Jul 2025 09:50:58 +0700 Subject: [PATCH 16/40] update harmony --- backend/harmony | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/harmony b/backend/harmony index 10f23e2a37..4305ac0d38 160000 --- a/backend/harmony +++ b/backend/harmony @@ -1 +1 @@ -Subproject commit 10f23e2a37f5dd8f967c0de3eb4321069a3df4b7 +Subproject commit 4305ac0d38a74e80bea9286d3588c324310fadf9 From bcac0b526b9314cff8e16cab1df70da5571559df Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 22 Jul 2025 09:51:55 +0700 Subject: [PATCH 17/40] fix ambiguity --- backend/FwHeadless/LexboxFwDataMediaAdapter.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/FwHeadless/LexboxFwDataMediaAdapter.cs b/backend/FwHeadless/LexboxFwDataMediaAdapter.cs index 8b8aa3bd84..aeaaad2848 100644 --- a/backend/FwHeadless/LexboxFwDataMediaAdapter.cs +++ b/backend/FwHeadless/LexboxFwDataMediaAdapter.cs @@ -6,6 +6,7 @@ using MiniLcm; using MiniLcm.Media; using SIL.LCModel; +using MediaFile = LexCore.Entities.MediaFile; namespace FwHeadless; From 0125051698dbe5f0e78426b8da2adf53edda183e Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 22 Jul 2025 16:58:00 +0700 Subject: [PATCH 18/40] fix crash searching when an entry has a null lexeme form --- backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 2 +- .../FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index dee4ab31b9..1ecc28c3c7 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -818,7 +818,7 @@ public IAsyncEnumerable SearchEntries(string query, QueryOptions? options { if (string.IsNullOrEmpty(query)) return null; return entry => entry.CitationForm.SearchValue(query) || - entry.LexemeFormOA.Form.SearchValue(query) || + entry.LexemeFormOA?.Form.SearchValue(query) == true || entry.AllSenses.Any(s => s.Gloss.SearchValue(query)); } diff --git a/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs b/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs index e96025705c..7d52e5ccda 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs @@ -28,7 +28,8 @@ public class LexEntryFilterMapProvider : EntryFilterMapProvider public override Expression> EntrySensesGloss => (entry, ws) => entry.AllSenses.Select(s => s.PickText(s.Gloss, ws)); public override Expression> EntrySensesDefinition => (entry, ws) => entry.AllSenses.Select(s => s.PickText(s.Definition, ws)); public override Expression> EntryNote => (entry, ws) => entry.PickText(entry.Comment, ws); - public override Expression> EntryLexemeForm => (entry, ws) => entry.PickText(entry.LexemeFormOA.Form, ws); + public override Expression> EntryLexemeForm => (entry, ws) => + entry.LexemeFormOA == null ? null : entry.PickText(entry.LexemeFormOA.Form, ws); public override Expression> EntryCitationForm => (entry, ws) => entry.PickText(entry.CitationForm, ws); public override Expression> EntryLiteralMeaning => (entry, ws) => entry.PickText(entry.LiteralMeaning, ws); public override Expression> EntryComplexFormTypes => e => EmptyToNull(e.ComplexFormEntryRefs.SelectMany(r => r.ComplexEntryTypesRS)); From 195122c88d618588c31a952e16ef45342fca1dcc Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 11:59:38 +0700 Subject: [PATCH 19/40] protect against overwriting a file which already exists, delete a created file in case of an error recording the file record in the db --- .../LcmCrdt/MediaServer/LcmMediaService.cs | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 7bb62d6031..2d98c4cd57 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -5,6 +5,7 @@ using SIL.Harmony.Core; using SIL.Harmony.Resource; using LcmCrdt.RemoteSync; +using Microsoft.Extensions.Logging; using MiniLcm.Media; namespace LcmCrdt.MediaServer; @@ -14,7 +15,8 @@ public class LcmMediaService( CurrentProjectService currentProjectService, IOptions options, IRefitHttpServiceFactory refitFactory, - IServerHttpClientProvider httpClientProvider + IServerHttpClientProvider httpClientProvider, + ILogger logger ) : IRemoteResourceService { public async Task AllResources() @@ -121,22 +123,46 @@ public async Task SaveFile(Stream stream, LcmFileMetadata metad { var projectResourceCachePath = ProjectResourceCachePath; Directory.CreateDirectory(projectResourceCachePath); - var localPath = Path.Combine(projectResourceCachePath, metadata.Filename); + var localPath = Path.Combine(projectResourceCachePath, Path.GetFileName(metadata.Filename)); + localPath = EnsureUnique(localPath); //must scope just to the copy, otherwise we can't upload the file to the server - await using (var localFile = File.OpenWrite(localPath)) + await using (var localFile = File.Create(localPath)) { await stream.CopyToAsync(localFile); } - IRemoteResourceService? remoteResourceService = null; - if (await httpClientProvider.ConnectionStatus() == ConnectionStatus.Online) remoteResourceService = this; - var resource = await resourceService.AddLocalResource( - localPath, - currentProjectService.ProjectData.ClientId, - resourceService: remoteResourceService - ); + try + { + IRemoteResourceService? remoteResourceService = null; + if (await httpClientProvider.ConnectionStatus() == ConnectionStatus.Online) remoteResourceService = this; + return await resourceService.AddLocalResource( + localPath, + currentProjectService.ProjectData.ClientId, + resourceService: remoteResourceService + ); + } + catch (Exception e) + { + logger.LogError(e, "Failed to record file {Filename}", metadata.Filename); + File.Delete(localPath); + throw; + } + } - return resource; + private string EnsureUnique(string filePath) + { + if (!File.Exists(filePath)) return filePath; + var directory = Path.GetDirectoryName(filePath); + ArgumentException.ThrowIfNullOrEmpty(directory); + var filename = Path.GetFileNameWithoutExtension(filePath); + var extension = Path.GetExtension(filePath); + var counter = 1; + while (File.Exists(filePath)) + { + filePath = Path.Combine(directory, $"{filename}-{counter}{extension}"); + counter++; + } + return filePath; } public async Task UploadPendingResources() From 61e6ef9a355ebac8596764883985a2b17a132516 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 12:07:52 +0700 Subject: [PATCH 20/40] correct error in file size --- frontend/viewer/src/lib/components/audio/AudioDialog.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte index 01cea62b0d..9b5b0a394e 100644 --- a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte +++ b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte @@ -22,7 +22,7 @@ let submitting = $state(false); let selectedFile = $state(); let audio = $state(); - const tooBig = $derived((audio?.size ?? 0) > 10 * 1034 * 1024); + const tooBig = $derived((audio?.size ?? 0) > 10 * 1024 * 1024); let requester: { resolve: (mediaUri: string | undefined) => void From 731b28bea0560760800dcf1600b97a8b3d026994 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 12:14:28 +0700 Subject: [PATCH 21/40] make style consistent --- backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 1ecc28c3c7..81cc8938e2 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -1581,7 +1581,7 @@ private string TypeToLinkedFolder(string mimeType) return mimeType switch { { } s when s.StartsWith("audio/") => AudioVisualFolder, - {} s when s.StartsWith("video/") => AudioVisualFolder, + { } s when s.StartsWith("video/") => AudioVisualFolder, { } s when s.StartsWith("image/") => "Pictures", _ => "Others" }; From b868963426106cdd59725ad52d873d5fd0d32818 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 12:50:47 +0700 Subject: [PATCH 22/40] handle file not found better in FwData --- backend/FwHeadless/LexboxFwDataMediaAdapter.cs | 6 +++--- .../FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 10 +++++++--- .../FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs | 4 ++-- .../FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs | 4 ++-- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/FwHeadless/LexboxFwDataMediaAdapter.cs b/backend/FwHeadless/LexboxFwDataMediaAdapter.cs index aeaaad2848..230f0e52e6 100644 --- a/backend/FwHeadless/LexboxFwDataMediaAdapter.cs +++ b/backend/FwHeadless/LexboxFwDataMediaAdapter.cs @@ -20,10 +20,10 @@ public MediaUri MediaUriFromPath(string path, LcmCache cache) return MediaUriForMediaFile(mediaFileService.FindMediaFile(config.Value.LexboxProjectId(cache), fullPath)); } - public string PathFromMediaUri(MediaUri mediaUri, LcmCache cache) + public string? PathFromMediaUri(MediaUri mediaUri, LcmCache cache) { - var mediaFile = mediaFileService.FindMediaFile(mediaUri.FileId) ?? - throw new NotFoundException($"Unable to find file {mediaUri.FileId}.", nameof(MediaFile)); + var mediaFile = mediaFileService.FindMediaFile(mediaUri.FileId); + if (mediaFile is null) return null; var fullFilePath = Path.Join(cache.ProjectId.ProjectFolder, mediaFile.Filename); return Path.GetRelativePath(cache.LangProject.LinkedFilesRootDir, fullFilePath); } diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 81cc8938e2..1012a902f6 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -722,10 +722,12 @@ private string ToMediaUri(string tsString) return mediaAdapter.MediaUriFromPath(Path.Combine(AudioVisualFolder, tsString), Cache).ToString(); } - internal string FromMediaUri(string mediaUri) + internal string FromMediaUri(string mediaUriString) { //path includes `AudioVisual` currently - var path = mediaAdapter.PathFromMediaUri(new MediaUri(mediaUri), Cache); + MediaUri mediaUri = new MediaUri(mediaUriString); + var path = mediaAdapter.PathFromMediaUri(mediaUri, Cache); + if (path is null) throw new NotFoundException($"Unable to find file {mediaUri.FileId}.", nameof(MediaFile)); return Path.GetRelativePath(AudioVisualFolder, path); } @@ -1537,7 +1539,9 @@ private static void ValidateOwnership(ILexExampleSentence lexExampleSentence, Gu public Task GetFileStream(MediaUri mediaUri) { if (mediaUri == MediaUri.NotFound) return Task.FromResult(new ReadFileResponse(ReadFileResult.NotFound)); - string fullPath = Path.Combine(Cache.LangProject.LinkedFilesRootDir, mediaAdapter.PathFromMediaUri(mediaUri, Cache)); + var pathFromMediaUri = mediaAdapter.PathFromMediaUri(mediaUri, Cache); + if (pathFromMediaUri is not {Length: > 0}) return Task.FromResult(new ReadFileResponse(ReadFileResult.NotFound)); + string fullPath = Path.Combine(Cache.LangProject.LinkedFilesRootDir, pathFromMediaUri); if (!File.Exists(fullPath)) return Task.FromResult(new ReadFileResponse(ReadFileResult.NotFound)); return Task.FromResult(new ReadFileResponse(File.OpenRead(fullPath), Path.GetFileName(fullPath))); } diff --git a/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs b/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs index eee31261b1..30196c1a42 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs @@ -18,6 +18,6 @@ public interface IMediaAdapter ///
/// /// - /// the path to the file represented by the mediaUri, relative to the LinkedFiles directory in the given project - string PathFromMediaUri(MediaUri mediaUri, LcmCache cache); + /// the path to the file represented by the mediaUri, relative to the LinkedFiles directory in the given project, will return null when it can't find the file + string? PathFromMediaUri(MediaUri mediaUri, LcmCache cache); } diff --git a/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs b/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs index c55aed772c..e14c7f6a1c 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs @@ -42,7 +42,7 @@ private static MediaUri PathToUri(string path) return new MediaUri(NewGuidV5(path), LocalMediaAuthority); } - public string PathFromMediaUri(MediaUri mediaUri, LcmCache cache) + public string? PathFromMediaUri(MediaUri mediaUri, LcmCache cache) { var paths = Paths(cache); if (mediaUri.Authority != LocalMediaAuthority) throw new ArgumentException("MediaUri must be local", nameof(mediaUri)); @@ -51,7 +51,7 @@ public string PathFromMediaUri(MediaUri mediaUri, LcmCache cache) return path; } - throw new NotFoundException("Media not found: " + mediaUri.FileId, "MedaiUri"); + return null; } // produces the same Guid for the same input name From dd90c61bdd53fc813630ffdac53d480fd3ed96b8 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 13:16:37 +0700 Subject: [PATCH 23/40] write media tests base --- .../FwLite/MiniLcm.Tests/MediaTestsBase.cs | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs diff --git a/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs b/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs new file mode 100644 index 0000000000..088d8d15d9 --- /dev/null +++ b/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs @@ -0,0 +1,182 @@ +using System.Text; +using MiniLcm.Media; +using Xunit.Abstractions; + +namespace MiniLcm.Tests; + +public abstract class MediaTestsBase : MiniLcmTestBase +{ + protected readonly ITestOutputHelper Output; + + protected MediaTestsBase(ITestOutputHelper output) + { + Output = output; + } + + [Fact] + public async Task FileOperations_TextFile_RoundTripSuccess() + { + // Arrange + const string testContent = "This is a test file content with special characters: áéíóú, 中文, 🚀"; + const string fileName = "test-file.txt"; + const string mimeType = "text/plain"; + const string author = "Test Author"; + var uploadDate = DateTimeOffset.UtcNow; + + var originalBytes = Encoding.UTF8.GetBytes(testContent); + var metadata = new LcmFileMetadata(fileName, mimeType, author, uploadDate); + + // Act - Save the file + UploadFileResponse saveResponse; + await using (var saveStream = new MemoryStream(originalBytes)) + { + saveResponse = await Api.SaveFile(saveStream, metadata); + } + + saveResponse.Result.Should().BeOneOf(UploadFileResult.SavedLocally, UploadFileResult.SavedToLexbox); + saveResponse.ErrorMessage.Should().BeNullOrEmpty(); + saveResponse.MediaUri.Should().NotBeNull(); + + // Act - Retrieve the file + var retrieveResponse = await Api.GetFileStream(saveResponse.MediaUri.Value); + + // Assert - Verify retrieval was successful + retrieveResponse.Result.Should().Be(ReadFileResult.Success); + retrieveResponse.ErrorMessage.Should().BeNullOrEmpty(); + retrieveResponse.Stream.Should().NotBeNull(); + retrieveResponse.FileName.Should().Be(fileName); + + // Assert - Verify content integrity + byte[] retrievedBytes; + await using (retrieveResponse.Stream) + { + using var memoryStream = new MemoryStream(); + await retrieveResponse.Stream.CopyToAsync(memoryStream); + retrievedBytes = memoryStream.ToArray(); + } + + retrievedBytes.Length.Should().Be(originalBytes.Length, + "Retrieved binary content should have the same length as original"); + retrievedBytes.Should() + .BeEquivalentTo(originalBytes, "Retrieved content should match original content exactly"); + + var retrievedContent = Encoding.UTF8.GetString(retrievedBytes); + retrievedContent.Should().Be(testContent, "Retrieved text content should match original text content"); + } + + [Fact] + public async Task FileOperations_BinaryFile_RoundTripSuccess() + { + // Arrange - Create test binary data (simulating a small image or audio file) + var originalBytes = new byte[1024]; + var random = new Random(42); // Use fixed seed for reproducible tests + random.NextBytes(originalBytes); + + const string fileName = "test-binary-file.dat"; + const string mimeType = "application/octet-stream"; + const string author = "Binary Test Author"; + var uploadDate = DateTimeOffset.UtcNow; + + var metadata = new LcmFileMetadata(fileName, mimeType, author, uploadDate); + + // Act - Save the binary file + UploadFileResponse saveResponse; + await using (var saveStream = new MemoryStream(originalBytes)) + { + saveResponse = await Api.SaveFile(saveStream, metadata); + } + + saveResponse.Result.Should().BeOneOf(UploadFileResult.SavedLocally, UploadFileResult.SavedToLexbox); + saveResponse.ErrorMessage.Should().BeNullOrEmpty(); + saveResponse.MediaUri.Should().NotBeNull(); + + // Act - Retrieve the binary file + var retrieveResponse = await Api.GetFileStream(saveResponse.MediaUri.Value); + + // Assert - Verify retrieval was successful + retrieveResponse.Result.Should().Be(ReadFileResult.Success); + retrieveResponse.ErrorMessage.Should().BeNullOrEmpty(); + retrieveResponse.Stream.Should().NotBeNull(); + retrieveResponse.FileName.Should().Be(fileName); + + // Assert - Verify binary content integrity + byte[] retrievedBytes; + await using (var retrievedStream = retrieveResponse.Stream) + { + using var memoryStream = new MemoryStream(); + await retrievedStream.CopyToAsync(memoryStream); + retrievedBytes = memoryStream.ToArray(); + } + + retrievedBytes.Length.Should().Be(originalBytes.Length, + "Retrieved binary content should have the same length as original"); + retrievedBytes.Should().BeEquivalentTo(originalBytes, + "Retrieved binary content should match original binary content exactly"); + } + + [Fact] + public async Task FileOperations_FileTooLarge_FailsGracefully() + { + // Arrange - Create test binary data (simulating a small image or audio file) + var originalBytes = new byte[1024 * 1024 * 25]; // 25 MB + var random = new Random(42); // Use fixed seed for reproducible tests + random.NextBytes(originalBytes); + + var saveResponse = await SaveTestFile("test-binary-file.dat", originalBytes); + + saveResponse.Result.Should().Be(UploadFileResult.TooBig); + saveResponse.ErrorMessage.Should().NotBeNullOrEmpty(); + saveResponse.MediaUri.Should().BeNull(); + } + + [Fact] + public async Task FileOperations_FileNameConflict_HandlesGracefully() + { + + const string fileName = "test-binary-file.dat"; + + var saveResponse = await SaveTestFile(fileName); + var firstMediaUri = saveResponse.MediaUri; + saveResponse.Result.Should().Be(UploadFileResult.SavedLocally); + + // Act - Save a different file with the same name + saveResponse = await SaveTestFile(fileName); + saveResponse.Result.Should().Be(UploadFileResult.SavedLocally); + saveResponse.MediaUri.Should().NotBe(firstMediaUri); + var retrieveResponse = await Api.GetFileStream(saveResponse.MediaUri.Value); + retrieveResponse.FileName.Should().Be("test-binary-file-1.dat"); + retrieveResponse.Stream?.Dispose(); + } + + private async Task SaveTestFile(string fileName, byte[]? originalBytes = null, string mimeType = "application/octet-stream") + { + const string author = "Test Author"; + var uploadDate = DateTimeOffset.UtcNow; + if (originalBytes is null) Random.Shared.NextBytes(originalBytes = new byte[1024]); + + var metadata = new LcmFileMetadata(fileName, mimeType, author, uploadDate); + + // Act - Save the binary file + UploadFileResponse saveResponse; + await using (var saveStream = new MemoryStream(originalBytes)) + { + saveResponse = await Api.SaveFile(saveStream, metadata); + } + + return saveResponse; + } + + + [Fact] + public async Task GetFileStream_NonExistentFile_HandlesGracefully() + { + // Test retrieving a non-existent file + var nonExistentMediaUri = new MediaUri(Guid.NewGuid(), "localhost");//fw only supports localhost + var response = await Api.GetFileStream(nonExistentMediaUri); + + // Should handle gracefully without throwing + response.Should().NotBeNull(); + response.Result.Should().BeOneOf(ReadFileResult.NotFound, ReadFileResult.Offline); + response.Stream.Should().BeNull(); + } +} From 40352b00efcd8afc73e22421c603f72eabc99dde Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 13:21:58 +0700 Subject: [PATCH 24/40] implement media tests for Fwdata and harmony, and handle file path issues in tests --- .../Fixtures/FwDataTestsKernel.cs | 2 ++ .../Fixtures/ProjectLoaderFixture.cs | 4 ++- .../MiniLcmTests/MediaTests.cs | 35 +++++++++++++++++++ .../FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs | 12 +++++-- .../LcmCrdt.Tests/MiniLcmTests/MediaTests.cs | 20 +++++++++++ 5 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs create mode 100644 backend/FwLite/LcmCrdt.Tests/MiniLcmTests/MediaTests.cs diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs index 009aaa4b39..d53c9b764e 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs @@ -11,6 +11,8 @@ public static IServiceCollection AddTestFwDataBridge(this IServiceCollection ser { services.AddFwDataBridge(); services.TryAddSingleton(_ => new ConfigurationRoot([])); + //this path is typically not used for projects (they're in memory) but it is used for media + services.Configure(config => config.ProjectsFolder = Path.GetFullPath(Path.Combine(".", "fw-test-projects"))); if (mockProjectLoader) { services.AddSingleton(); diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs index a9de985c5f..2f673d2ee6 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs @@ -15,7 +15,9 @@ public class ProjectLoaderFixture : IDisposable public ProjectLoaderFixture() { //todo make mock of IProjectLoader so we can load from test projects - var provider = new ServiceCollection().AddTestFwDataBridge().BuildServiceProvider(); + var provider = new ServiceCollection() + .AddTestFwDataBridge() + .BuildServiceProvider(); _serviceProvider = provider; _fwDataFactory = provider.GetRequiredService(); MockFwProjectLoader = provider.GetRequiredService(); diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs new file mode 100644 index 0000000000..8484e341e1 --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs @@ -0,0 +1,35 @@ +using FwDataMiniLcmBridge.Api; +using FwDataMiniLcmBridge.Tests.Fixtures; +using Xunit.Abstractions; + +namespace FwDataMiniLcmBridge.Tests.MiniLcmTests; + +[Collection(ProjectLoaderFixture.Name)] +public class MediaTests : MediaTestsBase +{ + private readonly ProjectLoaderFixture _fixture; + + public MediaTests(ProjectLoaderFixture fixture, ITestOutputHelper output) : base(output) + { + _fixture = fixture; + } + + protected override Task NewApi() + { + return Task.FromResult(_fixture.NewProjectApi("media-test", "en", "en")); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + var projectFolder = ((FwDataMiniLcmApi)Api).Cache.LangProject.LinkedFilesRootDir; + Directory.CreateDirectory(projectFolder); + } + + public override async Task DisposeAsync() + { + var projectFolder = ((FwDataMiniLcmApi)Api).Cache.ProjectId.ProjectFolder; + if (Directory.Exists(projectFolder)) Directory.Delete(projectFolder, true); + await base.DisposeAsync(); + } +} diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index 57866a4dcd..88c27214bd 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using LcmCrdt.MediaServer; using Meziantou.Extensions.Logging.Xunit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -40,6 +41,11 @@ private MiniLcmApiFixture(bool seedWs = true) } public async Task InitializeAsync() + { + await InitializeAsync("sena-3"); + } + + public async Task InitializeAsync(string projectName) { var db = $"file:{Guid.NewGuid():N}?mode=memory&cache=shared" ; if (Debugger.IsAttached) @@ -51,7 +57,7 @@ public async Task InitializeAsync() } } - var crdtProject = new CrdtProject("sena-3", db); + var crdtProject = new CrdtProject(projectName, db); var services = new ServiceCollection() .AddTestLcmCrdtClient(crdtProject) .AddLogging(builder => builder.AddDebug() @@ -64,7 +70,7 @@ public async Task InitializeAsync() await _crdtDbContext.Database.OpenConnectionAsync(); //can't use ProjectsService.CreateProject because it opens and closes the db context, this would wipe out the in memory db. await CrdtProjectsService.InitProjectDb(_crdtDbContext, - new ProjectData("Sena 3", "sena-3", Guid.NewGuid(), null, Guid.NewGuid())); + new ProjectData("Sena 3", projectName, Guid.NewGuid(), null, Guid.NewGuid())); await _services.ServiceProvider.GetRequiredService().RefreshProjectData(); if (_seedWs) { @@ -122,6 +128,8 @@ public ILogger CreateLogger(string categoryName) public async Task DisposeAsync() { + var projectResourceCachePath = _services.ServiceProvider.GetRequiredService().ProjectResourceCachePath; + if (Directory.Exists(projectResourceCachePath)) Directory.Delete(projectResourceCachePath, true); await (_crdtDbContext?.DisposeAsync() ?? ValueTask.CompletedTask); await _services.DisposeAsync(); } diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/MediaTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/MediaTests.cs new file mode 100644 index 0000000000..2641b03c2c --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/MediaTests.cs @@ -0,0 +1,20 @@ +using Xunit.Abstractions; + +namespace LcmCrdt.Tests.MiniLcmTests; + +public class MediaTests(ITestOutputHelper output) : MediaTestsBase(output) +{ + private readonly MiniLcmApiFixture _fixture = new(); + + protected override async Task NewApi() + { + await _fixture.InitializeAsync("media-test"); + return _fixture.Api; + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _fixture.DisposeAsync(); + } +} From 26d2bc44a0f59bfdd2292a1f74b6fd1f5588dd90 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 13:43:06 +0700 Subject: [PATCH 25/40] properly handle duplicate filenames by returning a message that the file already exists --- .../FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 12 ++++++++---- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 6 ++++-- .../FwLite/LcmCrdt/MediaServer/LcmMediaService.cs | 11 ++++++----- backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs | 8 ++------ backend/FwLite/MiniLcm/Media/MediaFile.cs | 5 ++++- backend/FwLite/MiniLcm/Media/UploadFileResponse.cs | 9 +++++++-- 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 1012a902f6..6a2f7d9300 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -1548,9 +1548,13 @@ public Task GetFileStream(MediaUri mediaUri) public async Task SaveFile(Stream stream, LcmFileMetadata metadata) { + if (stream.CanSeek && stream.Length > MediaFile.MaxFileSize) return new UploadFileResponse(UploadFileResult.TooBig); var pathRelativeToRoot = Path.Combine(TypeToLinkedFolder(metadata.MimeType), Path.GetFileName(metadata.Filename)); var fullPath = Path.Combine(Cache.LangProject.LinkedFilesRootDir, pathRelativeToRoot); - if (File.Exists(fullPath)) return new UploadFileResponse(UploadFileResult.AlreadyExists); + if (File.Exists(fullPath)) + return new UploadFileResponse(mediaAdapter.MediaUriFromPath(pathRelativeToRoot, Cache), + savedToLexbox: false, + newResource: false); var directory = Path.GetDirectoryName(fullPath); if (directory is not null) { @@ -1569,15 +1573,15 @@ public async Task SaveFile(Stream stream, LcmFileMetadata me { await using var fileStream = File.OpenWrite(fullPath); await stream.CopyToAsync(fileStream); + + var mediaUri = mediaAdapter.MediaUriFromPath(pathRelativeToRoot, Cache); + return new UploadFileResponse(mediaUri, savedToLexbox: false, newResource: true); } catch (Exception ex) { logger.LogError(ex, "Failed to save file {Filename} to {Path}", metadata.Filename, fullPath); return new UploadFileResponse($"Failed to save file: {ex.Message}"); } - - var mediaUri = mediaAdapter.MediaUriFromPath(pathRelativeToRoot, Cache); - return new UploadFileResponse(mediaUri, false); } private string TypeToLinkedFolder(string mimeType) diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index aa76cb51a2..6554a5ed7b 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -704,8 +704,10 @@ public async Task SaveFile(Stream stream, LcmFileMetadata me { try { - var result = await lcmMediaService.SaveFile(stream, metadata); - return new UploadFileResponse(new MediaUri(result.Id, ProjectData.ServerId ?? "lexbox.org"), result.Remote); + if (stream.CanSeek && stream.Length > MediaFile.MaxFileSize) return new UploadFileResponse(UploadFileResult.TooBig); + var (result, newResource) = await lcmMediaService.SaveFile(stream, metadata); + var mediaUri = new MediaUri(result.Id, ProjectData.ServerId ?? "lexbox.org"); + return new UploadFileResponse(mediaUri, savedToLexbox: result.Remote, newResource); } catch (Exception e) { diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 2d98c4cd57..d4cec3d0e0 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -97,7 +97,8 @@ async Task IRemoteResourceService.DownloadResource(string remote { filename = Path.GetFileName(filename); var localPath = Path.Combine(projectResourceCachePath, filename ?? remoteId); - await using var localFile = File.OpenWrite(localPath); + localPath = EnsureUnique(localPath); + await using var localFile = File.Create(localPath); await stream.CopyToAsync(localFile); return new DownloadResult(localPath); } @@ -119,12 +120,12 @@ await mediaClient.UploadFile( return new UploadResult(resourceId.ToString("N")); } - public async Task SaveFile(Stream stream, LcmFileMetadata metadata) + public async Task<(HarmonyResource resource, bool newResource)> SaveFile(Stream stream, LcmFileMetadata metadata) { var projectResourceCachePath = ProjectResourceCachePath; Directory.CreateDirectory(projectResourceCachePath); var localPath = Path.Combine(projectResourceCachePath, Path.GetFileName(metadata.Filename)); - localPath = EnsureUnique(localPath); + if (File.Exists(localPath)) return ((await resourceService.AllResources()).First(r => r.LocalPath == localPath), newResource: false); //must scope just to the copy, otherwise we can't upload the file to the server await using (var localFile = File.Create(localPath)) { @@ -135,11 +136,11 @@ public async Task SaveFile(Stream stream, LcmFileMetadata metad { IRemoteResourceService? remoteResourceService = null; if (await httpClientProvider.ConnectionStatus() == ConnectionStatus.Online) remoteResourceService = this; - return await resourceService.AddLocalResource( + return (await resourceService.AddLocalResource( localPath, currentProjectService.ProjectData.ClientId, resourceService: remoteResourceService - ); + ), newResource: true); } catch (Exception e) { diff --git a/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs b/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs index 088d8d15d9..a92582271c 100644 --- a/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs @@ -125,7 +125,6 @@ public async Task FileOperations_FileTooLarge_FailsGracefully() var saveResponse = await SaveTestFile("test-binary-file.dat", originalBytes); saveResponse.Result.Should().Be(UploadFileResult.TooBig); - saveResponse.ErrorMessage.Should().NotBeNullOrEmpty(); saveResponse.MediaUri.Should().BeNull(); } @@ -141,11 +140,8 @@ public async Task FileOperations_FileNameConflict_HandlesGracefully() // Act - Save a different file with the same name saveResponse = await SaveTestFile(fileName); - saveResponse.Result.Should().Be(UploadFileResult.SavedLocally); - saveResponse.MediaUri.Should().NotBe(firstMediaUri); - var retrieveResponse = await Api.GetFileStream(saveResponse.MediaUri.Value); - retrieveResponse.FileName.Should().Be("test-binary-file-1.dat"); - retrieveResponse.Stream?.Dispose(); + saveResponse.Result.Should().Be(UploadFileResult.AlreadyExists); + saveResponse.MediaUri.Should().Be(firstMediaUri); } private async Task SaveTestFile(string fileName, byte[]? originalBytes = null, string mimeType = "application/octet-stream") diff --git a/backend/FwLite/MiniLcm/Media/MediaFile.cs b/backend/FwLite/MiniLcm/Media/MediaFile.cs index dc81026bbb..06b78679bd 100644 --- a/backend/FwLite/MiniLcm/Media/MediaFile.cs +++ b/backend/FwLite/MiniLcm/Media/MediaFile.cs @@ -2,4 +2,7 @@ namespace MiniLcm.Media; -public record MediaFile(MediaUri Uri, LcmFileMetadata Metadata); +public record MediaFile(MediaUri Uri, LcmFileMetadata Metadata) +{ + public const int MaxFileSize = 10 * 1024 * 1024; // 10MB +} diff --git a/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs b/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs index 7dad66ff68..1b47542d3f 100644 --- a/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs +++ b/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs @@ -4,10 +4,15 @@ namespace MiniLcm.Media; public record UploadFileResponse { - public UploadFileResponse(MediaUri mediaUri, bool savedToLexbox) + public UploadFileResponse(MediaUri mediaUri, bool savedToLexbox, bool newResource) { MediaUri = mediaUri; - Result = savedToLexbox ? UploadFileResult.SavedToLexbox : UploadFileResult.SavedLocally; + Result = (savedToLexbox, newResource) switch + { + (_, false) => UploadFileResult.AlreadyExists, + (true, true) => UploadFileResult.SavedToLexbox, + (false, true) => UploadFileResult.SavedLocally, + }; } public UploadFileResponse(UploadFileResult result) From 79af5871fdb05892e0819aa64e8e9cd4beb494a9 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 13:44:19 +0700 Subject: [PATCH 26/40] remove unused parameter --- .../FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs | 3 +-- backend/FwLite/LcmCrdt.Tests/MiniLcmTests/MediaTests.cs | 4 +--- backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs | 8 -------- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs index 8484e341e1..b56ac52bda 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs @@ -1,6 +1,5 @@ using FwDataMiniLcmBridge.Api; using FwDataMiniLcmBridge.Tests.Fixtures; -using Xunit.Abstractions; namespace FwDataMiniLcmBridge.Tests.MiniLcmTests; @@ -9,7 +8,7 @@ public class MediaTests : MediaTestsBase { private readonly ProjectLoaderFixture _fixture; - public MediaTests(ProjectLoaderFixture fixture, ITestOutputHelper output) : base(output) + public MediaTests(ProjectLoaderFixture fixture) { _fixture = fixture; } diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/MediaTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/MediaTests.cs index 2641b03c2c..2f02e42996 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/MediaTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/MediaTests.cs @@ -1,8 +1,6 @@ -using Xunit.Abstractions; - namespace LcmCrdt.Tests.MiniLcmTests; -public class MediaTests(ITestOutputHelper output) : MediaTestsBase(output) +public class MediaTests : MediaTestsBase { private readonly MiniLcmApiFixture _fixture = new(); diff --git a/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs b/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs index a92582271c..17c2d015c1 100644 --- a/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs @@ -1,18 +1,10 @@ using System.Text; using MiniLcm.Media; -using Xunit.Abstractions; namespace MiniLcm.Tests; public abstract class MediaTestsBase : MiniLcmTestBase { - protected readonly ITestOutputHelper Output; - - protected MediaTestsBase(ITestOutputHelper output) - { - Output = output; - } - [Fact] public async Task FileOperations_TextFile_RoundTripSuccess() { From fb5671f8f739c3145989589d83a5dd7a42bd0041 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 14:18:15 +0700 Subject: [PATCH 27/40] change MediaAdapter to work off full file paths --- backend/FwHeadless/LexboxFwDataMediaAdapter.cs | 8 ++++---- .../Fixtures/ProjectLoaderFixture.cs | 1 + .../FwDataMiniLcmBridge.Tests/MediaFileTests.cs | 11 +++++++---- .../FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 16 +++++++--------- .../FwDataMiniLcmBridge/Media/IMediaAdapter.cs | 4 ++-- .../Media/LocalMediaAdapter.cs | 15 +++++++++++++-- .../Testing/FwHeadless/MediaFileServiceTests.cs | 17 ++++++++--------- 7 files changed, 42 insertions(+), 30 deletions(-) diff --git a/backend/FwHeadless/LexboxFwDataMediaAdapter.cs b/backend/FwHeadless/LexboxFwDataMediaAdapter.cs index 230f0e52e6..25c4da144b 100644 --- a/backend/FwHeadless/LexboxFwDataMediaAdapter.cs +++ b/backend/FwHeadless/LexboxFwDataMediaAdapter.cs @@ -15,9 +15,9 @@ public class LexboxFwDataMediaAdapter(IOptions config, MediaFi { public MediaUri MediaUriFromPath(string path, LcmCache cache) { - var fullPath = Path.Join(cache.LangProject.LinkedFilesRootDir, path); - if (!File.Exists(fullPath)) return MediaUri.NotFound; - return MediaUriForMediaFile(mediaFileService.FindMediaFile(config.Value.LexboxProjectId(cache), fullPath)); + if (!Path.IsPathRooted(path)) throw new ArgumentException("Path must be absolute, " + path, nameof(path)); + if (!File.Exists(path)) return MediaUri.NotFound; + return MediaUriForMediaFile(mediaFileService.FindMediaFile(config.Value.LexboxProjectId(cache), path)); } public string? PathFromMediaUri(MediaUri mediaUri, LcmCache cache) @@ -25,7 +25,7 @@ public MediaUri MediaUriFromPath(string path, LcmCache cache) var mediaFile = mediaFileService.FindMediaFile(mediaUri.FileId); if (mediaFile is null) return null; var fullFilePath = Path.Join(cache.ProjectId.ProjectFolder, mediaFile.Filename); - return Path.GetRelativePath(cache.LangProject.LinkedFilesRootDir, fullFilePath); + return fullFilePath; } private MediaUri MediaUriForMediaFile(MediaFile mediaFile) diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs index 2f673d2ee6..5573c7fdd6 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs @@ -11,6 +11,7 @@ public class ProjectLoaderFixture : IDisposable private readonly ServiceProvider _serviceProvider; private readonly IOptions _config; public MockFwProjectLoader MockFwProjectLoader { get; } + public IServiceProvider Services => _serviceProvider; public ProjectLoaderFixture() { diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs index a8c2c69614..6358232f23 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs @@ -1,6 +1,7 @@ using FwDataMiniLcmBridge.Api; using FwDataMiniLcmBridge.Media; using FwDataMiniLcmBridge.Tests.Fixtures; +using Microsoft.Extensions.DependencyInjection; using MiniLcm.Media; using MiniLcm.Models; using SIL.LCModel.Infrastructure; @@ -12,9 +13,11 @@ public class MediaFileTests : IAsyncLifetime { private readonly FwDataMiniLcmApi _api; private readonly WritingSystemId _audioWs = "en-Zxxx-x-audio"; + private IMediaAdapter _mediaAdapter; public MediaFileTests(ProjectLoaderFixture fixture) { + _mediaAdapter = fixture.Services.GetRequiredService(); _api = fixture.NewProjectApi("media-file-test", "en", "en"); } @@ -61,10 +64,10 @@ private async Task AddFileDirectly(string fileName, string? contents, bool private async Task StoreFileContentsAsync(string fileName, string? contents) { - var fwFilePath = Path.Combine(FwDataMiniLcmApi.AudioVisualFolder, fileName); - var filePath = Path.Combine(_api.Cache.LangProject.LinkedFilesRootDir, fwFilePath); + var filePath = Path.Combine(_api.Cache.LangProject.LinkedFilesRootDir, FwDataMiniLcmApi.AudioVisualFolder, fileName); await File.WriteAllTextAsync(filePath, contents); - return LocalMediaAdapter.NewGuidV5(fwFilePath); + //using media adapter to ensure it's cache is updated with the new file + return _mediaAdapter.MediaUriFromPath(filePath, _api.Cache).FileId; } private string GetFwAudioValue(Guid id) @@ -79,7 +82,7 @@ private string GetFwAudioValue(Guid id) public async Task GetEntry_MapsFilePathsFromAudioWs() { var fileName = "MapsAFileReferenceIntoAMediaUri.txt"; - var fileGuid = LocalMediaAdapter.NewGuidV5(Path.Combine(FwDataMiniLcmApi.AudioVisualFolder, fileName)); + var fileGuid = LocalMediaAdapter.NewGuidV5(Path.Combine(_api.Cache.LangProject.LinkedFilesRootDir, FwDataMiniLcmApi.AudioVisualFolder, fileName)); var entryId = await AddFileDirectly(fileName, "test"); var entry = await _api.GetEntry(entryId); diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 6a2f7d9300..21b82099d9 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -719,7 +719,8 @@ private string ToMediaUri(string tsString) //rooted media paths aren't supported if (Path.IsPathRooted(tsString)) throw new ArgumentException("Media path must be relative", nameof(tsString)); - return mediaAdapter.MediaUriFromPath(Path.Combine(AudioVisualFolder, tsString), Cache).ToString(); + var fullFilePath = Path.Join(Cache.LangProject.LinkedFilesRootDir, AudioVisualFolder, tsString); + return mediaAdapter.MediaUriFromPath(fullFilePath, Cache).ToString(); } internal string FromMediaUri(string mediaUriString) @@ -728,7 +729,7 @@ internal string FromMediaUri(string mediaUriString) MediaUri mediaUri = new MediaUri(mediaUriString); var path = mediaAdapter.PathFromMediaUri(mediaUri, Cache); if (path is null) throw new NotFoundException($"Unable to find file {mediaUri.FileId}.", nameof(MediaFile)); - return Path.GetRelativePath(AudioVisualFolder, path); + return Path.GetRelativePath(Path.Join(Cache.LangProject.LinkedFilesRootDir, AudioVisualFolder), path); } internal RichString? ToRichString(ITsString? tsString) @@ -1549,12 +1550,11 @@ public Task GetFileStream(MediaUri mediaUri) public async Task SaveFile(Stream stream, LcmFileMetadata metadata) { if (stream.CanSeek && stream.Length > MediaFile.MaxFileSize) return new UploadFileResponse(UploadFileResult.TooBig); - var pathRelativeToRoot = Path.Combine(TypeToLinkedFolder(metadata.MimeType), Path.GetFileName(metadata.Filename)); - var fullPath = Path.Combine(Cache.LangProject.LinkedFilesRootDir, pathRelativeToRoot); + var fullPath = Path.Combine(Cache.LangProject.LinkedFilesRootDir, TypeToLinkedFolder(metadata.MimeType), Path.GetFileName(metadata.Filename)); + var mediaUri = mediaAdapter.MediaUriFromPath(fullPath, Cache); + if (File.Exists(fullPath)) - return new UploadFileResponse(mediaAdapter.MediaUriFromPath(pathRelativeToRoot, Cache), - savedToLexbox: false, - newResource: false); + return new UploadFileResponse(mediaUri, savedToLexbox: false, newResource: false); var directory = Path.GetDirectoryName(fullPath); if (directory is not null) { @@ -1573,8 +1573,6 @@ public async Task SaveFile(Stream stream, LcmFileMetadata me { await using var fileStream = File.OpenWrite(fullPath); await stream.CopyToAsync(fileStream); - - var mediaUri = mediaAdapter.MediaUriFromPath(pathRelativeToRoot, Cache); return new UploadFileResponse(mediaUri, savedToLexbox: false, newResource: true); } catch (Exception ex) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs b/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs index 30196c1a42..fd03092148 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs @@ -9,7 +9,7 @@ public interface IMediaAdapter /// /// get the MediaUri representing a file, can be used later to get the path back /// - /// the path relative to LinkedFiles to find the file at + /// the full file path must be inside the project LinkedFiles directory /// the current project /// a media uri which can later be used to get the path MediaUri MediaUriFromPath(string path, LcmCache cache); @@ -18,6 +18,6 @@ public interface IMediaAdapter /// /// /// - /// the path to the file represented by the mediaUri, relative to the LinkedFiles directory in the given project, will return null when it can't find the file + /// the full path to the file represented by the mediaUri, will return null when it can't find the file string? PathFromMediaUri(MediaUri mediaUri, LcmCache cache); } diff --git a/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs b/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs index e14c7f6a1c..46801c2e1a 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs @@ -22,7 +22,6 @@ private Dictionary Paths(LcmCache cache) entry.SlidingExpiration = TimeSpan.FromMinutes(10); return Directory .EnumerateFiles(cache.LangProject.LinkedFilesRootDir, "*", SearchOption.AllDirectories) - .Select(file => Path.GetRelativePath(cache.LangProject.LinkedFilesRootDir, file)) .ToDictionary(file => PathToUri(file).FileId, file => file); }) ?? throw new Exception("Failed to get paths"); } @@ -30,13 +29,25 @@ private Dictionary Paths(LcmCache cache) //path is expected to be relative to the LinkedFilesRootDir public MediaUri MediaUriFromPath(string path, LcmCache cache) { - if (!File.Exists(Path.Combine(cache.LangProject.LinkedFilesRootDir, path))) return MediaUri.NotFound; + EnsureCorrectRootFolder(path, cache); + if (!File.Exists(path)) return MediaUri.NotFound; var uri = PathToUri(path); //this may be a new file, so we need to add it to the cache Paths(cache)[uri.FileId] = path; return uri; } + private void EnsureCorrectRootFolder(string path, LcmCache cache) + { + if (Path.IsPathRooted(path)) + { + if (path.StartsWith(cache.LangProject.LinkedFilesRootDir)) return; + throw new ArgumentException("Path must be in the LinkedFilesRootDir", nameof(path)); + } + + throw new ArgumentException("Path must be absolute, " + path, nameof(path)); + } + private static MediaUri PathToUri(string path) { return new MediaUri(NewGuidV5(path), LocalMediaAuthority); diff --git a/backend/Testing/FwHeadless/MediaFileServiceTests.cs b/backend/Testing/FwHeadless/MediaFileServiceTests.cs index 3062fccd4d..1189c398cb 100644 --- a/backend/Testing/FwHeadless/MediaFileServiceTests.cs +++ b/backend/Testing/FwHeadless/MediaFileServiceTests.cs @@ -11,6 +11,7 @@ using MiniLcm.Media; using SIL.LCModel; using Testing.Fixtures; +using MediaFile = LexCore.Entities.MediaFile; namespace Testing.FwHeadless; @@ -52,13 +53,6 @@ public void Dispose() _lexBoxDbContext.Files.ExecuteDelete(); } - private string RelativeToLinkedFiles(string path) - { - if (Path.IsPathRooted(path)) return Path.GetRelativePath(_cache.LangProject.LinkedFilesRootDir, path); - path.Should().StartWith("LinkedFiles"); - return Path.GetRelativePath("LinkedFiles", path); - } - private async Task AddFile(string fileName) { AddFwFile(fileName); @@ -85,6 +79,11 @@ private async Task AddDbFile(string fileName) return mediaFile; } + private string FullFilePath(MediaFile mediaFile) + { + return Path.Join(_cache.ProjectId.ProjectFolder, mediaFile.Filename); + } + private async Task AssertDbFileExists(string fileName) { var files = await _lexBoxDbContext.Files.Where(f => f.ProjectId == _projectId).ToArrayAsync(); @@ -148,7 +147,7 @@ public async Task Sync_PreExistingFilesArePreserved() public async Task Adapter_ToMediaUri() { var mediaFile = await AddFile("Adapter_ToMediaUri.txt"); - var mediaUri = _adapter.MediaUriFromPath(RelativeToLinkedFiles(mediaFile.Filename), _cache); + var mediaUri = _adapter.MediaUriFromPath(FullFilePath(mediaFile), _cache); mediaUri.FileId.Should().Be(mediaFile.Id); } @@ -158,6 +157,6 @@ public async Task Adapter_MediaUriToPath() var mediaFile = await AddFile("Adapter_MediaUriToPath.txt"); var path = _adapter.PathFromMediaUri(new MediaUri(mediaFile.Id, "test"), _cache); path.Should().Be("Adapter_MediaUriToPath.txt"); - Directory.EnumerateFiles(_cache.LangProject.LinkedFilesRootDir).Select(RelativeToLinkedFiles).Should().Contain(path); + Directory.EnumerateFiles(_cache.LangProject.LinkedFilesRootDir).Should().Contain(path); } } From 3807cb7165ae1ff9a1693b58aa83dea0c4dce6ec Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 14:22:35 +0700 Subject: [PATCH 28/40] record workaround reason --- .../viewer/src/lib/components/field-editors/audio-input.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/viewer/src/lib/components/field-editors/audio-input.svelte b/frontend/viewer/src/lib/components/field-editors/audio-input.svelte index d5eba6fe87..fd19f11352 100644 --- a/frontend/viewer/src/lib/components/field-editors/audio-input.svelte +++ b/frontend/viewer/src/lib/components/field-editors/audio-input.svelte @@ -20,6 +20,7 @@ get duration() { this.#durationSub(); let duration = this.audio.duration; + //avoids bug: https://github.com/huntabyte/bits-ui/issues/1663 if (duration === Infinity) duration = NaN; return duration; } From 6fa062bc7b1fdd3e6d0333a68ac435c481fc1c78 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 14:25:30 +0700 Subject: [PATCH 29/40] use `is true` to avoid issues with different return types in the future --- backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 21b82099d9..f5331b15f1 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -819,9 +819,10 @@ public IAsyncEnumerable SearchEntries(string query, QueryOptions? options private Func? EntrySearchPredicate(string? query = null) { + string v = ""; if (string.IsNullOrEmpty(query)) return null; return entry => entry.CitationForm.SearchValue(query) || - entry.LexemeFormOA?.Form.SearchValue(query) == true || + entry.LexemeFormOA?.Form.SearchValue(query) is true || entry.AllSenses.Any(s => s.Gloss.SearchValue(query)); } From ccd4d39f4a678a9ed30f15be5c0fe3ddd69ccf47 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 14:30:21 +0700 Subject: [PATCH 30/40] fix failing test --- backend/Testing/FwHeadless/MediaFileServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Testing/FwHeadless/MediaFileServiceTests.cs b/backend/Testing/FwHeadless/MediaFileServiceTests.cs index 1189c398cb..c153bdc512 100644 --- a/backend/Testing/FwHeadless/MediaFileServiceTests.cs +++ b/backend/Testing/FwHeadless/MediaFileServiceTests.cs @@ -156,7 +156,7 @@ public async Task Adapter_MediaUriToPath() { var mediaFile = await AddFile("Adapter_MediaUriToPath.txt"); var path = _adapter.PathFromMediaUri(new MediaUri(mediaFile.Id, "test"), _cache); - path.Should().Be("Adapter_MediaUriToPath.txt"); + path.Should().Be(FullFilePath(mediaFile)); Directory.EnumerateFiles(_cache.LangProject.LinkedFilesRootDir).Should().Contain(path); } } From 31864497d9815c7b8c476763f196e3e2074c21ac Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 14:33:02 +0700 Subject: [PATCH 31/40] fix lint issue --- frontend/viewer/src/lib/in-memory-api-service.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/viewer/src/lib/in-memory-api-service.ts b/frontend/viewer/src/lib/in-memory-api-service.ts index 232aa8d8de..3468e7d0b2 100644 --- a/frontend/viewer/src/lib/in-memory-api-service.ts +++ b/frontend/viewer/src/lib/in-memory-api-service.ts @@ -25,8 +25,12 @@ import {WritingSystemService} from './writing-system-service.svelte'; import {FwLitePlatform} from '$lib/dotnet-types/generated-types/FwLiteShared/FwLitePlatform'; import {delay} from '$lib/utils/time'; import {initProjectContext, ProjectContext} from '$lib/project-context.svelte'; -import type { IFwLiteConfig } from '$lib/dotnet-types/generated-types/FwLiteShared/IFwLiteConfig'; +import type {IFwLiteConfig} from '$lib/dotnet-types/generated-types/FwLiteShared/IFwLiteConfig'; import type {IReadFileResponseJs} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IReadFileResponseJs'; +import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult'; +import type {ILcmFileMetadata} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ILcmFileMetadata'; +import type {IUploadFileResponse} from '$lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse'; +import {UploadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/UploadFileResult'; function pickWs(ws: string, defaultWs: string): string { return ws === 'default' ? defaultWs : ws; @@ -348,7 +352,10 @@ export class InMemoryApiService implements IMiniLcmJsInvokable { } getFileStream(_mediaUri: string): Promise { - throw new Error('Method not implemented.'); + return Promise.resolve({result: ReadFileResult.NotSupported}); } + saveFile(_streamReference: Blob | ArrayBuffer | Uint8Array, _metadata: ILcmFileMetadata): Promise { + return Promise.resolve({result: UploadFileResult.NotSupported}); + } } From 149971f7948f95d8928fbc1998d8d737c83fa8a9 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 14:36:28 +0700 Subject: [PATCH 32/40] disable warning about custom element props --- frontend/viewer/svelte.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/viewer/svelte.config.js b/frontend/viewer/svelte.config.js index fa5ac63ad0..fdab17fbd5 100644 --- a/frontend/viewer/svelte.config.js +++ b/frontend/viewer/svelte.config.js @@ -10,7 +10,7 @@ const typescriptConfig = path.join(__dirname, 'tsconfig.json'); export default { compilerOptions: { warningFilter: (warning) => warning.code != 'element_invalid_self_closing_tag', - customElement: true, + customElement: false, }, // Consult https://svelte.dev/docs#compile-time-svelte-preprocess // for more information about preprocessors From 107c8493cb6e54f1c93312acb3100cbbcd67e660 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 23 Jul 2025 14:48:12 +0700 Subject: [PATCH 33/40] remove unused var --- .../viewer/src/lib/components/field-editors/audio-input.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/viewer/src/lib/components/field-editors/audio-input.svelte b/frontend/viewer/src/lib/components/field-editors/audio-input.svelte index fd19f11352..1d9fe3e440 100644 --- a/frontend/viewer/src/lib/components/field-editors/audio-input.svelte +++ b/frontend/viewer/src/lib/components/field-editors/audio-input.svelte @@ -41,7 +41,7 @@ const missingDuration = $derived(zeroDuration.replaceAll('0', '‒')); // <= this "figure dash" is supposed to be the dash closest to the width of a number