diff --git a/backend/FwHeadless/LexboxFwDataMediaAdapter.cs b/backend/FwHeadless/LexboxFwDataMediaAdapter.cs index 3e3b08c8c7..25c4da144b 100644 --- a/backend/FwHeadless/LexboxFwDataMediaAdapter.cs +++ b/backend/FwHeadless/LexboxFwDataMediaAdapter.cs @@ -4,7 +4,9 @@ using LexCore.Exceptions; using Microsoft.Extensions.Options; using MiniLcm; +using MiniLcm.Media; using SIL.LCModel; +using MediaFile = LexCore.Entities.MediaFile; namespace FwHeadless; @@ -13,17 +15,17 @@ 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) + 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); + return fullFilePath; } private MediaUri MediaUriForMediaFile(MediaFile mediaFile) 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..5573c7fdd6 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs @@ -11,11 +11,14 @@ public class ProjectLoaderFixture : IDisposable private readonly ServiceProvider _serviceProvider; private readonly IOptions _config; public MockFwProjectLoader MockFwProjectLoader { get; } + public IServiceProvider Services => _serviceProvider; 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/MediaFileTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs index d69799d287..6358232f23 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs @@ -1,6 +1,8 @@ using FwDataMiniLcmBridge.Api; using FwDataMiniLcmBridge.Media; using FwDataMiniLcmBridge.Tests.Fixtures; +using Microsoft.Extensions.DependencyInjection; +using MiniLcm.Media; using MiniLcm.Models; using SIL.LCModel.Infrastructure; @@ -11,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"); } @@ -60,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) @@ -78,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.Tests/MiniLcmTests/MediaTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs new file mode 100644 index 0000000000..b56ac52bda --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/MediaTests.cs @@ -0,0 +1,34 @@ +using FwDataMiniLcmBridge.Api; +using FwDataMiniLcmBridge.Tests.Fixtures; + +namespace FwDataMiniLcmBridge.Tests.MiniLcmTests; + +[Collection(ProjectLoaderFixture.Name)] +public class MediaTests : MediaTestsBase +{ + private readonly ProjectLoaderFixture _fixture; + + public MediaTests(ProjectLoaderFixture fixture) + { + _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/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 93e4d08c21..94fedc809f 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; @@ -718,14 +719,17 @@ 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 mediaUri) + internal string FromMediaUri(string mediaUriString) { //path includes `AudioVisual` currently - var path = mediaAdapter.PathFromMediaUri(new MediaUri(mediaUri), Cache); - return Path.GetRelativePath(AudioVisualFolder, path); + 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(Path.Join(Cache.LangProject.LinkedFilesRootDir, AudioVisualFolder), path); } internal RichString? ToRichString(ITsString? tsString) @@ -817,7 +821,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) is true || entry.AllSenses.Any(s => s.Gloss.SearchValue(query)); } @@ -1536,8 +1540,55 @@ 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))); } + + public async Task SaveFile(Stream stream, LcmFileMetadata metadata) + { + if (stream.SafeLength() > MediaFile.MaxFileSize) return new UploadFileResponse(UploadFileResult.TooBig); + var fullPath = Path.Combine(Cache.LangProject.LinkedFilesRootDir, TypeToLinkedFolder(metadata.MimeType), Path.GetFileName(metadata.Filename)); + + if (File.Exists(fullPath)) + return new UploadFileResponse(mediaAdapter.MediaUriFromPath(fullPath, Cache), savedToLexbox: false, newResource: false); + 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.Create(fullPath); + await stream.CopyToAsync(fileStream); + return new UploadFileResponse(mediaAdapter.MediaUriFromPath(fullPath, Cache), 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}"); + } + } + + 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/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/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)); diff --git a/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs b/backend/FwLite/FwDataMiniLcmBridge/Media/IMediaAdapter.cs index d45193f005..fd03092148 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; @@ -8,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); @@ -17,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 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 9fc4f5dc47..46801c2e1a 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; @@ -21,19 +22,38 @@ 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 => MediaUriFromPath(file, cache).FileId, file => file); + .ToDictionary(file => PathToUri(file).FileId, file => file); }) ?? throw new Exception("Failed to get paths"); } //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); } - 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)); @@ -42,7 +62,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 diff --git a/backend/FwLite/FwLiteMaui/MainPage.xaml.Android.cs b/backend/FwLite/FwLiteMaui/MainPage.xaml.Android.cs new file mode 100644 index 0000000000..6e99dd5dc2 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/MainPage.xaml.Android.cs @@ -0,0 +1,39 @@ +#if ANDROID + +using AndroidX.Activity; +using FwLiteMaui.Platforms.Android; +using Microsoft.AspNetCore.Components.WebView; +using Microsoft.Maui.Platform; + +namespace FwLiteMaui; + +public partial class MainPage +{ + // To manage Android permissions, update AndroidManifest.xml to include the permissions and + // features required by your app. You may have to perform additional configuration to enable + // use of those APIs from the WebView, as is done below. A custom WebChromeClient is needed + // to define what happens when the WebView requests a set of permissions. See + // PermissionManagingBlazorWebChromeClient.cs to explore the approach taken in this example. + + private partial void BlazorWebViewInitializing(object? sender, BlazorWebViewInitializingEventArgs e) + { + } + + private partial void BlazorWebViewInitialized(object? sender, BlazorWebViewInitializedEventArgs e) + { + if (e.WebView.Context?.GetActivity() is not ComponentActivity activity) + { + throw new InvalidOperationException( + $"The permission-managing WebChromeClient requires that the current activity be a '{nameof(ComponentActivity)}'."); + } + + e.WebView.Settings.JavaScriptEnabled = true; + e.WebView.Settings.AllowFileAccess = true; + e.WebView.Settings.MediaPlaybackRequiresUserGesture = false; + e.WebView.Settings.SetGeolocationEnabled(true); + e.WebView.Settings.SetGeolocationDatabasePath(e.WebView.Context?.FilesDir?.Path); + e.WebView.SetWebChromeClient(new PermissionManagingBlazorWebChromeClient(e.WebView.WebChromeClient!, activity)); + } +} + +#endif diff --git a/backend/FwLite/FwLiteMaui/MainPage.xaml.Windows.cs b/backend/FwLite/FwLiteMaui/MainPage.xaml.Windows.cs new file mode 100644 index 0000000000..8b145c1d56 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/MainPage.xaml.Windows.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Components.WebView; + +#if WINDOWS +namespace FwLiteMaui; + +public partial class MainPage +{ + private partial void BlazorWebViewInitializing(object? sender, BlazorWebViewInitializingEventArgs e) + { + } + + private partial void BlazorWebViewInitialized(object? sender, BlazorWebViewInitializedEventArgs e) + { + e.WebView.CoreWebView2.PermissionRequested += new SilentPermissionRequestHandler().OnPermissionRequested; + } +} + +#endif diff --git a/backend/FwLite/FwLiteMaui/MainPage.xaml.cs b/backend/FwLite/FwLiteMaui/MainPage.xaml.cs index 678e65fffb..d936d5ebdf 100644 --- a/backend/FwLite/FwLiteMaui/MainPage.xaml.cs +++ b/backend/FwLite/FwLiteMaui/MainPage.xaml.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Components.WebView; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -9,6 +10,8 @@ public partial class MainPage : ContentPage public MainPage() { InitializeComponent(); + blazorWebView.BlazorWebViewInitializing += BlazorWebViewInitializing; + blazorWebView.BlazorWebViewInitialized += BlazorWebViewInitialized; } internal string StartPath @@ -16,4 +19,17 @@ internal string StartPath get => blazorWebView.StartPath; set => blazorWebView.StartPath = value; } + +#if ANDROID || WINDOWS + private partial void BlazorWebViewInitializing(object? sender, BlazorWebViewInitializingEventArgs e); + private partial void BlazorWebViewInitialized(object? sender, BlazorWebViewInitializedEventArgs e); +#else + private void BlazorWebViewInitializing(object? sender, BlazorWebViewInitializingEventArgs e) + { + } + + private void BlazorWebViewInitialized(object? sender, BlazorWebViewInitializedEventArgs e) + { + } +#endif } diff --git a/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidManifest.xml b/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidManifest.xml index b5c870fd87..3b081b8732 100644 --- a/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidManifest.xml +++ b/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidManifest.xml @@ -4,4 +4,6 @@ + + diff --git a/backend/FwLite/FwLiteMaui/Platforms/Android/PermissionManagingBlazorWebChromeClient.cs b/backend/FwLite/FwLiteMaui/Platforms/Android/PermissionManagingBlazorWebChromeClient.cs new file mode 100644 index 0000000000..0ead7ad700 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/Platforms/Android/PermissionManagingBlazorWebChromeClient.cs @@ -0,0 +1,231 @@ +using Android; +using Android.App; +using Android.Content.PM; +using Android.Graphics; +using Android.OS; +using Android.Views; +using Android.Webkit; +using AndroidX.Activity; +using AndroidX.Activity.Result; +using AndroidX.Activity.Result.Contract; +using AndroidX.Core.Content; +using Java.Interop; +using System; +using System.Collections.Generic; +using View = Android.Views.View; +using WebView = Android.Webkit.WebView; + +namespace FwLiteMaui.Platforms.Android; + +//copied from https://github.com/MackinnonBuck/MauiBlazorPermissionsExample/ +internal class PermissionManagingBlazorWebChromeClient : WebChromeClient, IActivityResultCallback +{ + // This class implements a permission requesting workflow that matches workflow recommended + // by the official Android developer documentation. + // See: https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions + // The current implementation supports location, camera, and microphone permissions. To add your own, + // update the s_rationalesByPermission dictionary to include your rationale for requiring the permission. + // If necessary, you may need to also update s_requiredPermissionsByWebkitResource to define how a specific + // Webkit resource maps to an Android permission. + + // In a real app, you would probably use more convincing rationales tailored toward what your app does. + private const string CameraAccessRationale = "This app requires access to your camera. Please grant access to your camera when requested."; + private const string LocationAccessRationale = "This app requires access to your location. Please grant access to your precise location when requested."; + private const string MicrophoneAccessRationale = "To Record audio, we need your permission, Please grant access to your Microphone"; + + private static readonly Dictionary s_rationalesByPermission = new() + { + [Manifest.Permission.Camera] = CameraAccessRationale, + [Manifest.Permission.AccessFineLocation] = LocationAccessRationale, + [Manifest.Permission.RecordAudio] = MicrophoneAccessRationale, + // Add more rationales as you add more supported permissions. + }; + + private static readonly Dictionary s_requiredPermissionsByWebkitResource = new() + { + [PermissionRequest.ResourceVideoCapture] = new[] { Manifest.Permission.Camera }, + [PermissionRequest.ResourceAudioCapture] = new[] { Manifest.Permission.ModifyAudioSettings, Manifest.Permission.RecordAudio }, + // Add more Webkit resource -> Android permission mappings as needed. + }; + + private readonly WebChromeClient _blazorWebChromeClient; + private readonly ComponentActivity _activity; + private readonly ActivityResultLauncher _requestPermissionLauncher; + + private Action? _pendingPermissionRequestCallback; + + public PermissionManagingBlazorWebChromeClient(WebChromeClient blazorWebChromeClient, ComponentActivity activity) + { + _blazorWebChromeClient = blazorWebChromeClient; + _activity = activity; + _requestPermissionLauncher = _activity.RegisterForActivityResult(new ActivityResultContracts.RequestPermission(), this); + } + + public override void OnCloseWindow(WebView? window) + { + _blazorWebChromeClient.OnCloseWindow(window); + _requestPermissionLauncher.Unregister(); + } + + public override void OnGeolocationPermissionsShowPrompt(string? origin, GeolocationPermissions.ICallback? callback) + { + ArgumentNullException.ThrowIfNull(callback, nameof(callback)); + + RequestPermission(Manifest.Permission.AccessFineLocation, isGranted => callback.Invoke(origin, isGranted, false)); + } + + public override void OnPermissionRequest(PermissionRequest? request) + { + ArgumentNullException.ThrowIfNull(request, nameof(request)); + + if (request.GetResources() is not { } requestedResources) + { + request.Deny(); + return; + } + + RequestAllResources(requestedResources, grantedResources => + { + if (grantedResources.Count == 0) + { + request.Deny(); + } + else + { + request.Grant(grantedResources.ToArray()); + } + }); + } + + private void RequestAllResources(Memory requestedResources, Action> callback) + { + if (requestedResources.Length == 0) + { + // No resources to request - invoke the callback with an empty list. + callback(new()); + return; + } + + var currentResource = requestedResources.Span[0]; + var requiredPermissions = s_requiredPermissionsByWebkitResource.GetValueOrDefault(currentResource, Array.Empty()); + + RequestAllPermissions(requiredPermissions, isGranted => + { + // Recurse with the remaining resources. If the first resource was granted, use a modified callback + // that adds the first resource to the granted resources list. + RequestAllResources(requestedResources[1..], !isGranted ? callback : grantedResources => + { + grantedResources.Add(currentResource); + callback(grantedResources); + }); + }); + } + + private void RequestAllPermissions(Memory requiredPermissions, Action callback) + { + if (requiredPermissions.Length == 0) + { + // No permissions left to request - success! + callback(true); + return; + } + + RequestPermission(requiredPermissions.Span[0], isGranted => + { + if (isGranted) + { + // Recurse with the remaining permissions. + RequestAllPermissions(requiredPermissions[1..], callback); + } + else + { + // The first required permission was not granted. Fail now and don't attempt to grant + // the remaining permissions. + callback(false); + } + }); + } + + private void RequestPermission(string permission, Action callback) + { + // This method implements the workflow described here: + // https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions + + if (ContextCompat.CheckSelfPermission(_activity, permission) == Permission.Granted) + { + callback.Invoke(true); + } + // else if (_activity.ShouldShowRequestPermissionRationale(permission) && s_rationalesByPermission.TryGetValue(permission, out var rationale)) + // { + // new AlertDialog.Builder(_activity) + // .SetTitle("Enable app permissions")! + // .SetMessage(rationale)! + // .SetNegativeButton("No thanks", (_, _) => callback(false))! + // .SetPositiveButton("Continue", (_, _) => LaunchPermissionRequestActivity(permission, callback))! + // .Show(); + // } + else + { + LaunchPermissionRequestActivity(permission, callback); + } + } + + private void LaunchPermissionRequestActivity(string permission, Action callback) + { + if (_pendingPermissionRequestCallback is not null) + { + throw new InvalidOperationException("Cannot perform multiple permission requests simultaneously."); + } + + _pendingPermissionRequestCallback = callback; + _requestPermissionLauncher.Launch(permission); + } + + void IActivityResultCallback.OnActivityResult(Java.Lang.Object? isGranted) + { + var callback = _pendingPermissionRequestCallback; + _pendingPermissionRequestCallback = null; + callback?.Invoke((bool)isGranted!); + } + + #region Unremarkable overrides + // See: https://github.com/dotnet/maui/issues/6565 + public override JniPeerMembers JniPeerMembers => _blazorWebChromeClient.JniPeerMembers; + public override Bitmap? DefaultVideoPoster => _blazorWebChromeClient.DefaultVideoPoster; + public override View? VideoLoadingProgressView => _blazorWebChromeClient.VideoLoadingProgressView; + public override void GetVisitedHistory(IValueCallback? callback) + => _blazorWebChromeClient.GetVisitedHistory(callback); + public override bool OnConsoleMessage(ConsoleMessage? consoleMessage) + => _blazorWebChromeClient.OnConsoleMessage(consoleMessage); + public override bool OnCreateWindow(WebView? view, bool isDialog, bool isUserGesture, Message? resultMsg) + => _blazorWebChromeClient.OnCreateWindow(view, isDialog, isUserGesture, resultMsg); + public override void OnGeolocationPermissionsHidePrompt() + => _blazorWebChromeClient.OnGeolocationPermissionsHidePrompt(); + public override void OnHideCustomView() + => _blazorWebChromeClient.OnHideCustomView(); + public override bool OnJsAlert(WebView? view, string? url, string? message, JsResult? result) + => _blazorWebChromeClient.OnJsAlert(view, url, message, result); + public override bool OnJsBeforeUnload(WebView? view, string? url, string? message, JsResult? result) + => _blazorWebChromeClient.OnJsBeforeUnload(view, url, message, result); + public override bool OnJsConfirm(WebView? view, string? url, string? message, JsResult? result) + => _blazorWebChromeClient.OnJsConfirm(view, url, message, result); + public override bool OnJsPrompt(WebView? view, string? url, string? message, string? defaultValue, JsPromptResult? result) + => _blazorWebChromeClient.OnJsPrompt(view, url, message, defaultValue, result); + public override void OnPermissionRequestCanceled(PermissionRequest? request) + => _blazorWebChromeClient.OnPermissionRequestCanceled(request); + public override void OnProgressChanged(WebView? view, int newProgress) + => _blazorWebChromeClient.OnProgressChanged(view, newProgress); + public override void OnReceivedIcon(WebView? view, Bitmap? icon) + => _blazorWebChromeClient.OnReceivedIcon(view, icon); + public override void OnReceivedTitle(WebView? view, string? title) + => _blazorWebChromeClient.OnReceivedTitle(view, title); + public override void OnReceivedTouchIconUrl(WebView? view, string? url, bool precomposed) + => _blazorWebChromeClient.OnReceivedTouchIconUrl(view, url, precomposed); + public override void OnRequestFocus(WebView? view) + => _blazorWebChromeClient.OnRequestFocus(view); + public override void OnShowCustomView(View? view, ICustomViewCallback? callback) + => _blazorWebChromeClient.OnShowCustomView(view, callback); + public override bool OnShowFileChooser(WebView? webView, IValueCallback? filePathCallback, FileChooserParams? fileChooserParams) + => _blazorWebChromeClient.OnShowFileChooser(webView, filePathCallback, fileChooserParams); + #endregion +} diff --git a/backend/FwLite/FwLiteMaui/Platforms/Windows/Package.appxmanifest b/backend/FwLite/FwLiteMaui/Platforms/Windows/Package.appxmanifest index 7da7139f0c..9c4bada059 100644 --- a/backend/FwLite/FwLiteMaui/Platforms/Windows/Package.appxmanifest +++ b/backend/FwLite/FwLiteMaui/Platforms/Windows/Package.appxmanifest @@ -63,6 +63,7 @@ + diff --git a/backend/FwLite/FwLiteMaui/Platforms/Windows/SilentPermissionRequestHandler.cs b/backend/FwLite/FwLiteMaui/Platforms/Windows/SilentPermissionRequestHandler.cs new file mode 100644 index 0000000000..33c28bbca1 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/Platforms/Windows/SilentPermissionRequestHandler.cs @@ -0,0 +1,15 @@ +using Microsoft.Web.WebView2.Core; +namespace FwLiteMaui; + +public class SilentPermissionRequestHandler +{ + private static readonly Uri BaseUri = new("https://0.0.0.0"); + + public void OnPermissionRequested(CoreWebView2 sender, CoreWebView2PermissionRequestedEventArgs args) + { + args.State = Uri.TryCreate(args.Uri, UriKind.RelativeOrAbsolute, out var uri) && BaseUri.IsBaseOf(uri) + ? CoreWebView2PermissionState.Allow + : CoreWebView2PermissionState.Deny; + args.Handled = true; + } +} diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index 8bc71aff90..8324354e28 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -1,7 +1,9 @@ using FwLiteShared.Sync; using LcmCrdt; +using Microsoft.Extensions.Logging; using Microsoft.JSInterop; using MiniLcm; +using MiniLcm.Media; using MiniLcm.Models; using MiniLcm.Validators; using Reinforced.Typings.Attributes; @@ -12,6 +14,7 @@ public class MiniLcmJsInvokable( IMiniLcmApi api, BackgroundSyncService backgroundSyncService, IProjectIdentifier project, + ILogger logger, MiniLcmApiNotifyWrapperFactory notificationWrapperFactory, MiniLcmApiValidationWrapperFactory validationWrapperFactory) : IDisposable { @@ -343,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/backend/FwLite/FwLiteShared/Sync/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs index be322330fe..e105f4129f 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) @@ -142,6 +146,18 @@ public async Task AwaitSyncFinished() 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/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 236492eb6c..ed296a3fe6 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -24,6 +24,8 @@ using System.Runtime.CompilerServices; using FwLiteShared.AppUpdate; using FwLiteShared.Sync; +using MiniLcm.Media; +using MediaFile = MiniLcm.Media.MediaFile; namespace FwLiteShared.TypeGen; @@ -53,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(); @@ -63,6 +66,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}" } ]); @@ -78,6 +82,9 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) typeof(IObjectWithId), typeof(RichString), typeof(RichTextObjectData), + + typeof(MediaFile), + typeof(LcmFileMetadata) ], exportBuilder => exportBuilder.WithPublicNonStaticProperties(exportBuilder => { @@ -100,6 +107,7 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) ]); builder.ExportAsEnum(); builder.ExportAsEnum().UseString(); + builder.ExportAsEnum().UseString(); builder.ExportAsInterface() .FlattenHierarchy() .WithPublicProperties() @@ -111,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/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..2f02e42996 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/MediaTests.cs @@ -0,0 +1,18 @@ +namespace LcmCrdt.Tests.MiniLcmTests; + +public class MediaTests : MediaTestsBase +{ + 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(); + } +} diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 277adc8457..e5b18cd204 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; @@ -699,6 +700,22 @@ public async Task GetFileStream(MediaUri mediaUri) return await lcmMediaService.GetFileStream(mediaUri.FileId); } + public async Task SaveFile(Stream stream, LcmFileMetadata metadata) + { + try + { + if (stream.SafeLength() > 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) + { + 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/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 02a4044118..d4cec3d0e0 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -5,6 +5,8 @@ using SIL.Harmony.Core; using SIL.Harmony.Resource; using LcmCrdt.RemoteSync; +using Microsoft.Extensions.Logging; +using MiniLcm.Media; namespace LcmCrdt.MediaServer; @@ -13,7 +15,8 @@ public class LcmMediaService( CurrentProjectService currentProjectService, IOptions options, IRefitHttpServiceFactory refitFactory, - IServerHttpClientProvider httpClientProvider + IServerHttpClientProvider httpClientProvider, + ILogger logger ) : IRemoteResourceService { public async Task AllResources() @@ -68,12 +71,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; @@ -84,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); } @@ -93,8 +107,69 @@ async Task IRemoteResourceService.DownloadResource(string remote public string ProjectResourceCachePath => Path.Combine(options.Value.LocalResourceCachePath, currentProjectService.Project.Name); - public Task UploadResource(Guid resourceId, string localPath) + + async Task IRemoteResourceService.UploadResource(Guid resourceId, string localPath) + { + 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<(HarmonyResource resource, bool newResource)> SaveFile(Stream stream, LcmFileMetadata metadata) + { + var projectResourceCachePath = ProjectResourceCachePath; + Directory.CreateDirectory(projectResourceCachePath); + var localPath = Path.Combine(projectResourceCachePath, Path.GetFileName(metadata.Filename)); + 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)) + { + await stream.CopyToAsync(localFile); + } + + try + { + IRemoteResourceService? remoteResourceService = null; + if (await httpClientProvider.ConnectionStatus() == ConnectionStatus.Online) remoteResourceService = this; + return (await resourceService.AddLocalResource( + localPath, + currentProjectService.ProjectData.ClientId, + resourceService: remoteResourceService + ), newResource: true); + } + catch (Exception e) + { + logger.LogError(e, "Failed to record file {Filename}", metadata.Filename); + File.Delete(localPath); + throw; + } + } + + 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() { - throw new NotImplementedException(); + if (await httpClientProvider.ConnectionStatus() != ConnectionStatus.Online) return false; + await resourceService.UploadPendingResources(currentProjectService.ProjectData.ClientId, this); + return true; } } diff --git a/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs b/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs new file mode 100644 index 0000000000..9d57b5d724 --- /dev/null +++ b/backend/FwLite/MiniLcm.Tests/MediaTestsBase.cs @@ -0,0 +1,171 @@ +using System.Text; +using MiniLcm.Media; + +namespace MiniLcm.Tests; + +public abstract class MediaTestsBase : MiniLcmTestBase +{ + [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().And.NotBe(MediaUri.NotFound); + + // 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().And.NotBe(MediaUri.NotFound); + + // 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.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; + firstMediaUri.Should().NotBe(MediaUri.NotFound); + saveResponse.Result.Should().Be(UploadFileResult.SavedLocally); + + // Act - Save a different file with the same name + saveResponse = await SaveTestFile(fileName); + 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") + { + 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(); + } +} 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/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index 05688d706e..dd33a7afbb 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 using the provided data stream and metadata. + /// + /// 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 new file mode 100644 index 0000000000..5cf90fde48 --- /dev/null +++ b/backend/FwLite/MiniLcm/Media/FileMetadata.cs @@ -0,0 +1,7 @@ +namespace MiniLcm.Media; + +public record LcmFileMetadata( + string Filename, + string MimeType, + 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..06b78679bd --- /dev/null +++ b/backend/FwLite/MiniLcm/Media/MediaFile.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace MiniLcm.Media; + +public record MediaFile(MediaUri Uri, LcmFileMetadata Metadata) +{ + public const int MaxFileSize = 10 * 1024 * 1024; // 10MB +} diff --git a/backend/FwLite/MiniLcm/MediaUri.cs b/backend/FwLite/MiniLcm/Media/MediaUri.cs similarity index 61% rename from backend/FwLite/MiniLcm/MediaUri.cs rename to backend/FwLite/MiniLcm/Media/MediaUri.cs index d48e917eb8..e69a63f7a7 100644 --- a/backend/FwLite/MiniLcm/MediaUri.cs +++ b/backend/FwLite/MiniLcm/Media/MediaUri.cs @@ -1,6 +1,10 @@ -namespace MiniLcm; +using System.Text.Json; +using System.Text.Json.Serialization; -public record struct MediaUri +namespace MiniLcm.Media; + +[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/Models/ReadFileResponse.cs b/backend/FwLite/MiniLcm/Media/ReadFileResponse.cs similarity index 74% rename from backend/FwLite/MiniLcm/Models/ReadFileResponse.cs rename to backend/FwLite/MiniLcm/Media/ReadFileResponse.cs index c68f1eb510..cc67fa978d 100644 --- a/backend/FwLite/MiniLcm/Models/ReadFileResponse.cs +++ b/backend/FwLite/MiniLcm/Media/ReadFileResponse.cs @@ -1,8 +1,8 @@ using System.Text.Json.Serialization; -namespace MiniLcm.Models; +namespace MiniLcm.Media; -public record ReadFileResponse +public record ReadFileResponse : IAsyncDisposable, IDisposable { public ReadFileResponse(Stream stream, string fileName) { @@ -24,6 +24,15 @@ public ReadFileResponse(ReadFileResult result, string? errorMessage = null) public ReadFileResult Result { get; } public string? ErrorMessage { get; } + public ValueTask DisposeAsync() + { + return Stream?.DisposeAsync() ?? ValueTask.CompletedTask; + } + + public void Dispose() + { + Stream?.Dispose(); + } } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/backend/FwLite/MiniLcm/Media/StreamUtils.cs b/backend/FwLite/MiniLcm/Media/StreamUtils.cs new file mode 100644 index 0000000000..7c249446df --- /dev/null +++ b/backend/FwLite/MiniLcm/Media/StreamUtils.cs @@ -0,0 +1,17 @@ +namespace MiniLcm.Media; + +public static class StreamUtils +{ + public static long? SafeLength(this Stream stream) + { + try + { + return stream.Length; + } + catch + { + //some streams don't know their length, but CanSeek can be false and it still returns it's length, so we just catch it here + return null; + } + } +} diff --git a/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs b/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs new file mode 100644 index 0000000000..1b47542d3f --- /dev/null +++ b/backend/FwLite/MiniLcm/Media/UploadFileResponse.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace MiniLcm.Media; + +public record UploadFileResponse +{ + public UploadFileResponse(MediaUri mediaUri, bool savedToLexbox, bool newResource) + { + MediaUri = mediaUri; + Result = (savedToLexbox, newResource) switch + { + (_, false) => UploadFileResult.AlreadyExists, + (true, true) => UploadFileResult.SavedToLexbox, + (false, true) => 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; + } + + public UploadFileResponse(string errorMessage) + { + Result = UploadFileResult.Error; + ErrorMessage = errorMessage; + } + + public UploadFileResult Result { get; } + public string? ErrorMessage { get; } + public MediaUri? MediaUri { get; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UploadFileResult +{ + SavedLocally, + SavedToLexbox, + TooBig, + NotSupported, + AlreadyExists, + Error +} 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/backend/Testing/FwHeadless/MediaFileServiceTests.cs b/backend/Testing/FwHeadless/MediaFileServiceTests.cs index 42a7ad7e07..c153bdc512 100644 --- a/backend/Testing/FwHeadless/MediaFileServiceTests.cs +++ b/backend/Testing/FwHeadless/MediaFileServiceTests.cs @@ -8,8 +8,10 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using MiniLcm; +using MiniLcm.Media; using SIL.LCModel; using Testing.Fixtures; +using MediaFile = LexCore.Entities.MediaFile; namespace Testing.FwHeadless; @@ -51,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); @@ -84,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(); @@ -147,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); } @@ -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"); - Directory.EnumerateFiles(_cache.LangProject.LinkedFilesRootDir).Select(RelativeToLinkedFiles).Should().Contain(path); + path.Should().Be(FullFilePath(mediaFile)); + Directory.EnumerateFiles(_cache.LangProject.LinkedFilesRootDir).Should().Contain(path); } } diff --git a/backend/harmony b/backend/harmony index 6cd0630a2b..4305ac0d38 160000 --- a/backend/harmony +++ b/backend/harmony @@ -1 +1 @@ -Subproject commit 6cd0630a2be7bc4010a1ebc1c7e826054768b553 +Subproject commit 4305ac0d38a74e80bea9286d3588c324310fadf9 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 diff --git a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte index f4641915ff..9b5b0a394e 100644 --- a/frontend/viewer/src/lib/components/audio/AudioDialog.svelte +++ b/frontend/viewer/src/lib/components/audio/AudioDialog.svelte @@ -9,18 +9,23 @@ 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(); + const tooBig = $derived((audio?.size ?? 0) > 10 * 1024 * 1024); let requester: { - resolve: (value: string | undefined) => void + resolve: (mediaUri: string | undefined) => void } | undefined; @@ -67,11 +72,26 @@ } async function uploadAudio() { - if (!audio) throw new Error('No file selected'); - const name = (selectedFile?.name ?? audio.type); - const id = `audio-${name}-${Date.now()}`; - await delay(1000); - return id; + if (!audio || !selectedFile) throw new Error($t`No file selected`); + 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'); + 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) { @@ -80,11 +100,38 @@ } async function onRecordingComplete(blob: Blob) { - selectedFile = undefined; + 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; @@ -105,18 +152,20 @@ {$t`Add audio`} - {#if !audio} + {#if !audio || !selectedFile} {#if loading} {:else} {/if} {:else} - - + + {#if tooBig} +

