From b16bb5d7fb237d6922a84cf106a933e061830569 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 18 Aug 2025 14:33:47 +0700 Subject: [PATCH 01/14] Add API for downloading media file metadata Will eventually add API to do this in batch form for all media files in the project, then we can (e.g.) download metadata immediately and download file contents on-demand, or download and cache all file contents prior to going offline. --- .../LcmCrdt/MediaServer/IMediaServerClient.cs | 3 +++ .../LcmCrdt/MediaServer/LcmMediaService.cs | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs b/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs index 10443aea5c..029fd0a5c2 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs @@ -8,6 +8,9 @@ public interface IMediaServerClient [Get("/api/media/{fileId}")] Task DownloadFile(Guid fileId); + [Get("/api/media/metadata/{fileId}")] + Task GetFileMetadata(Guid fileId); + [Post("/api/media")] [Multipart] Task UploadFile(MultipartItem file, diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index d4cec3d0e0..2d660bf1d0 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -7,6 +7,7 @@ using LcmCrdt.RemoteSync; using Microsoft.Extensions.Logging; using MiniLcm.Media; +using System.Net.Http.Json; namespace LcmCrdt.MediaServer; @@ -69,6 +70,29 @@ public async Task GetFileStream(Guid fileId) return new(File.OpenRead(localResource.LocalPath), Path.GetFileName(localResource.LocalPath)); } + public async Task GetFileMetadata(Guid fileId) + { + var mediaClient = await MediaServerClient(); + var response = await mediaClient.GetFileMetadata(fileId); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"Failed to retrieve metadata for file {fileId}: {response.StatusCode} {response.ReasonPhrase}"); + } + var metadata = await response.Content.ReadFromJsonAsync(); + if (metadata is null) + { + // Try to get content into error message, but if buffering not enabled for this request, give up + var content = ""; + try + { + content = await response.Content.ReadAsStringAsync(); + } + catch { } // Oh well, we tried + throw new Exception($"Failed to retrieve metadata for file {fileId}: response was in incorrect format. {content}"); + } + return metadata; + } + private async Task<(Stream? stream, string? filename)> RequestMediaFile(Guid fileId) { var mediaClient = await MediaServerClient(); From 8a24f83ffb6ae58091a553e89a67c16d645037cf Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 20 Aug 2025 13:16:26 +0700 Subject: [PATCH 02/14] Add API to up/download all pending resources Will be used in frontend to let the API call a simpler method if the "Select All" checkbox is checked, but we still need a method to download a selected list of resources in case the user wants to omit some. --- .../LcmCrdt/MediaServer/LcmMediaService.cs | 59 ++++++++++++++++++- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 2d660bf1d0..326ee5f726 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -25,6 +25,16 @@ public async Task AllResources() return await resourceService.AllResources(); } + public async Task ResourcesPendingDownload() + { + return await resourceService.ListResourcesPendingDownload(); + } + + public async Task ResourcesPendingUpload() + { + return await resourceService.ListResourcesPendingUpload(); + } + /// /// should only be used in fw-headless for files which already exist in the lexbox db /// @@ -43,6 +53,41 @@ public async Task DeleteResource(Guid fileId) await resourceService.DeleteResource(currentProjectService.ProjectData.ClientId, fileId); } + public async Task DownloadResourceIfNeeded(Guid fileId) + { + var localResource = await resourceService.GetLocalResource(fileId); + if (localResource is null) + { + var connectionStatus = await httpClientProvider.ConnectionStatus(); + if (connectionStatus == ConnectionStatus.Online) + { + return await resourceService.DownloadResource(fileId, this); + } + } + return localResource; + } + + public async Task DownloadAllResources() + { + var resources = await ResourcesPendingDownload(); + var localResourceCachePath = options.Value.LocalResourceCachePath; + foreach (var resource in resources) + { + if (resource.RemoteId is null) continue; + await ((IRemoteResourceService)this).DownloadResource(resource.RemoteId, localResourceCachePath); + // NOTE: DownloadResource never uses the localResourceCachePath parameter; bug? Or just a quirk of how the API works? + } + } + + public async Task UploadAllResources() + { + var resources = await ResourcesPendingUpload(); + foreach (var resource in resources) + { + await ((IRemoteResourceService)this).UploadResource(resource.Id, resource.LocalPath); + } + } + /// /// return a stream for the file, if it's not cached locally, it will be downloaded /// @@ -51,20 +96,28 @@ public async Task DeleteResource(Guid fileId) /// public async Task GetFileStream(Guid fileId) { - var localResource = await resourceService.GetLocalResource(fileId); + var localResource = await DownloadResourceIfNeeded(fileId); if (localResource is null) { var connectionStatus = await httpClientProvider.ConnectionStatus(); if (connectionStatus == ConnectionStatus.Online) { - localResource = await resourceService.DownloadResource(fileId, this); + // Try again, maybe earlier failure was a blip + localResource = await DownloadResourceIfNeeded(fileId); } else { return new ReadFileResponse(ReadFileResult.Offline); } } - //todo, consider trying to download the file again, maybe the cache was cleared + if (localResource is null || !File.Exists(localResource.LocalPath)) + { + // One more attempt to download again, maybe the cache was cleared + localResource = await DownloadResourceIfNeeded(fileId); + // If still null then connection is offline or unreliable enough to consider as offline + if (localResource is null) return new ReadFileResponse(ReadFileResult.Offline); + } + // If still can't find local path then this is where we give up if (!File.Exists(localResource.LocalPath)) throw new FileNotFoundException("Unable to find the file with Id" + fileId, localResource.LocalPath); return new(File.OpenRead(localResource.LocalPath), Path.GetFileName(localResource.LocalPath)); From e14fab2a5732712542697c656c46deaccb030b85 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 21 Aug 2025 11:02:34 +0700 Subject: [PATCH 03/14] Add API for up/downloading only specific resources This will be used if the frontend UI shows the user a list of available resources and he chooses to select only some of them rather than all. --- .../FwLite/LcmCrdt/MediaServer/LcmMediaService.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 326ee5f726..7dc7ba9b1a 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -81,7 +81,19 @@ public async Task DownloadAllResources() public async Task UploadAllResources() { - var resources = await ResourcesPendingUpload(); + await UploadResources(await ResourcesPendingUpload()); + } + + public async Task DownloadResources(IEnumerable resources) + { + foreach (var resource in resources) + { + await DownloadResourceIfNeeded(resource.Id); + } + } + + public async Task UploadResources(IEnumerable resources) + { foreach (var resource in resources) { await ((IRemoteResourceService)this).UploadResource(resource.Id, resource.LocalPath); From 693e720a0c73bd54e289f6793edb27ef773f65b4 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 21 Aug 2025 14:20:45 +0700 Subject: [PATCH 04/14] Add JsInvokable version of MediaFilesService Will be used in media files dialog (to be created). --- .../FwLiteShared/Services/FwLiteProvider.cs | 2 + .../Services/MediaFilesServiceJsInvokable.cs | 44 +++++++++++++++++++ .../Services/ProjectServicesProvider.cs | 8 +++- .../TypeGen/ReinforcedFwLiteTypingConfig.cs | 9 +++- frontend/viewer/src/DotnetProjectView.svelte | 6 +++ .../FwLiteShared/Services/DotnetService.ts | 1 + .../Services/IMediaFilesServiceJsInvokable.ts | 18 ++++++++ .../FwLiteShared/Services/IProjectScope.ts | 1 + .../SIL/Harmony/Resource/ILocalResource.ts | 11 +++++ .../viewer/src/lib/project-context.svelte.ts | 9 ++++ .../src/lib/services/media-files-service.ts | 44 +++++++++++++++++++ .../src/lib/services/service-provider.ts | 2 + 12 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Resource/ILocalResource.ts create mode 100644 frontend/viewer/src/lib/services/media-files-service.ts diff --git a/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs b/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs index 9a2b69b58b..a24d7511e6 100644 --- a/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs @@ -46,6 +46,7 @@ IServiceProvider services DotnetService.ProjectServicesProvider => typeof(ProjectServicesProvider), DotnetService.HistoryService => typeof(HistoryServiceJsInvokable), DotnetService.SyncService => typeof(SyncServiceJsInvokable), + DotnetService.MediaFilesService => typeof(MediaFilesServiceJsInvokable), DotnetService.AppLauncher => typeof(IAppLauncher), DotnetService.TroubleshootingService => typeof(ITroubleshootingService), DotnetService.TestingService => typeof(TestingService), @@ -103,6 +104,7 @@ public enum DotnetService ProjectServicesProvider, HistoryService, SyncService, + MediaFilesService, AppLauncher, TroubleshootingService, TestingService, diff --git a/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs new file mode 100644 index 0000000000..14a057ee3c --- /dev/null +++ b/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs @@ -0,0 +1,44 @@ +using Microsoft.JSInterop; +using LcmCrdt.MediaServer; +using SIL.Harmony.Resource; + +namespace FwLiteShared.Services; + +public class MediaFilesServiceJsInvokable(LcmMediaService mediaService) +{ + [JSInvokable] + public async Task ResourcesPendingDownload() + { + return await mediaService.ResourcesPendingDownload(); + } + + [JSInvokable] + public async Task ResourcesPendingUpload() + { + return await mediaService.ResourcesPendingUpload(); + } + + [JSInvokable] + public async Task DownloadAllResources() + { + await mediaService.ResourcesPendingUpload(); + } + + [JSInvokable] + public async Task UploadAllResources() + { + await mediaService.ResourcesPendingUpload(); + } + + [JSInvokable] + public async Task DownloadResources(IEnumerable resources) + { + await mediaService.DownloadResources(resources); + } + + [JSInvokable] + public async Task UploadResources(IEnumerable resources) + { + await mediaService.UploadResources(resources); + } +} diff --git a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs index 71b6ee85f2..57a3608943 100644 --- a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs @@ -69,7 +69,8 @@ public Task OpenCrdtProject(string code) scope.Server = server; scope.SetCrdtServices( ActivatorUtilities.CreateInstance(scopedServices), - ActivatorUtilities.CreateInstance(scopedServices) + ActivatorUtilities.CreateInstance(scopedServices), + ActivatorUtilities.CreateInstance(scopedServices) ); _projectScopes.TryAdd(scope, scope); return scope; @@ -173,10 +174,12 @@ public ProjectScope(AsyncServiceScope serviceScope, public void SetCrdtServices( HistoryServiceJsInvokable historyService, - SyncServiceJsInvokable syncService) + SyncServiceJsInvokable syncService, + MediaFilesServiceJsInvokable mediaFilesService) { HistoryService = DotNetObjectReference.Create(historyService); SyncService = DotNetObjectReference.Create(syncService); + MediaFilesService = DotNetObjectReference.Create(mediaFilesService); } public ValueTask CleanupAsync() @@ -191,4 +194,5 @@ public ValueTask CleanupAsync() public DotNetObjectReference MiniLcm { get; set; } public DotNetObjectReference? HistoryService { get; set; } public DotNetObjectReference? SyncService { get; set; } + public DotNetObjectReference? MediaFilesService { get; set; } } diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 77d482dab7..1948d2a350 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -21,6 +21,7 @@ using SIL.Harmony; using SIL.Harmony.Core; using SIL.Harmony.Db; +using SIL.Harmony.Resource; using System.Runtime.CompilerServices; using FwLiteShared.AppUpdate; using FwLiteShared.Sync; @@ -84,7 +85,9 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) typeof(RichTextObjectData), typeof(MediaFile), - typeof(LcmFileMetadata) + typeof(LcmFileMetadata), + typeof(RemoteResource), + typeof(LocalResource) ], exportBuilder => exportBuilder.WithPublicNonStaticProperties(exportBuilder => { @@ -112,6 +115,10 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) .FlattenHierarchy() .WithPublicProperties() .WithPublicMethods(b => b.AlwaysReturnPromise().OnlyJsInvokable()); + builder.ExportAsInterface() + // .WithPublicMethods(b => b.AlwaysReturnPromise().OnlyJsInvokable()); + .WithPublicMethods(); + // TODO: Does MediaFilesServiceJsInvokable need the AlwaysReturnPromise().OnlyJsInvokable() setup that MiniLcmJsInvokable needs? builder.ExportAsEnum().UseString(); builder.ExportAsInterfaces([ typeof(QueryOptions), diff --git a/frontend/viewer/src/DotnetProjectView.svelte b/frontend/viewer/src/DotnetProjectView.svelte index 51f2f84d97..3e372263ef 100644 --- a/frontend/viewer/src/DotnetProjectView.svelte +++ b/frontend/viewer/src/DotnetProjectView.svelte @@ -13,6 +13,7 @@ } from '$lib/dotnet-types/generated-types/FwLiteShared/Services/ISyncServiceJsInvokable'; import ProjectLoader from './ProjectLoader.svelte'; import {initProjectContext} from '$lib/project-context.svelte'; + import type {IMediaFilesServiceJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable'; const projectServicesProvider = useProjectServicesProvider(); const projectContext = initProjectContext(); @@ -52,11 +53,16 @@ if (projectScope.syncService) { syncService = wrapInProxy(projectScope.syncService, DotnetService.SyncService); } + let mediaFilesService: IMediaFilesServiceJsInvokable | undefined = undefined; + if (projectScope.mediaFilesService) { + mediaFilesService = wrapInProxy(projectScope.mediaFilesService, DotnetService.MediaFilesService); + } const api = wrapInProxy(projectScope.miniLcm, DotnetService.MiniLcmApi); projectContext.setup({ api, historyService, syncService, + mediaFilesService, projectName, projectCode: code, projectType, diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/DotnetService.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/DotnetService.ts index 53875f57b9..e3840f2f15 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/DotnetService.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/DotnetService.ts @@ -12,6 +12,7 @@ export enum DotnetService { ProjectServicesProvider = "ProjectServicesProvider", HistoryService = "HistoryService", SyncService = "SyncService", + MediaFilesService = "MediaFilesService", AppLauncher = "AppLauncher", TroubleshootingService = "TroubleshootingService", TestingService = "TestingService", diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts new file mode 100644 index 0000000000..ce41edc5e4 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts @@ -0,0 +1,18 @@ +/* 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 {IRemoteResource} from '../../SIL/Harmony/Resource/IRemoteResource'; +import type {ILocalResource} from '../../SIL/Harmony/Resource/ILocalResource'; + +export interface IMediaFilesServiceJsInvokable +{ + resourcesPendingDownload() : Promise; + resourcesPendingUpload() : Promise; + downloadAllResources() : Promise; + uploadAllResources() : Promise; + downloadResources(resources: IRemoteResource[]) : Promise; + uploadResources(resources: ILocalResource[]) : Promise; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectScope.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectScope.ts index 7cd53912f9..a560a59238 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectScope.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectScope.ts @@ -16,5 +16,6 @@ export interface IProjectScope miniLcm: DotNet.DotNetObject; historyService?: DotNet.DotNetObject; syncService?: DotNet.DotNetObject; + mediaFilesService?: DotNet.DotNetObject; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Resource/ILocalResource.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Resource/ILocalResource.ts new file mode 100644 index 0000000000..3dade6d918 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Resource/ILocalResource.ts @@ -0,0 +1,11 @@ +/* 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 ILocalResource +{ + id: string; + localPath: string; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/project-context.svelte.ts b/frontend/viewer/src/lib/project-context.svelte.ts index 60c787e17c..5de3e474bb 100644 --- a/frontend/viewer/src/lib/project-context.svelte.ts +++ b/frontend/viewer/src/lib/project-context.svelte.ts @@ -6,6 +6,9 @@ import type { import type { ISyncServiceJsInvokable } from '$lib/dotnet-types/generated-types/FwLiteShared/Services/ISyncServiceJsInvokable'; +import type { + IMediaFilesServiceJsInvokable +} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable'; import {resource, type ResourceOptions, type ResourceReturn} from 'runed'; import {SvelteMap} from 'svelte/reactivity'; import type {IProjectData} from '$lib/dotnet-types/generated-types/LcmCrdt/IProjectData'; @@ -18,6 +21,7 @@ interface ProjectContextSetup { api: IMiniLcmJsInvokable; historyService?: IHistoryServiceJsInvokable; syncService?: ISyncServiceJsInvokable; + mediaFilesService?: IMediaFilesServiceJsInvokable; projectName: string; projectCode: string; projectType?: 'crdt' | 'fwdata'; @@ -43,6 +47,7 @@ export class ProjectContext { #projectData = $state(); #historyService: IHistoryServiceJsInvokable | undefined = $state(undefined); #syncService: ISyncServiceJsInvokable | undefined = $state(undefined); + #mediaFilesService: IMediaFilesServiceJsInvokable | undefined = $state(undefined); #paratext = $state(false); #features = resource(() => this.#api, (api) => { if (!api) return Promise.resolve({} satisfies IMiniLcmFeatures); @@ -81,6 +86,9 @@ export class ProjectContext { public get syncService(): ISyncServiceJsInvokable | undefined { return this.#syncService; } + public get mediaFilesService(): IMediaFilesServiceJsInvokable | undefined { + return this.#mediaFilesService; + } public get inParatext(): boolean { return this.#paratext; } @@ -93,6 +101,7 @@ export class ProjectContext { this.#api = args.api; this.#historyService = args.historyService; this.#syncService = args.syncService; + this.#mediaFilesService = args.mediaFilesService; this.#projectName = args.projectName; this.#projectCode = args.projectCode; this.#projectType = args.projectType; diff --git a/frontend/viewer/src/lib/services/media-files-service.ts b/frontend/viewer/src/lib/services/media-files-service.ts new file mode 100644 index 0000000000..7d5ef9b4f8 --- /dev/null +++ b/frontend/viewer/src/lib/services/media-files-service.ts @@ -0,0 +1,44 @@ +import type {IMediaFilesServiceJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable'; +import type {ILocalResource} from '$lib/dotnet-types/generated-types/SIL/Harmony/Resource/ILocalResource'; +import type {IRemoteResource} from '$lib/dotnet-types/generated-types/SIL/Harmony/Resource/IRemoteResource'; +import {type ProjectContext, useProjectContext} from '$lib/project-context.svelte'; + +export function useMediaFilesService() { + const projectContext = useProjectContext(); + if (!projectContext.mediaFilesService) { + throw new Error('MediaFilesService not available in the current project context'); + } + return new MediaFilesService(projectContext); +} + +export class MediaFilesService { + #projectContext: ProjectContext; + get mediaFilesApi(): IMediaFilesServiceJsInvokable { + if (!this.#projectContext.mediaFilesService) { + throw new Error('MediaFilesService not available in the current project context'); + } + return this.#projectContext.mediaFilesService; + } + + constructor(projectContext: ProjectContext) { + this.#projectContext = projectContext; + } + resourcesPendingDownload() { + return this.mediaFilesApi.resourcesPendingDownload(); + } + resourcesPendingUpload() { + return this.mediaFilesApi.resourcesPendingUpload(); + } + downloadAllResources() { + return this.mediaFilesApi.downloadAllResources(); + } + uploadAllResources() { + return this.mediaFilesApi.uploadAllResources(); + } + downloadResources(resources: IRemoteResource[]) { + return this.mediaFilesApi.downloadResources(resources); + } + uploadResources(resources: ILocalResource[]) { + return this.mediaFilesApi.uploadResources(resources); + } +} diff --git a/frontend/viewer/src/lib/services/service-provider.ts b/frontend/viewer/src/lib/services/service-provider.ts index 2eca49731f..7ae9448050 100644 --- a/frontend/viewer/src/lib/services/service-provider.ts +++ b/frontend/viewer/src/lib/services/service-provider.ts @@ -16,6 +16,7 @@ import type {IJsEventListener} from '$lib/dotnet-types/generated-types/FwLiteSha import type {IFwEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IFwEvent'; import type {IHistoryServiceJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable'; import type {ISyncServiceJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/ISyncServiceJsInvokable'; +import type {IMediaFilesServiceJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable'; import {useProjectContext} from '../project-context.svelte'; export type ServiceKey = keyof LexboxServiceRegistry; @@ -28,6 +29,7 @@ export type LexboxServiceRegistry = { [DotnetService.ProjectServicesProvider]: IProjectServicesProvider, [DotnetService.HistoryService]: IHistoryServiceJsInvokable, [DotnetService.SyncService]: ISyncServiceJsInvokable, + [DotnetService.MediaFilesService]: IMediaFilesServiceJsInvokable, [DotnetService.AppLauncher]: IAppLauncher, [DotnetService.TroubleshootingService]: ITroubleshootingService, [DotnetService.TestingService]: ITestingService, From 24bf2981584b4b507167ccc64f589d23acb3a3eb Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 21 Aug 2025 14:27:05 +0700 Subject: [PATCH 05/14] Call correct service methods for up/download all Copy and paste error here --- .../FwLiteShared/Services/MediaFilesServiceJsInvokable.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs index 14a057ee3c..76b113708c 100644 --- a/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs @@ -21,13 +21,13 @@ public async Task ResourcesPendingUpload() [JSInvokable] public async Task DownloadAllResources() { - await mediaService.ResourcesPendingUpload(); + await mediaService.DownloadAllResources(); } [JSInvokable] public async Task UploadAllResources() { - await mediaService.ResourcesPendingUpload(); + await mediaService.UploadAllResources(); } [JSInvokable] From b46bb0d92c1fe5aad84409f3d166ad96a4f92e1c Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 21 Aug 2025 14:28:05 +0700 Subject: [PATCH 06/14] Create VERY basic UI for testing media files API --- .../src/project/MediaFilesDialog.svelte | 147 ++++++++++++++++++ .../viewer/src/project/ProjectSidebar.svelte | 9 ++ 2 files changed, 156 insertions(+) create mode 100644 frontend/viewer/src/project/MediaFilesDialog.svelte diff --git a/frontend/viewer/src/project/MediaFilesDialog.svelte b/frontend/viewer/src/project/MediaFilesDialog.svelte new file mode 100644 index 0000000000..3a5d29cc08 --- /dev/null +++ b/frontend/viewer/src/project/MediaFilesDialog.svelte @@ -0,0 +1,147 @@ + + + + + + + + {$t`Download Files`} + + {#if loadingDownload || loadingUpload} + + {:else} +
+ + +
+ +
+
+ {pendingDownloadCount ?? '?'} files to download +
+
+ +
+
+ +
+
+ {pendingUploadCount ?? '?'} files to upload +
+
+ +
+
+ {/if} +
+
diff --git a/frontend/viewer/src/project/ProjectSidebar.svelte b/frontend/viewer/src/project/ProjectSidebar.svelte index 0bb81649ba..2dabdcf9c4 100644 --- a/frontend/viewer/src/project/ProjectSidebar.svelte +++ b/frontend/viewer/src/project/ProjectSidebar.svelte @@ -16,6 +16,7 @@ import DevContent from '$lib/layout/DevContent.svelte'; import TroubleshootDialog from '$lib/troubleshoot/TroubleshootDialog.svelte'; import SyncDialog from './SyncDialog.svelte'; + import MediaFilesDialog from './MediaFilesDialog.svelte'; import {useFeatures} from '$lib/services/feature-service'; import {useProjectStats} from '$lib/project-stats'; import {formatNumber} from '$lib/components/ui/format'; @@ -52,6 +53,7 @@ const supportsTroubleshooting = useTroubleshootingService(); let troubleshootDialog = $state(); let syncDialog = $state(); + let mediaFilesDialog = $state(); {#snippet ViewButton(view: View, icon: IconClass, label: string, stat?: string)} @@ -103,8 +105,15 @@ {#if features.sync} + + mediaFilesDialog?.open()} class="justify-between"> +
+ + {$t`Media Files`} +
+
syncDialog?.open()} class="justify-between"> {#snippet tooltipContent()} {#if syncStatus === SyncStatus.Offline} From 7fa38a85d60d09bb727318944be81b43c7d27d20 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 21 Aug 2025 15:36:19 +0700 Subject: [PATCH 07/14] Fix DownloadAllResources Was calling wrong remote API --- .../FwLite/LcmCrdt/MediaServer/LcmMediaService.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 7dc7ba9b1a..2c69970908 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -69,14 +69,17 @@ public async Task DeleteResource(Guid fileId) public async Task DownloadAllResources() { - var resources = await ResourcesPendingDownload(); - var localResourceCachePath = options.Value.LocalResourceCachePath; - foreach (var resource in resources) + var connectionStatus = await httpClientProvider.ConnectionStatus(); + if (connectionStatus == ConnectionStatus.Online) { - if (resource.RemoteId is null) continue; - await ((IRemoteResourceService)this).DownloadResource(resource.RemoteId, localResourceCachePath); - // NOTE: DownloadResource never uses the localResourceCachePath parameter; bug? Or just a quirk of how the API works? + var resources = await ResourcesPendingDownload(); + foreach (var resource in resources) + { + if (resource.RemoteId is null) continue; + await resourceService.DownloadResource(resource.Id, this); + } } + // TODO: Gracefully handle other connection statuses, e.g. "not logged in" } public async Task UploadAllResources() From f1e5c302ac75ce76360e22f1fa9c194a8aeeb804 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 21 Aug 2025 15:51:23 +0700 Subject: [PATCH 08/14] Fix notification in barebones test UI --- frontend/viewer/src/project/MediaFilesDialog.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/viewer/src/project/MediaFilesDialog.svelte b/frontend/viewer/src/project/MediaFilesDialog.svelte index 3a5d29cc08..804fd8cf8d 100644 --- a/frontend/viewer/src/project/MediaFilesDialog.svelte +++ b/frontend/viewer/src/project/MediaFilesDialog.svelte @@ -78,9 +78,10 @@ async function downloadAll() { try { const downloadPromise = service.downloadAllResources(); + const count = pendingDownloadCount; // Break reactivity before we set pending count to 0 AppNotification.promise(downloadPromise, { loading: $t`Downloading files from remote...`, - success: (result) => $t`${pendingDownloadCount} files downloaded.`, + success: $t`${count} files downloaded.`, error: (error) => $t`Failed to download files.` + '\n' + (error as Error).message, }); } finally { @@ -92,9 +93,10 @@ async function uploadAll() { try { const uploadPromise = service.uploadAllResources(); + const count = pendingUploadCount; // Break reactivity before we set pending count to 0 AppNotification.promise(uploadPromise, { loading: $t`Uploading files to remote...`, - success: (result) => $t`${pendingUploadCount} files uploaded.`, + success: $t`${count} files uploaded.`, error: (error) => $t`Failed to upload files.` + '\n' + (error as Error).message, }); } finally { From a636f4178d4a19cb3457f61aecffb0d0420f917d Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 22 Aug 2025 11:02:02 +0700 Subject: [PATCH 09/14] Add GetFileMetadata to JsInvokable API This allows us to display a list of filenames, and possibly author and/or upload dates, for the user to be able to select which file(s) to download or upload. --- .../Services/MediaFilesServiceJsInvokable.cs | 7 +++ .../LcmCrdt/MediaServer/IMediaServerClient.cs | 3 +- .../LcmCrdt/MediaServer/LcmMediaService.cs | 16 +----- .../Services/IMediaFilesServiceJsInvokable.ts | 2 + .../src/lib/services/media-files-service.ts | 3 ++ .../src/project/MediaFilesDialog.svelte | 52 +++++++++++++------ 6 files changed, 53 insertions(+), 30 deletions(-) diff --git a/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs index 76b113708c..6436e8c644 100644 --- a/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs @@ -1,6 +1,7 @@ using Microsoft.JSInterop; using LcmCrdt.MediaServer; using SIL.Harmony.Resource; +using MiniLcm.Media; namespace FwLiteShared.Services; @@ -41,4 +42,10 @@ public async Task UploadResources(IEnumerable resources) { await mediaService.UploadResources(resources); } + + [JSInvokable] + public async Task GetFileMetadata(Guid fileId) + { + return await mediaService.GetFileMetadata(fileId); + } } diff --git a/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs b/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs index 029fd0a5c2..fe19e2a543 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/IMediaServerClient.cs @@ -1,3 +1,4 @@ +using MiniLcm.Media; using Refit; namespace LcmCrdt.MediaServer; @@ -9,7 +10,7 @@ public interface IMediaServerClient Task DownloadFile(Guid fileId); [Get("/api/media/metadata/{fileId}")] - Task GetFileMetadata(Guid fileId); + Task GetFileMetadata(Guid fileId); [Post("/api/media")] [Multipart] diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 2c69970908..e90e7c11e7 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -141,22 +141,10 @@ public async Task GetFileStream(Guid fileId) public async Task GetFileMetadata(Guid fileId) { var mediaClient = await MediaServerClient(); - var response = await mediaClient.GetFileMetadata(fileId); - if (!response.IsSuccessStatusCode) - { - throw new Exception($"Failed to retrieve metadata for file {fileId}: {response.StatusCode} {response.ReasonPhrase}"); - } - var metadata = await response.Content.ReadFromJsonAsync(); + var metadata = await mediaClient.GetFileMetadata(fileId); if (metadata is null) { - // Try to get content into error message, but if buffering not enabled for this request, give up - var content = ""; - try - { - content = await response.Content.ReadAsStringAsync(); - } - catch { } // Oh well, we tried - throw new Exception($"Failed to retrieve metadata for file {fileId}: response was in incorrect format. {content}"); + throw new Exception($"Failed to retrieve metadata for file {fileId}"); } return metadata; } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts index ce41edc5e4..b5fac10b41 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts @@ -5,6 +5,7 @@ import type {IRemoteResource} from '../../SIL/Harmony/Resource/IRemoteResource'; import type {ILocalResource} from '../../SIL/Harmony/Resource/ILocalResource'; +import type {ILcmFileMetadata} from '../../MiniLcm/Media/ILcmFileMetadata'; export interface IMediaFilesServiceJsInvokable { @@ -14,5 +15,6 @@ export interface IMediaFilesServiceJsInvokable uploadAllResources() : Promise; downloadResources(resources: IRemoteResource[]) : Promise; uploadResources(resources: ILocalResource[]) : Promise; + getFileMetadata(fileId: string) : Promise; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/services/media-files-service.ts b/frontend/viewer/src/lib/services/media-files-service.ts index 7d5ef9b4f8..e141e95a96 100644 --- a/frontend/viewer/src/lib/services/media-files-service.ts +++ b/frontend/viewer/src/lib/services/media-files-service.ts @@ -41,4 +41,7 @@ export class MediaFilesService { uploadResources(resources: ILocalResource[]) { return this.mediaFilesApi.uploadResources(resources); } + getFileMetadata(id: string) { + return this.mediaFilesApi.getFileMetadata(id); + } } diff --git a/frontend/viewer/src/project/MediaFilesDialog.svelte b/frontend/viewer/src/project/MediaFilesDialog.svelte index 804fd8cf8d..b13e1e4654 100644 --- a/frontend/viewer/src/project/MediaFilesDialog.svelte +++ b/frontend/viewer/src/project/MediaFilesDialog.svelte @@ -33,10 +33,12 @@ const projectContext = useProjectContext(); const service = useMediaFilesService(); const features = useFeatures(); - let remoteFiles = $state([]); - let localFiles = $state([]); - let pendingUploadCount = $derived(localFiles?.length ?? 0); - let pendingDownloadCount = $derived(remoteFiles?.length ?? 0); + let remoteFileIds = $state([]); + let localFileIds = $state([]); + let pendingUploadCount = $derived(localFileIds?.length ?? 0); + let pendingDownloadCount = $derived(remoteFileIds?.length ?? 0); + const localFiles = $derived(localFileIds.map(localFile => service.getFileMetadata(localFile.id))); + const remoteFiles = $derived(remoteFileIds.map(remoteFile => service.getFileMetadata(remoteFile.id))); let server = $derived(projectContext.server); let loading = $state(false); const openQueryParam = new QueryParamStateBool( @@ -60,7 +62,7 @@ try { let remotePromise = service.resourcesPendingDownload(); let localPromise = service.resourcesPendingUpload(); - [localFiles, remoteFiles] = await Promise.all([ + [localFileIds, remoteFileIds] = await Promise.all([ localPromise, remotePromise, ]); @@ -70,8 +72,8 @@ } function onClose(): void { - localFiles = []; - remoteFiles = []; + localFileIds = []; + remoteFileIds = []; } let loadingDownload = $state(false); @@ -104,12 +106,6 @@ loadingUpload = false; } } - - function onLoginStatusChange(status: 'logged-in' | 'logged-out') { - if (status === 'logged-in') { - onOpen(); - } - } @@ -126,7 +122,7 @@
- +
{pendingDownloadCount ?? '?'} files to download @@ -134,8 +130,21 @@
+
+
    + {#each remoteFiles as filePromise, idx (idx)} +
  • + {#await filePromise} + ... + {:then metadata} + {metadata.filename} of type {metadata.mimeType} + {/await} +
  • + {/each} +
+
- +
{pendingUploadCount ?? '?'} files to upload @@ -143,6 +152,19 @@
+
+
    + {#each localFiles as filePromise, idx (idx)} +
  • + {#await filePromise} + ... + {:then metadata} + {metadata.filename} of type {metadata.mimeType} + {/await} +
  • + {/each} +
+
{/if} From 51e5622c1b5f12cfe63535bfc7259ff9346e04fa Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 22 Aug 2025 11:10:35 +0700 Subject: [PATCH 10/14] Slightly nicer display of file types --- .../viewer/src/project/MediaFilesDialog.svelte | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/viewer/src/project/MediaFilesDialog.svelte b/frontend/viewer/src/project/MediaFilesDialog.svelte index b13e1e4654..ae8495aef4 100644 --- a/frontend/viewer/src/project/MediaFilesDialog.svelte +++ b/frontend/viewer/src/project/MediaFilesDialog.svelte @@ -106,6 +106,15 @@ loadingUpload = false; } } + + function fileTypeIcon(mimeType: string) + { + if (!mimeType) return 'i-mdi-file'; + if (mimeType.startsWith('audio')) return 'i-mdi-file-music'; + if (mimeType.startsWith('video')) return 'i-mdi-file-video'; + if (mimeType.startsWith('image')) return 'i-mdi-file-image'; + return 'i-mdi-file'; + } @@ -122,7 +131,7 @@
- +
{pendingDownloadCount ?? '?'} files to download @@ -137,14 +146,14 @@ {#await filePromise} ... {:then metadata} - {metadata.filename} of type {metadata.mimeType} + {metadata.filename} {/await} {/each}
- +
{pendingUploadCount ?? '?'} files to upload @@ -159,7 +168,7 @@ {#await filePromise} ... {:then metadata} - {metadata.filename} of type {metadata.mimeType} + {metadata.filename} {/await} {/each} From ba611da69985b86a896d716b4a40122b3e3692b9 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 22 Aug 2025 12:17:41 +0700 Subject: [PATCH 11/14] Tweak down/upload JS API to take GUIDs, not objects This allows us to provide checkboxes, which have to have string (rather than object) values in Svelte, to select files to download. --- .../Services/MediaFilesServiceJsInvokable.cs | 8 +++---- .../LcmCrdt/MediaServer/LcmMediaService.cs | 22 +++++++++++++++++-- .../Services/IMediaFilesServiceJsInvokable.ts | 4 ++-- .../src/lib/services/media-files-service.ts | 4 ++-- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs index 6436e8c644..4079701867 100644 --- a/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs @@ -32,15 +32,15 @@ public async Task UploadAllResources() } [JSInvokable] - public async Task DownloadResources(IEnumerable resources) + public async Task DownloadResources(IEnumerable resourceIds) { - await mediaService.DownloadResources(resources); + await mediaService.DownloadResources(resourceIds); } [JSInvokable] - public async Task UploadResources(IEnumerable resources) + public async Task UploadResources(IEnumerable resourceIds) { - await mediaService.UploadResources(resources); + await mediaService.UploadResources(resourceIds); } [JSInvokable] diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index e90e7c11e7..a2588d3836 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -84,7 +84,15 @@ public async Task DownloadAllResources() public async Task UploadAllResources() { - await UploadResources(await ResourcesPendingUpload()); + await UploadResources((await ResourcesPendingUpload()).Select(r => r.Id)); + } + + public async Task DownloadResources(IEnumerable resourceIds) + { + foreach (var resourceId in resourceIds) + { + await DownloadResourceIfNeeded(resourceId); + } } public async Task DownloadResources(IEnumerable resources) @@ -95,11 +103,21 @@ public async Task DownloadResources(IEnumerable resources) } } + public async Task UploadResources(IEnumerable resourceIds) + { + var clientId = currentProjectService.ProjectData.ClientId; + foreach (var resourceId in resourceIds) + { + await resourceService.UploadPendingResource(resourceId, clientId, this); + } + } + public async Task UploadResources(IEnumerable resources) { + var clientId = currentProjectService.ProjectData.ClientId; foreach (var resource in resources) { - await ((IRemoteResourceService)this).UploadResource(resource.Id, resource.LocalPath); + await resourceService.UploadPendingResource(resource, clientId, this); } } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts index b5fac10b41..7c73a899ad 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts @@ -13,8 +13,8 @@ export interface IMediaFilesServiceJsInvokable resourcesPendingUpload() : Promise; downloadAllResources() : Promise; uploadAllResources() : Promise; - downloadResources(resources: IRemoteResource[]) : Promise; - uploadResources(resources: ILocalResource[]) : Promise; + downloadResources(resourceIds: string[]) : Promise; + uploadResources(resourceIds: string[]) : Promise; getFileMetadata(fileId: string) : Promise; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/services/media-files-service.ts b/frontend/viewer/src/lib/services/media-files-service.ts index e141e95a96..efb5aec9a7 100644 --- a/frontend/viewer/src/lib/services/media-files-service.ts +++ b/frontend/viewer/src/lib/services/media-files-service.ts @@ -35,10 +35,10 @@ export class MediaFilesService { uploadAllResources() { return this.mediaFilesApi.uploadAllResources(); } - downloadResources(resources: IRemoteResource[]) { + downloadResources(resources: string[]) { return this.mediaFilesApi.downloadResources(resources); } - uploadResources(resources: ILocalResource[]) { + uploadResources(resources: string[]) { return this.mediaFilesApi.uploadResources(resources); } getFileMetadata(id: string) { From 0ae4a8d7b7c6b79c76c9a2e1b995e57522b4a834 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 22 Aug 2025 12:20:06 +0700 Subject: [PATCH 12/14] Test UI now allows selecting files to download Also would allow selecting files to upload, but there's no way in the UI to add a file to the project without it being automatically uploaded, so this is harder to test. --- .../ui/checkbox/checkbox-group.svelte | 10 +++ .../src/lib/components/ui/checkbox/index.ts | 3 +- .../src/project/MediaFilesDialog.svelte | 88 ++++++++++++++----- 3 files changed, 78 insertions(+), 23 deletions(-) create mode 100644 frontend/viewer/src/lib/components/ui/checkbox/checkbox-group.svelte diff --git a/frontend/viewer/src/lib/components/ui/checkbox/checkbox-group.svelte b/frontend/viewer/src/lib/components/ui/checkbox/checkbox-group.svelte new file mode 100644 index 0000000000..823ff5cebd --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/checkbox/checkbox-group.svelte @@ -0,0 +1,10 @@ + + + diff --git a/frontend/viewer/src/lib/components/ui/checkbox/index.ts b/frontend/viewer/src/lib/components/ui/checkbox/index.ts index 30198775c8..b128c38cdb 100644 --- a/frontend/viewer/src/lib/components/ui/checkbox/index.ts +++ b/frontend/viewer/src/lib/components/ui/checkbox/index.ts @@ -1,5 +1,6 @@ import Root from './checkbox.svelte'; +import Group from './checkbox-group.svelte'; export { // - Root as Checkbox, Root + Root as Checkbox, Group as CheckboxGroup, Root }; diff --git a/frontend/viewer/src/project/MediaFilesDialog.svelte b/frontend/viewer/src/project/MediaFilesDialog.svelte index ae8495aef4..f32e597519 100644 --- a/frontend/viewer/src/project/MediaFilesDialog.svelte +++ b/frontend/viewer/src/project/MediaFilesDialog.svelte @@ -25,6 +25,7 @@ import {useMediaFilesService} from '$lib/services/media-files-service'; import type {IRemoteResource} from '$lib/dotnet-types/generated-types/SIL/Harmony/Resource/IRemoteResource'; import type {ILocalResource} from '$lib/dotnet-types/generated-types/SIL/Harmony/Resource/ILocalResource'; + import {Checkbox, CheckboxGroup} from '$lib/components/ui/checkbox'; const { syncStatus = SyncStatus.Success @@ -33,12 +34,10 @@ const projectContext = useProjectContext(); const service = useMediaFilesService(); const features = useFeatures(); - let remoteFileIds = $state([]); - let localFileIds = $state([]); - let pendingUploadCount = $derived(localFileIds?.length ?? 0); - let pendingDownloadCount = $derived(remoteFileIds?.length ?? 0); - const localFiles = $derived(localFileIds.map(localFile => service.getFileMetadata(localFile.id))); - const remoteFiles = $derived(remoteFileIds.map(remoteFile => service.getFileMetadata(remoteFile.id))); + let remoteFiles = $state([]); + let localFiles = $state([]); + let pendingUploadCount = $derived(localFiles?.length ?? 0); + let pendingDownloadCount = $derived(remoteFiles?.length ?? 0); let server = $derived(projectContext.server); let loading = $state(false); const openQueryParam = new QueryParamStateBool( @@ -46,6 +45,9 @@ false, ); + let selectedFilesToDownload = $state([]); + let selectedFilesToUpload = $state([]); + const serverName = $derived(server?.displayName ?? projectContext.projectData?.serverId ?? 'unknown'); watch(() => openQueryParam.current, (newValue) => { @@ -62,7 +64,7 @@ try { let remotePromise = service.resourcesPendingDownload(); let localPromise = service.resourcesPendingUpload(); - [localFileIds, remoteFileIds] = await Promise.all([ + [localFiles, remoteFiles] = await Promise.all([ localPromise, remotePromise, ]); @@ -72,14 +74,14 @@ } function onClose(): void { - localFileIds = []; - remoteFileIds = []; + localFiles = []; + remoteFiles = []; } let loadingDownload = $state(false); - async function downloadAll() { + function downloadAll() { try { - const downloadPromise = service.downloadAllResources(); + const downloadPromise = service.downloadAllResources().then(onOpen); const count = pendingDownloadCount; // Break reactivity before we set pending count to 0 AppNotification.promise(downloadPromise, { loading: $t`Downloading files from remote...`, @@ -87,14 +89,13 @@ error: (error) => $t`Failed to download files.` + '\n' + (error as Error).message, }); } finally { - pendingDownloadCount = 0; loadingDownload = false; } } let loadingUpload = $state(false); - async function uploadAll() { + function uploadAll() { try { - const uploadPromise = service.uploadAllResources(); + const uploadPromise = service.uploadAllResources().then(onOpen); const count = pendingUploadCount; // Break reactivity before we set pending count to 0 AppNotification.promise(uploadPromise, { loading: $t`Uploading files to remote...`, @@ -102,7 +103,34 @@ error: (error) => $t`Failed to upload files.` + '\n' + (error as Error).message, }); } finally { - pendingUploadCount = 0; + loadingUpload = false; + } + } + function downloadSelected() { + try { + const downloadPromise = service.downloadResources(selectedFilesToDownload).then(onOpen); + const count = selectedFilesToDownload.length; // Break reactivity before we set selected count to 0 + AppNotification.promise(downloadPromise, { + loading: $t`Downloading files from remote...`, + success: $t`${count} files downloaded.`, + error: (error) => $t`Failed to download files.` + '\n' + (error as Error).message, + }); + } finally { + selectedFilesToDownload = []; + loadingDownload = false; + } + } + function uploadSelected() { + try { + const uploadPromise = service.uploadResources(selectedFilesToUpload).then(onOpen); + const count = selectedFilesToUpload.length; // Break reactivity before we set selected count to 0 + AppNotification.promise(uploadPromise, { + loading: $t`Uploading files to remote...`, + success: $t`${count} files uploaded.`, + error: (error) => $t`Failed to upload files.` + '\n' + (error as Error).message, + }); + } finally { + selectedFilesToUpload = []; loadingUpload = false; } } @@ -126,7 +154,7 @@ {:else}
@@ -137,21 +165,29 @@ {pendingDownloadCount ?? '?'} files to download
- +
    - {#each remoteFiles as filePromise, idx (idx)} + + {#each remoteFiles as file, idx (idx)}
  • - {#await filePromise} + {#await service.getFileMetadata(file.id)} ... {:then metadata} + {metadata.filename} {/await}
  • {/each} +
+ {#if selectedFilesToDownload?.length} +
+ +
+ {/if}
@@ -159,21 +195,29 @@ {pendingUploadCount ?? '?'} files to upload
- +
    - {#each localFiles as filePromise, idx (idx)} + + {#each localFiles as file, idx (idx)}
  • - {#await filePromise} + {#await service.getFileMetadata(file.id)} ... {:then metadata} + {metadata.filename} {/await}
  • {/each} +
+ {#if selectedFilesToUpload?.length} +
+ +
+ {/if}
{/if} From 35e07a36197b6dc2076868d0f0e60c3bd9f4b1c9 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 22 Aug 2025 16:13:08 +0700 Subject: [PATCH 13/14] Also expose allResources to JSInvokable API --- .../Services/MediaFilesServiceJsInvokable.cs | 6 ++++++ .../TypeGen/ReinforcedFwLiteTypingConfig.cs | 1 + .../Services/IMediaFilesServiceJsInvokable.ts | 2 ++ .../SIL/Harmony/Resource/IHarmonyResource.ts | 14 ++++++++++++++ .../viewer/src/lib/services/media-files-service.ts | 4 ++++ 5 files changed, 27 insertions(+) create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Resource/IHarmonyResource.ts diff --git a/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs index 4079701867..841ba94e39 100644 --- a/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MediaFilesServiceJsInvokable.cs @@ -7,6 +7,12 @@ namespace FwLiteShared.Services; public class MediaFilesServiceJsInvokable(LcmMediaService mediaService) { + [JSInvokable] + public async Task AllResources() + { + return await mediaService.AllResources(); + } + [JSInvokable] public async Task ResourcesPendingDownload() { diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 1948d2a350..dd26521024 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -86,6 +86,7 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) typeof(MediaFile), typeof(LcmFileMetadata), + typeof(HarmonyResource), typeof(RemoteResource), typeof(LocalResource) ], diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts index 7c73a899ad..0f4fc775e2 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable.ts @@ -3,12 +3,14 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {IHarmonyResource} from '../../SIL/Harmony/Resource/IHarmonyResource'; import type {IRemoteResource} from '../../SIL/Harmony/Resource/IRemoteResource'; import type {ILocalResource} from '../../SIL/Harmony/Resource/ILocalResource'; import type {ILcmFileMetadata} from '../../MiniLcm/Media/ILcmFileMetadata'; export interface IMediaFilesServiceJsInvokable { + allResources() : Promise; resourcesPendingDownload() : Promise; resourcesPendingUpload() : Promise; downloadAllResources() : Promise; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Resource/IHarmonyResource.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Resource/IHarmonyResource.ts new file mode 100644 index 0000000000..db368c8bdb --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Resource/IHarmonyResource.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 interface IHarmonyResource +{ + id: string; + remoteId?: string; + localPath?: string; + local: boolean; + remote: boolean; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/services/media-files-service.ts b/frontend/viewer/src/lib/services/media-files-service.ts index efb5aec9a7..14765517c0 100644 --- a/frontend/viewer/src/lib/services/media-files-service.ts +++ b/frontend/viewer/src/lib/services/media-files-service.ts @@ -1,4 +1,5 @@ import type {IMediaFilesServiceJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable'; +import type {IHarmonyResource} from '$lib/dotnet-types/generated-types/SIL/Harmony/Resource/IHarmonyResource'; import type {ILocalResource} from '$lib/dotnet-types/generated-types/SIL/Harmony/Resource/ILocalResource'; import type {IRemoteResource} from '$lib/dotnet-types/generated-types/SIL/Harmony/Resource/IRemoteResource'; import {type ProjectContext, useProjectContext} from '$lib/project-context.svelte'; @@ -23,6 +24,9 @@ export class MediaFilesService { constructor(projectContext: ProjectContext) { this.#projectContext = projectContext; } + allResources() { + return this.mediaFilesApi.allResources(); + } resourcesPendingDownload() { return this.mediaFilesApi.resourcesPendingDownload(); } From 9add1da3c7b3b3c29a380072aeebd5299140f7f9 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 22 Aug 2025 16:13:38 +0700 Subject: [PATCH 14/14] Add very ugly test UI for allResources API call --- .../src/project/MediaFilesDialog.svelte | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/viewer/src/project/MediaFilesDialog.svelte b/frontend/viewer/src/project/MediaFilesDialog.svelte index f32e597519..caddc84661 100644 --- a/frontend/viewer/src/project/MediaFilesDialog.svelte +++ b/frontend/viewer/src/project/MediaFilesDialog.svelte @@ -25,6 +25,7 @@ import {useMediaFilesService} from '$lib/services/media-files-service'; import type {IRemoteResource} from '$lib/dotnet-types/generated-types/SIL/Harmony/Resource/IRemoteResource'; import type {ILocalResource} from '$lib/dotnet-types/generated-types/SIL/Harmony/Resource/ILocalResource'; + import type {IHarmonyResource} from '$lib/dotnet-types/generated-types/SIL/Harmony/Resource/IHarmonyResource'; import {Checkbox, CheckboxGroup} from '$lib/components/ui/checkbox'; const { @@ -48,6 +49,8 @@ let selectedFilesToDownload = $state([]); let selectedFilesToUpload = $state([]); + let allFiles = $state([]); + const serverName = $derived(server?.displayName ?? projectContext.projectData?.serverId ?? 'unknown'); watch(() => openQueryParam.current, (newValue) => { @@ -64,9 +67,11 @@ try { let remotePromise = service.resourcesPendingDownload(); let localPromise = service.resourcesPendingUpload(); - [localFiles, remoteFiles] = await Promise.all([ + let allPromise = service.allResources(); + [localFiles, remoteFiles, allFiles] = await Promise.all([ localPromise, remotePromise, + allPromise, ]); } finally { loading = false; @@ -218,6 +223,22 @@ {/if} +
+ ALL FILES +
+
+
    + {#each allFiles as file, idx (idx)} +
  • + {#await service.getFileMetadata(file.id)} + ... + {:then metadata} + {metadata.filename} + {/await} +
  • + {/each} +
+
{/if}