{$t`File too big`}

+ {/if} - 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'); 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..1d9fe3e440 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,10 @@ get duration() { this.#durationSub(); - return this.audio.duration; + let duration = this.audio.duration; + //avoids bug: https://github.com/huntabyte/bits-ui/issues/1663 + if (duration === Infinity) duration = NaN; + return duration; } } @@ -38,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 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} 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..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 @@ -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 {ILcmFileMetadata} from '../../MiniLcm/Media/ILcmFileMetadata'; 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: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata) : 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..6c3668ee58 --- /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..c03487ce96 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IMediaFile.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 {ILcmFileMetadata} from './ILcmFileMetadata'; + +export interface IMediaFile +{ + uri: string; + metadata: ILcmFileMetadata; +} +/* 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..6c9af0123e --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse.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. + +import type {UploadFileResult} from './UploadFileResult'; + +export interface IUploadFileResponse +{ + result: UploadFileResult; + errorMessage?: string; + mediaUri?: 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 */ 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}); + } } 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()}; diff --git a/frontend/viewer/svelte.config.js b/frontend/viewer/svelte.config.js index fa5ac63ad0..a96d50c629 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: true,//required for storybook tests }, // Consult https://svelte.dev/docs#compile-time-svelte-preprocess // for more information about preprocessors