diff --git a/backend/FwLite/FwDataMiniLcmBridge/LcmUtils/FwLink.cs b/backend/FwLite/FwDataMiniLcmBridge/LcmUtils/FwLink.cs new file mode 100644 index 000000000..eebc13486 --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge/LcmUtils/FwLink.cs @@ -0,0 +1,9 @@ +namespace FwDataMiniLcmBridge.LcmUtils; + +public static class FwLink +{ + public static string ToEntry(Guid entryId, string projectName) + { + return $"silfw://localhost/link?database={projectName}&tool=lexiconEdit&guid={entryId}"; + } +} diff --git a/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs b/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs index 51f6fe4ac..8ef1afcf6 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs +++ b/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs @@ -20,28 +20,40 @@ public static void AddFwLiteMauiServices(this IServiceCollection services, services.AddSingleton(); configuration.AddJsonFile("appsettings.json", optional: true); - services.Configure(config => - config.LexboxServers = - [ - new(new("https://lexbox.dev.languagetechnology.org"), "Lexbox Dev"), - new(new("https://staging.languagedepot.org"), "Lexbox Staging") - ]); - string environment = "Production"; #if DEBUG environment = "Development"; services.AddBlazorWebViewDeveloperTools(); #endif - var env = new HostingEnvironment() { EnvironmentName = environment }; + IHostEnvironment env = new HostingEnvironment() { EnvironmentName = environment }; services.AddSingleton(env); services.AddFwLiteShared(env); services.AddMauiBlazorWebView(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + services.Configure(config => + { + List servers = + [ + new(new("https://staging.languagedepot.org"), "Lexbox Staging") + ]; + if (env.IsDevelopment()) + { + servers.Add(new(new("https://lexbox.dev.languagetechnology.org"), "Lexbox Dev")); + } + + config.LexboxServers = servers.ToArray(); + config.AfterLoginWebView = () => + { + var window = Application.Current?.Windows.FirstOrDefault(); + if (window is not null) Application.Current?.ActivateWindow(window); + }; + }); #if INCLUDE_FWDATA_BRIDGE //need to call them like this otherwise we need a using statement at the top of the file FwDataMiniLcmBridge.FwDataBridgeKernel.AddFwDataBridge(services); FwLiteProjectSync.FwLiteProjectSyncKernel.AddFwLiteProjectSync(services); + services.AddSingleton(); #endif #if WINDOWS services.AddFwLiteWindows(env); @@ -49,11 +61,7 @@ public static void AddFwLiteMauiServices(this IServiceCollection services, #if ANDROID services.Configure(config => config.ParentActivityOrWindow = Platform.CurrentActivity); #endif - services.Configure(config => config.AfterLoginWebView = () => - { - var window = Application.Current?.Windows.FirstOrDefault(); - if (window is not null) Application.Current?.ActivateWindow(window); - }); + services.Configure(config => { config.AppVersion = AppVersion.Version; diff --git a/backend/FwLite/FwLiteMaui/Platforms/Windows/WindowsKernel.cs b/backend/FwLite/FwLiteMaui/Platforms/Windows/WindowsKernel.cs index fad2b1099..7820fae62 100644 --- a/backend/FwLite/FwLiteMaui/Platforms/Windows/WindowsKernel.cs +++ b/backend/FwLite/FwLiteMaui/Platforms/Windows/WindowsKernel.cs @@ -1,22 +1,44 @@ using System.Runtime.InteropServices; using FwLiteShared; +using FwLiteShared.Auth; using Microsoft.Extensions.Hosting; +using Microsoft.Maui.Platform; namespace FwLiteMaui; public static class WindowsKernel { + public static void AddFwLiteWindows(this IServiceCollection services, IHostEnvironment environment) { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; - services.AddSingleton(); + services.AddSingleton(); if (!FwLiteMauiKernel.IsPortableApp) { services.AddSingleton(); } + + services.Configure(config => + { + config.AfterLoginWebView = () => + { + var window = Application.Current?.Windows.FirstOrDefault()?.Handler?.PlatformView as Microsoft.UI.Xaml.Window; + if (window is null) throw new InvalidOperationException("Could not find window"); + //note, window.Activate() does not work per https://github.com/microsoft/microsoft-ui-xaml/issues/7595 + var hwnd = window.GetWindowHandle(); + WindowHelper.SetForegroundWindow(hwnd); + }; + }); + services.Configure(config => { config.UseDevAssets = environment.IsDevelopment(); }); } } + +public class WindowHelper +{ + [DllImport("user32.dll")] + public static extern void SetForegroundWindow(IntPtr hWnd); +} diff --git a/backend/FwLite/FwLiteMaui/Services/AppLauncher.cs b/backend/FwLite/FwLiteMaui/Services/AppLauncher.cs new file mode 100644 index 000000000..9d915f573 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/Services/AppLauncher.cs @@ -0,0 +1,40 @@ +#if INCLUDE_FWDATA_BRIDGE +using FwDataMiniLcmBridge; +using FwDataMiniLcmBridge.LcmUtils; +using FwLiteShared.Services; +using Microsoft.JSInterop; + +namespace FwLiteMaui.Services; + +public class AppLauncher(FwDataFactory fwDataFactory, FieldWorksProjectList projectList) : IAppLauncher +{ + private readonly ILauncher _launcher = Launcher.Default; + + [JSInvokable] + public Task CanOpen(string uri) + { + return _launcher.CanOpenAsync(uri); + } + + [JSInvokable] + public Task Open(string uri) + { + return _launcher.OpenAsync(uri); + } + + [JSInvokable] + public Task TryOpen(string uri) + { + return _launcher.TryOpenAsync(uri); + } + + [JSInvokable] + public Task OpenInFieldWorks(Guid entryId, string projectName) + { + var project = projectList.GetProject(projectName); + if (project is null) return Task.FromResult(false); + fwDataFactory.CloseProject(project); + return _launcher.TryOpenAsync(FwLink.ToEntry(entryId, projectName)); + } +} +#endif diff --git a/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs index d077bc6e4..08e5a21ea 100644 --- a/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs +++ b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs @@ -148,6 +148,12 @@ private void InvalidateProjectCache() { _authResult = await _application.AcquireTokenSilent(DefaultScopes, account).ExecuteAsync(); } + catch (MsalUiRequiredException) + { + _logger.LogWarning("Ui required, logging out"); + await _application.RemoveAsync(account); + _authResult = null; + } catch (MsalClientException e) when (e.ErrorCode == "multiple_matching_tokens_detected") { _logger.LogWarning(e, "Multiple matching tokens detected, logging out"); @@ -161,6 +167,12 @@ await _application .RemoveAsync(account); //todo might not be the best way to handle this, maybe it's a transient error? _authResult = null; } + catch (Exception e) + { + _logger.LogError(e, "Failed to acquire token silently"); + await _application.RemoveAsync(account); + _authResult = null; + } return _authResult; } diff --git a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs index 6072d9dfd..6664c9285 100644 --- a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs +++ b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs @@ -19,6 +19,7 @@ public static IServiceCollection AddFwLiteShared(this IServiceCollection service services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); //this is scoped so that there will be once instance per blazor circuit, this prevents issues where the same instance is used when reloading the page. diff --git a/backend/FwLite/FwLiteShared/Layout/SvelteLayout.razor b/backend/FwLite/FwLiteShared/Layout/SvelteLayout.razor index 5f0f58fde..a5202da79 100644 --- a/backend/FwLite/FwLiteShared/Layout/SvelteLayout.razor +++ b/backend/FwLite/FwLiteShared/Layout/SvelteLayout.razor @@ -7,6 +7,7 @@ @inject ILogger Logger @inject FwLiteProvider FwLiteProvider @inject IOptions Config; +@inject ProjectServicesProvider ProjectServicesProvider; @implements IAsyncDisposable @if (useDevAssets) { @@ -47,7 +48,6 @@ else @code { private bool useDevAssets => Config.Value.UseDevAssets; // private bool useDevAssets => false; - private IJSObjectReference? module; protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -59,12 +59,14 @@ else await FwLiteProvider.SetService(JS, serviceKey, service); } + await FwLiteProvider.SetService(JS, DotnetService.ProjectServicesProvider, ProjectServicesProvider); + if (useDevAssets) { - module = await JS.InvokeAsync("import", "http://localhost:5173/src/main.ts"); + await JS.InvokeAsync("import", "http://localhost:5173/src/main.ts"); } else { - module = await JS.InvokeAsync("import", + await JS.InvokeAsync("import", "/" + Assets["_content/FwLiteShared/viewer/main.js"]); } } @@ -72,7 +74,6 @@ else public async ValueTask DisposeAsync() { - await (module?.DisposeAsync() ?? ValueTask.CompletedTask); } } diff --git a/backend/FwLite/FwLiteShared/Pages/CrdtProject.razor b/backend/FwLite/FwLiteShared/Pages/CrdtProject.razor index 59a3be620..2bcad7a05 100644 --- a/backend/FwLite/FwLiteShared/Pages/CrdtProject.razor +++ b/backend/FwLite/FwLiteShared/Pages/CrdtProject.razor @@ -1,32 +1,8 @@ @page "/project/{projectName}" @using FwLiteShared.Layout -@using FwLiteShared.Services -@using Microsoft.Extensions.DependencyInjection -@inherits OwningComponentBaseAsync @layout SvelteLayout; -@inject IJSRuntime JS; -@* injecting here means we get a provider scoped to the current circuit *@ -@inject FwLiteProvider FwLiteProvider; @code { - [Parameter] public required string ProjectName { get; set; } - - private IAsyncDisposable? _disposable; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender) return; - //scoped services here are per page render, meaning they will get cleaned up when the page is disposed - _disposable = await FwLiteProvider.InjectCrdtProject(JS, ScopedServices, ProjectName); - } - - protected override async ValueTask DisposeAsyncCore() - { - //sadly this is not called when the page is left, not sure how we can fix that yet - await base.DisposeAsyncCore(); - if (_disposable is not null) - await _disposable.DisposeAsync(); - } } diff --git a/backend/FwLite/FwLiteShared/Pages/FwdataProject.razor b/backend/FwLite/FwLiteShared/Pages/FwdataProject.razor index dbae36bb9..3bd8d7188 100644 --- a/backend/FwLite/FwLiteShared/Pages/FwdataProject.razor +++ b/backend/FwLite/FwLiteShared/Pages/FwdataProject.razor @@ -1,31 +1,10 @@ @page "/fwdata/{projectName}" @using FwLiteShared.Layout -@using FwLiteShared.Services -@using Microsoft.Extensions.DependencyInjection -@inherits OwningComponentBaseAsync @layout SvelteLayout; -@inject IJSRuntime JS; -@inject FwLiteProvider FwLiteProvider; @code { [Parameter] public required string ProjectName { get; set; } - private IAsyncDisposable? _disposable; - - protected override Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender) return Task.CompletedTask; - _disposable = FwLiteProvider.InjectFwDataProject(ScopedServices, ProjectName); - return Task.CompletedTask; - } - - protected override async ValueTask DisposeAsyncCore() - { - await base.DisposeAsyncCore(); - if (_disposable is not null) - await _disposable.DisposeAsync(); - } - } diff --git a/backend/FwLite/FwLiteShared/Pages/Home.razor b/backend/FwLite/FwLiteShared/Pages/Home.razor index 6602abc52..7da07e8e6 100644 --- a/backend/FwLite/FwLiteShared/Pages/Home.razor +++ b/backend/FwLite/FwLiteShared/Pages/Home.razor @@ -2,15 +2,5 @@ @using FwLiteShared.Layout @using FwLiteShared.Services @layout SvelteLayout; -@inject FwLiteProvider FwLiteProvider; -@inject IJSRuntime JS; @*this looks empty because it is, but it's required to declare the route which is then used by the svelte router*@ -@code -{ - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - await FwLiteProvider.SetService(JS, DotnetService.MiniLcmApi, null); - } -} diff --git a/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs b/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs index 9a7cb5099..c672bb69b 100644 --- a/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs @@ -18,47 +18,25 @@ public class FwLiteProvider( CombinedProjectsService projectService, AuthService authService, ImportFwdataService importFwdataService, - CrdtProjectsService crdtProjectsService, - LexboxProjectService lexboxProjectService, - ChangeEventBus changeEventBus, - IEnumerable projectProviders, ILogger logger, IOptions config, - ILoggerFactory loggerFactory -) : IDisposable + IAppLauncher? appLauncher = null +) { public const string OverrideServiceFunctionName = "setOverrideService"; - private readonly List _disposables = []; - private readonly MiniLcmApiProvider _miniLcmApiProvider = new(loggerFactory.CreateLogger()); - - private IProjectProvider? FwDataProjectProvider => - projectProviders.FirstOrDefault(p => p.DataFormat == ProjectDataFormat.FwData); - - public void Dispose() - { - foreach (var disposable in _disposables) - { - disposable.Dispose(); - } - } public Dictionary GetServices() { - return Enum.GetValues().Where(s => s != DotnetService.MiniLcmApi) - .ToDictionary(s => s, GetService); - } - - public object GetService(DotnetService service) - { - return service switch + var services = new Dictionary() { - DotnetService.CombinedProjectsService => projectService, - DotnetService.AuthService => authService, - DotnetService.ImportFwdataService => importFwdataService, - DotnetService.FwLiteConfig => config.Value, - DotnetService.MiniLcmApiProvider => _miniLcmApiProvider, - _ => throw new ArgumentOutOfRangeException(nameof(service), service, null) + [DotnetService.CombinedProjectsService] = projectService, + [DotnetService.AuthService] = authService, + [DotnetService.ImportFwdataService] = importFwdataService, + [DotnetService.FwLiteConfig] = config.Value }; + if (appLauncher is not null) + services[DotnetService.AppLauncher] = appLauncher; + return services; } public async Task SetService(IJSRuntime jsRuntime, DotnetService service, object? serviceInstance) @@ -87,114 +65,18 @@ private bool ShouldConvertToDotnetObject(DotnetService service, [NotNullWhen(tru _ => true }; } - - public async Task InjectCrdtProject(IJSRuntime jsRuntime, - IServiceProvider scopedServices, - string projectName) - { - var project = crdtProjectsService.GetProject(projectName) ?? throw new InvalidOperationException($"Crdt Project {projectName} not found"); - var projectData = await scopedServices.GetRequiredService().SetupProjectContext(project); - await lexboxProjectService.ListenForProjectChanges(projectData, CancellationToken.None); - var entryUpdatedSubscription = changeEventBus.OnProjectEntryUpdated(project).Subscribe(entry => - { - _ = jsRuntime.DurableInvokeVoidAsync("notifyEntryUpdated", projectName, entry); - }); - var cleanup = ProvideMiniLcmApi(ActivatorUtilities.CreateInstance(scopedServices, project)); - - return Defer.Async(() => - { - cleanup.Dispose(); - entryUpdatedSubscription.Dispose(); - return Task.CompletedTask; - }); - } - - public IAsyncDisposable InjectFwDataProject(IServiceProvider scopedServices, string projectName) - { - if (FwDataProjectProvider is null) throw new InvalidOperationException("FwData Project provider is not available"); - var project = FwDataProjectProvider.GetProject(projectName) ?? throw new InvalidOperationException($"FwData Project {projectName} not found"); - var service = ActivatorUtilities.CreateInstance(scopedServices, - FwDataProjectProvider.OpenProject(project), project); - var cleanup = ProvideMiniLcmApi(service); - return Defer.Async(() => - { - cleanup.Dispose(); - service.Dispose(); - return Task.CompletedTask; - }); - } - - private IDisposable ProvideMiniLcmApi(MiniLcmJsInvokable miniLcmApi) - { - var reference = DotNetObjectReference.Create(miniLcmApi); - var cleanup = _miniLcmApiProvider.SetMiniLcmApi(reference); - return Defer.Action(() => - { - reference.Dispose(); - cleanup.Dispose(); - }); - } } -/// -/// this service is used to allow the frontend to await the api being setup by the backend, this means the frontend doesn't need to poll for the api being ready -/// -internal class MiniLcmApiProvider(ILogger logger) -{ - private TaskCompletionSource> _tcs = new(); - - [JSInvokable] - public async Task> GetMiniLcmApi() - { -#pragma warning disable VSTHRD003 - return await _tcs.Task; -#pragma warning restore VSTHRD003 - } - - public IDisposable SetMiniLcmApi(DotNetObjectReference miniLcmApi) - { - logger.LogInformation("Setting MiniLcmApi"); - _tcs.SetResult(miniLcmApi); - var currentTask = _tcs.Task; - return Defer.Action(() => - { - //if the tcs has been reset, then we don't want to clear it again - if (_tcs.Task == currentTask) - { - ClearMiniLcmApi(); - } - }); - } - - [JSInvokable] - public void ClearMiniLcmApi() - { - logger.LogInformation("Clearing MiniLcmApi"); - //we can't cancel a tcs if it's already completed - if (!_tcs.Task.IsCompleted) - { - //we need to tell any clients awaiting the tcs it's canceled. otherwise they will hang - _tcs.SetCanceled(); - } - else if (_tcs.Task.IsCompletedSuccessfully) - { -#pragma warning disable VSTHRD002 - _tcs.Task.Result.Value.Dispose(); -#pragma warning restore VSTHRD002 - } - - //create a new tcs so any new clients will await the new api. - _tcs = new(); - } -} [JsonConverter(typeof(JsonStringEnumConverter))] public enum DotnetService { MiniLcmApi, - MiniLcmApiProvider, CombinedProjectsService, AuthService, ImportFwdataService, FwLiteConfig, + ProjectServicesProvider, + HistoryService, + AppLauncher } diff --git a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs new file mode 100644 index 000000000..259539406 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs @@ -0,0 +1,33 @@ +using LcmCrdt; +using Microsoft.JSInterop; +using MiniLcm.Models; +using SIL.Harmony.Db; + +namespace FwLiteShared.Services; + +public class HistoryServiceJsInvokable(HistoryService historyService) +{ + [JSInvokable] + public Task GetObject(Guid commitId, Guid entityId) + { + return historyService.GetObject(commitId, entityId); + } + + [JSInvokable] + public async ValueTask ProjectActivity() + { + return await historyService.ProjectActivity().ToArrayAsync(); + } + + [JSInvokable] + public Task GetSnapshot(Guid snapshotId) + { + return historyService.GetSnapshot(snapshotId); + } + + [JSInvokable] + public async ValueTask GetHistory(Guid entityId) + { + return await historyService.GetHistory(entityId).ToArrayAsync(); + } +} diff --git a/backend/FwLite/FwLiteShared/Services/IAppLauncher.cs b/backend/FwLite/FwLiteShared/Services/IAppLauncher.cs new file mode 100644 index 000000000..d24b2627e --- /dev/null +++ b/backend/FwLite/FwLiteShared/Services/IAppLauncher.cs @@ -0,0 +1,17 @@ +using Microsoft.JSInterop; + +namespace FwLiteShared.Services; + +public interface IAppLauncher +{ + [JSInvokable] + Task CanOpen(string uri); + + [JSInvokable] + Task Open(string uri); + + [JSInvokable] + Task TryOpen(string uri); + [JSInvokable] + Task OpenInFieldWorks(Guid entryId, string projectName); +} diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index d24f7a3c4..d65357b90 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -6,7 +6,7 @@ namespace FwLiteShared.Services; -internal class MiniLcmJsInvokable( +public class MiniLcmJsInvokable( IMiniLcmApi api, BackgroundSyncService backgroundSyncService, IProjectIdentifier project) : IDisposable diff --git a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs new file mode 100644 index 000000000..9a43b22f2 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs @@ -0,0 +1,125 @@ +using FwLiteShared.Projects; +using LcmCrdt; +using LexCore.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; +using MiniLcm.Models; +using MiniLcm.Project; + +namespace FwLiteShared.Services; + +//this service is special, it is scoped, but it should not inject any scoped project services +public class ProjectServicesProvider( + CrdtProjectsService crdtProjectsService, + IServiceProvider scopedServices, + LexboxProjectService lexboxProjectService, + ChangeEventBus changeEventBus, + IEnumerable projectProviders, + IJSRuntime jsRuntime, + ILogger logger +) : IAsyncDisposable +{ + private IProjectProvider? FwDataProjectProvider => + projectProviders.FirstOrDefault(p => p.DataFormat == ProjectDataFormat.FwData); + private readonly ConcurrentWeakDictionary _projectScopes = new(); + //handles cleanup of project scopes which didn't get cleaned up by the js code, maybe because the user closed the tab + //this will get executed when the blazor circuit is disposed + public async ValueTask DisposeAsync() + { + foreach (var scope in _projectScopes.Values) + { + await (scope.Cleanup?.Value.DisposeAsync() ?? ValueTask.CompletedTask); + } + } + + [JSInvokable] + public async Task DisposeService(DotNetObjectReference service) + { + await service.Value.DisposeAsync(); + } + + [JSInvokable] + public async Task OpenCrdtProject(string projectName) + { + var project = crdtProjectsService.GetProject(projectName) ?? + throw new InvalidOperationException($"Crdt Project {projectName} not found"); + var currentProjectService = scopedServices.GetRequiredService(); + var projectData = await currentProjectService.SetupProjectContext(project); + await lexboxProjectService.ListenForProjectChanges(projectData, CancellationToken.None); + var entryUpdatedSubscription = changeEventBus.OnProjectEntryUpdated(project).Subscribe(entry => + { + _ = jsRuntime.DurableInvokeVoidAsync("notifyEntryUpdated", projectName, entry); + }); + var miniLcm = ActivatorUtilities.CreateInstance(scopedServices, project); + var historyService = scopedServices.GetRequiredService(); + var scope = new ProjectScope(Defer.Async(() => + { + logger.LogInformation("Disposing project scope {ProjectName}", projectName); + currentProjectService.ClearProjectContext(); + entryUpdatedSubscription.Dispose(); + _projectScopes.Remove(projectName); + return Task.CompletedTask; + }), projectName, miniLcm, new HistoryServiceJsInvokable(historyService)); + await TrackScope(scope); + return scope; + } + + [JSInvokable] + public async Task OpenFwDataProject(string projectName) + { + if (FwDataProjectProvider is null) + throw new InvalidOperationException("FwData Project provider is not available"); + var project = FwDataProjectProvider.GetProject(projectName) ?? + throw new InvalidOperationException($"FwData Project {projectName} not found"); + var miniLcm = ActivatorUtilities.CreateInstance(scopedServices, + FwDataProjectProvider.OpenProject(project), + project); + var scope = new ProjectScope(Defer.Async(() => + { + logger.LogInformation("Disposing fwdata project scope {ProjectName}", projectName); + _projectScopes.Remove(projectName); + return Task.CompletedTask; + }), projectName, miniLcm, null); + await TrackScope(scope); + return scope; + } + + private async ValueTask TrackScope(ProjectScope scope) + { + var oldScope = _projectScopes.Remove(scope.ProjectName); + _projectScopes.Add(scope.ProjectName, scope); + await (oldScope?.Cleanup?.Value.DisposeAsync() ?? ValueTask.CompletedTask); + } +} + +public class ProjectScope +{ + public ProjectScope(IAsyncDisposable cleanup, + string projectName, + MiniLcmJsInvokable miniLcm, + HistoryServiceJsInvokable? historyService) + { + ProjectName = projectName; + MiniLcm = DotNetObjectReference.Create(miniLcm); + HistoryService = historyService is null ? null : DotNetObjectReference.Create(historyService); + Cleanup = DotNetObjectReference.Create(Defer.Async(async () => + { + await cleanup.DisposeAsync(); + if (HistoryService is not null) + { + HistoryService.Dispose(); + } + + MiniLcm.Value.Dispose(); + MiniLcm.Dispose(); + Cleanup?.Dispose(); + Cleanup = null; + })); + } + + public DotNetObjectReference? Cleanup { get; set; } + public string ProjectName { get; set; } + public DotNetObjectReference MiniLcm { get; set; } + public DotNetObjectReference? HistoryService { get; set; } +} diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index dc442429d..a58f45c90 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -12,6 +12,7 @@ using Reinforced.Typings.Ast.TypeNames; using Reinforced.Typings.Fluent; using Reinforced.Typings.Visitors.TypeScript; +using SIL.Harmony.Db; namespace FwLiteShared.TypeGen; @@ -22,7 +23,7 @@ public static void Configure(ConfigurationBuilder builder) { builder.Global(c => c.AutoAsync() .UseModules() - .UnresolvedToUnknown() + .UnresolvedToUnknown(true) .CamelCaseForProperties() .CamelCaseForMethods() .AutoOptionalProperties() @@ -39,6 +40,7 @@ public static void Configure(ConfigurationBuilder builder) exportBuilder => exportBuilder.WithName("DotNet.DotNetObject").Imports([ new() { From = "@microsoft/dotnet-js-interop", Target = "type {DotNet}" } ])); + builder.ExportAsInterface(); ConfigureMiniLcmTypes(builder); ConfigureFwLiteSharedTypes(builder); @@ -62,6 +64,7 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) typeof(ComplexFormType), typeof(ComplexFormComponent), typeof(MiniLcmJsInvokable.MiniLcmFeatures), + typeof(IObjectWithId) ], exportBuilder => exportBuilder.WithPublicNonStaticProperties(exportBuilder => { @@ -90,7 +93,9 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(AuthService), typeof(ImportFwdataService), typeof(CombinedProjectsService), - typeof(MiniLcmApiProvider) + typeof(HistoryServiceJsInvokable), + typeof(ProjectServicesProvider), + typeof(IAppLauncher) ], exportBuilder => exportBuilder.WithPublicMethods(b => b.AlwaysReturnPromise().OnlyJsInvokable())); builder.ExportAsInterfaces([ @@ -101,7 +106,11 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(CrdtProject), typeof(ProjectData), typeof(IProjectIdentifier), - typeof(FwLiteConfig) + typeof(FwLiteConfig), + typeof(HistoryLineItem), + typeof(ProjectActivity), + typeof(ObjectSnapshot), + typeof(ProjectScope) ], exportBuilder => exportBuilder.WithPublicProperties()); } diff --git a/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs index c73a56ee6..f60ad37d1 100644 --- a/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs @@ -1,4 +1,5 @@ using FwDataMiniLcmBridge; +using FwDataMiniLcmBridge.LcmUtils; using FwLiteWeb.Hubs; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; @@ -29,7 +30,7 @@ public static IEndpointConventionBuilder MapFwIntegrationRoutes(this WebApplicat await hubContext.Clients.Group(context.Project.Name).OnProjectClosed(CloseReason.Locked); factory.CloseProject(context.Project); //need to use redirect as a way to not trigger flex until after we have closed the project - return Results.Redirect($"silfw://localhost/link?database={context.Project.Name}&tool=lexiconEdit&guid={id}"); + return Results.Redirect(FwLink.ToEntry(id, context.Project.Name)); }); return group; } diff --git a/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs index 164ddc73a..e6c263411 100644 --- a/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs @@ -26,9 +26,9 @@ public static IEndpointConventionBuilder MapHistoryRoutes(this WebApplication ap }); group.MapGet("/snapshot/{snapshotId:guid}", async (Guid snapshotId, HistoryService historyService) => await historyService.GetSnapshot(snapshotId)); - group.MapGet("/snapshot/at/{timestamp}", - async (DateTime timestamp, Guid entityId, HistoryService historyService) => - await historyService.GetObject(timestamp, entityId)); + group.MapGet("/snapshot/commit/{commitId}", + async (Guid commitId, Guid entityId, HistoryService historyService) => + await historyService.GetObject(commitId, entityId)); group.MapGet("/{entityId}", (Guid entityId, HistoryService historyService) => historyService.GetHistory(entityId)); return group; diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index c13ed435f..7620c9f5f 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -61,6 +61,12 @@ public void SetupProjectContextForNewDb(CrdtProject project) { _project = project; } + + public void ClearProjectContext() + { + _project = null; + } + public async ValueTask SetupProjectContext(CrdtProject project) { if (_project != null && project != _project) throw new InvalidOperationException("Can't setup project context for a different project"); diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index e70e6c412..1546b593d 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -76,6 +76,11 @@ public IAsyncEnumerable ProjectActivity() return await dbContext.Snapshots.SingleOrDefaultAsync(s => s.Id == snapshotId); } + public async Task GetObject(Guid commitId, Guid entityId) + { + return await dataModel.GetAtCommit(commitId, entityId); + } + public async Task GetObject(DateTime timestamp, Guid entityId) { //todo requires the timestamp to be exact, otherwise the change made on that timestamp will not be included diff --git a/backend/LexCore/Utils/ConcurrentWeakDictionary.cs b/backend/LexCore/Utils/ConcurrentWeakDictionary.cs index 748f5d3db..e090f2b25 100644 --- a/backend/LexCore/Utils/ConcurrentWeakDictionary.cs +++ b/backend/LexCore/Utils/ConcurrentWeakDictionary.cs @@ -15,6 +15,9 @@ public class ConcurrentWeakDictionary { private readonly ConcurrentDictionary> _lookup = new(); + public IEnumerable Values => _lookup.Keys.ToArray() + .Select(key => TryGetValue(key, out var value) ? value : null) + .OfType(); public void Add(TKey key, TValue value) { var added = _lookup.TryAdd(key, new WeakReference(value)); @@ -31,6 +34,15 @@ public TValue GetOrAdd(TKey key, Func valueFactory) return GetOrAdd(key, valueFactory); } + public TValue? Remove(TKey key) + { + _lookup.TryRemove(key, out var weakReference); + TValue? target = null; + weakReference?.TryGetTarget(out target); + Cull(); + return target; + } + public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) { value = default!; diff --git a/frontend/viewer/src/App.svelte b/frontend/viewer/src/App.svelte index f0aefe038..ffedc2754 100644 --- a/frontend/viewer/src/App.svelte +++ b/frontend/viewer/src/App.svelte @@ -66,14 +66,14 @@ {#key params.name} - + {/key} {#key params.name} - + {/key} diff --git a/frontend/viewer/src/DotnetProjectView.svelte b/frontend/viewer/src/DotnetProjectView.svelte index c0a488f30..06f2a91e6 100644 --- a/frontend/viewer/src/DotnetProjectView.svelte +++ b/frontend/viewer/src/DotnetProjectView.svelte @@ -2,22 +2,37 @@ import ProjectView from './ProjectView.svelte'; import {onDestroy, onMount} from 'svelte'; import {DotnetService, type IMiniLcmJsInvokable} from '$lib/dotnet-types'; - import {useMiniLcmApiProvider} from '$lib/services/service-provider'; + import {useProjectServicesProvider} from '$lib/services/service-provider'; import {wrapInProxy} from '$lib/services/service-provider-dotnet'; + import type {IProjectScope} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectScope'; + import type { + IHistoryServiceJsInvokable + } from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable'; - const miniLcmApiProvider = useMiniLcmApiProvider(); - + const projectServicesProvider = useProjectServicesProvider(); export let projectName: string; + export let type: 'fwdata' | 'crdt'; + let projectScope: IProjectScope; let serviceLoaded = false; onMount(async () => { - const miniLcmApi = await miniLcmApiProvider.getMiniLcmApi(); - window.lexbox.ServiceProvider.setService(DotnetService.MiniLcmApi, wrapInProxy(miniLcmApi) as IMiniLcmJsInvokable); + console.debug('ProjectView mounted'); + if (type === 'crdt') { + projectScope = await projectServicesProvider.openCrdtProject(projectName); + } else { + projectScope = await projectServicesProvider.openFwDataProject(projectName); + } + //todo also history service + if (projectScope.historyService) { + window.lexbox.ServiceProvider.setService(DotnetService.HistoryService, wrapInProxy(projectScope.historyService) as IHistoryServiceJsInvokable); + } + window.lexbox.ServiceProvider.setService(DotnetService.MiniLcmApi, wrapInProxy(projectScope.miniLcm) as IMiniLcmJsInvokable); serviceLoaded = true; }); onDestroy(() => { if (serviceLoaded) { window.lexbox.ServiceProvider.removeService(DotnetService.MiniLcmApi); - void miniLcmApiProvider.clearMiniLcmApi(); + if (projectScope.cleanup) + void projectServicesProvider.disposeService(projectScope.cleanup); } }); diff --git a/frontend/viewer/src/HomeView.svelte b/frontend/viewer/src/HomeView.svelte index 3f2c4f9ec..7f0f69a72 100644 --- a/frontend/viewer/src/HomeView.svelte +++ b/frontend/viewer/src/HomeView.svelte @@ -177,7 +177,7 @@ + subheading={!server ? 'Local only' : ('Synced with ' + server.displayName)}>
@@ -237,17 +237,30 @@ {:else} {#each serverProjects as project} {@const localProject = matchesProject(projects, project)} - { if (!localProject?.crdt) {void downloadCrdtProject(project, server);} }} - loading={downloading === project.name}> -
- -
-
+ {#if localProject?.crdt} + + +
+ +
+
+
+ {:else} + void downloadCrdtProject(project, server)} + loading={downloading === project.name}> +
+ +
+
+ {/if} {/each} {/if} diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index 4fc3e5479..99f706aca 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -41,6 +41,7 @@ import {SortField} from '$lib/dotnet-types/generated-types/MiniLcm/SortField'; import DeleteDialog from '$lib/entry-editor/DeleteDialog.svelte'; import {initDialogService} from '$lib/entry-editor/dialog-service'; + import OpenInFieldWorksButton from '$lib/OpenInFieldWorksButton.svelte'; export let loading = false; export let about: string | undefined = undefined; @@ -272,12 +273,7 @@ }; }); - function openInFlex() { - AppNotification.displayAction('The project is open in FieldWorks. Please close it to reopen.', 'warning', { - label: 'Open', - callback: () => window.location.reload() - }); - } + let newEntryDialog: NewEntryDialog; async function openNewEntryDialog(text: string, options?: NewEntryDialogOptions): Promise { @@ -413,20 +409,10 @@ {/if} - {#if $features.openWithFlex && $selectedEntry} + {#if $selectedEntry}
- +
{/if}
diff --git a/frontend/viewer/src/lib/OpenInFieldWorksButton.svelte b/frontend/viewer/src/lib/OpenInFieldWorksButton.svelte new file mode 100644 index 000000000..2158054b7 --- /dev/null +++ b/frontend/viewer/src/lib/OpenInFieldWorksButton.svelte @@ -0,0 +1,37 @@ + + 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 0f7d83a62..2affa253b 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 @@ -5,10 +5,12 @@ export enum DotnetService { MiniLcmApi = "MiniLcmApi", - MiniLcmApiProvider = "MiniLcmApiProvider", CombinedProjectsService = "CombinedProjectsService", AuthService = "AuthService", ImportFwdataService = "ImportFwdataService", - FwLiteConfig = "FwLiteConfig" + FwLiteConfig = "FwLiteConfig", + ProjectServicesProvider = "ProjectServicesProvider", + HistoryService = "HistoryService", + AppLauncher = "AppLauncher" } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IAppLauncher.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IAppLauncher.ts new file mode 100644 index 000000000..aab4c33dc --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IAppLauncher.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 IAppLauncher +{ + canOpen(uri: string) : Promise; + open(uri: string) : Promise; + tryOpen(uri: string) : Promise; + openInFieldWorks(entryId: string, projectName: string) : Promise; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.ts new file mode 100644 index 000000000..1702669a5 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.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 {IObjectWithId} from '../../MiniLcm/Models/IObjectWithId'; +import type {IProjectActivity} from '../../LcmCrdt/IProjectActivity'; +import type {IObjectSnapshot} from '../../SIL/Harmony/Db/IObjectSnapshot'; +import type {IHistoryLineItem} from '../../LcmCrdt/IHistoryLineItem'; + +export interface IHistoryServiceJsInvokable +{ + getObject(commitId: string, entityId: string) : Promise; + projectActivity() : Promise; + getSnapshot(snapshotId: string) : Promise; + getHistory(entityId: string) : Promise; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmApiProvider.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectScope.ts similarity index 63% rename from frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmApiProvider.ts rename to frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectScope.ts index 191ce0bf8..d781343ef 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmApiProvider.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectScope.ts @@ -5,9 +5,11 @@ import type {DotNet} from '@microsoft/dotnet-js-interop'; -export interface IMiniLcmApiProvider +export interface IProjectScope { - getMiniLcmApi() : Promise; - clearMiniLcmApi() : Promise; + cleanup?: DotNet.DotNetObject; + projectName: string; + miniLcm: DotNet.DotNetObject; + historyService?: DotNet.DotNetObject; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectServicesProvider.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectServicesProvider.ts new file mode 100644 index 000000000..d6bd2184f --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectServicesProvider.ts @@ -0,0 +1,16 @@ +/* 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 {IAsyncDisposable} from '../../System/IAsyncDisposable'; +import type {DotNet} from '@microsoft/dotnet-js-interop'; +import type {IProjectScope} from './IProjectScope'; + +export interface IProjectServicesProvider extends IAsyncDisposable +{ + disposeService(service: DotNet.DotNetObject) : Promise; + openCrdtProject(projectName: string) : Promise; + openFwDataProject(projectName: string) : Promise; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IHistoryLineItem.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IHistoryLineItem.ts new file mode 100644 index 000000000..e69daf852 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IHistoryLineItem.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 {IObjectWithId} from '../MiniLcm/Models/IObjectWithId'; + +export interface IHistoryLineItem +{ + commitId: string; + entityId: string; + timestamp: string; + snapshotId?: string; + changeName?: string; + entity?: IObjectWithId; + entityName?: string; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts new file mode 100644 index 000000000..4faf67729 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.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 IProjectActivity +{ + commitId: string; + timestamp: string; + changes: unknown[]; + changeName: string; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IComplexFormComponent.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IComplexFormComponent.ts index 3b468c07f..46f41018e 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IComplexFormComponent.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IComplexFormComponent.ts @@ -3,7 +3,9 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. -export interface IComplexFormComponent +import type {IObjectWithId} from './IObjectWithId'; + +export interface IComplexFormComponent extends IObjectWithId { id: string; deletedAt?: string; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IComplexFormType.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IComplexFormType.ts index 635735051..119a03711 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IComplexFormType.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IComplexFormType.ts @@ -3,9 +3,10 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {IObjectWithId} from './IObjectWithId'; import type {IMultiString} from '$lib/dotnet-types/i-multi-string'; -export interface IComplexFormType +export interface IComplexFormType extends IObjectWithId { id: string; name: IMultiString; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IEntry.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IEntry.ts index dee8a0381..5fd3f51b9 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IEntry.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IEntry.ts @@ -3,12 +3,13 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {IObjectWithId} from './IObjectWithId'; import type {IMultiString} from '$lib/dotnet-types/i-multi-string'; import type {ISense} from './ISense'; import type {IComplexFormComponent} from './IComplexFormComponent'; import type {IComplexFormType} from './IComplexFormType'; -export interface IEntry +export interface IEntry extends IObjectWithId { id: string; deletedAt?: string; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IExampleSentence.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IExampleSentence.ts index e31d457b5..ef28c797d 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IExampleSentence.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IExampleSentence.ts @@ -3,9 +3,10 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {IObjectWithId} from './IObjectWithId'; import type {IMultiString} from '$lib/dotnet-types/i-multi-string'; -export interface IExampleSentence +export interface IExampleSentence extends IObjectWithId { id: string; sentence: IMultiString; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IObjectWithId.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IObjectWithId.ts new file mode 100644 index 000000000..61f15e988 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IObjectWithId.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 IObjectWithId +{ + id: string; + deletedAt?: string; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPartOfSpeech.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPartOfSpeech.ts index 257f1969d..67edfdfb4 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPartOfSpeech.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPartOfSpeech.ts @@ -3,9 +3,10 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {IObjectWithId} from './IObjectWithId'; import type {IMultiString} from '$lib/dotnet-types/i-multi-string'; -export interface IPartOfSpeech +export interface IPartOfSpeech extends IObjectWithId { id: string; name: IMultiString; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/ISemanticDomain.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/ISemanticDomain.ts index f96c70571..502822071 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/ISemanticDomain.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/ISemanticDomain.ts @@ -3,9 +3,10 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {IObjectWithId} from './IObjectWithId'; import type {IMultiString} from '$lib/dotnet-types/i-multi-string'; -export interface ISemanticDomain +export interface ISemanticDomain extends IObjectWithId { id: string; name: IMultiString; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/ISense.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/ISense.ts index 2ad6cf9df..58d6e08ee 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/ISense.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/ISense.ts @@ -3,12 +3,13 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {IObjectWithId} from './IObjectWithId'; import type {IMultiString} from '$lib/dotnet-types/i-multi-string'; import type {IPartOfSpeech} from './IPartOfSpeech'; import type {ISemanticDomain} from './ISemanticDomain'; import type {IExampleSentence} from './IExampleSentence'; -export interface ISense +export interface ISense extends IObjectWithId { id: string; deletedAt?: string; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IWritingSystem.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IWritingSystem.ts index af6182eff..16573b226 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IWritingSystem.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IWritingSystem.ts @@ -3,9 +3,10 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {IObjectWithId} from './IObjectWithId'; import type {WritingSystemType} from './WritingSystemType'; -export interface IWritingSystem +export interface IWritingSystem extends IObjectWithId { id: string; wsId: string; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Db/IObjectSnapshot.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Db/IObjectSnapshot.ts new file mode 100644 index 000000000..0ef85cd61 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/SIL/Harmony/Db/IObjectSnapshot.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. + +export interface IObjectSnapshot +{ + id: string; + typeName: string; + entity: unknown; + references: string[]; + entityId: string; + entityIsDeleted: boolean; + commitId: string; + commit: unknown; + isRoot: boolean; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/System/IAsyncDisposable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/System/IAsyncDisposable.ts new file mode 100644 index 000000000..2855634c4 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/System/IAsyncDisposable.ts @@ -0,0 +1,9 @@ +/* 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 IAsyncDisposable +{ +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte index 1331c9b00..4ab32c43e 100644 --- a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte @@ -7,6 +7,8 @@ import { defaultEntry } from '../utils'; import { createEventDispatcher, getContext } from 'svelte'; import type { SaveHandler } from '../services/save-event-service'; + import {useCurrentView} from '$lib/services/view-service'; + import {fieldName} from '$lib/i18n'; const dispatch = createEventDispatcher<{ created: { entry: IEntry }; @@ -15,6 +17,7 @@ let loading = false; let entry: IEntry = defaultEntry(); + const currentView = useCurrentView(); const lexboxApi = useLexboxApi(); const saveHandler = getContext('saveHandler'); let requester: { @@ -59,18 +62,18 @@ -
New Entry
+
New {fieldName({id: 'entry'}, $currentView.i18nKey)}
- +
diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte index 531ba11b9..ed9c9c97c 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte @@ -17,6 +17,7 @@ import ComplexForms from '../field-editors/ComplexForms.svelte'; import ComplexFormTypes from '../field-editors/ComplexFormTypes.svelte'; import {useDialogService} from '$lib/entry-editor/dialog-service'; + import {fieldName} from '$lib/i18n'; const dialogService = useDialogService(); const dispatch = createEventDispatcher<{ change: { entry: IEntry, sense?: ISense, example?: IExampleSentence}; @@ -24,11 +25,15 @@ }>(); export let entry: IEntry; + //used to not try to delete an object which has not been created yet + let newSenses: ISense[] = []; + let newExamples: IExampleSentence[] = []; function addSense() { const sense = defaultSense(entry.id); highlightedEntity = sense; entry.senses = [...entry.senses, sense]; + newSenses = [...newSenses, sense]; } function addExample(sense: ISense) { @@ -36,6 +41,7 @@ highlightedEntity = sentence; sense.exampleSentences = [...sense.exampleSentences, sentence]; entry = entry; // examples counts are not updated without this + newExamples = [...newExamples, sentence]; } async function deleteEntry() { if (!await dialogService.promptDelete('Entry')) return; @@ -43,29 +49,50 @@ } async function deleteSense(sense: ISense) { + if (newSenses.some(s => s.id === sense.id)) { + newSenses = newSenses.filter(s => s.id !== sense.id); + entry.senses = entry.senses.filter(s => s.id !== sense.id); + return; + } if (!await dialogService.promptDelete('Sense')) return; - entry.senses = entry.senses.filter(s => s !== sense); + entry.senses = entry.senses.filter(s => s.id !== sense.id); dispatch('delete', {entry, sense}); } function moveSense(sense: ISense, i: number) { entry.senses.splice(entry.senses.indexOf(sense), 1); entry.senses.splice(i, 0, sense); - dispatch('change', {entry, sense}); + onSenseChange(sense); highlightedEntity = sense; } + async function deleteExample(sense: ISense, example: IExampleSentence) { + if (newExamples.some(e => e.id === example.id)) { + newExamples = newExamples.filter(e => e.id !== example.id); + sense.exampleSentences = sense.exampleSentences.filter(e => e.id !== example.id); + entry = entry; // examples are not updated without this + return; + } if (!await dialogService.promptDelete('Example sentence')) return; - sense.exampleSentences = sense.exampleSentences.filter(e => e !== example); + sense.exampleSentences = sense.exampleSentences.filter(e => e.id !== example.id); dispatch('delete', {entry, sense, example}); entry = entry; // examples are not updated without this } function moveExample(sense: ISense, example: IExampleSentence, i: number) { sense.exampleSentences.splice(sense.exampleSentences.indexOf(example), 1); sense.exampleSentences.splice(i, 0, example); - dispatch('change', {entry, sense, example}); + onExampleChange(sense, example); highlightedEntity = example; entry = entry; // examples are not updated without this } + + function onSenseChange(sense: ISense) { + newSenses = newSenses.filter(s => s.id !== sense.id); + dispatch('change', {entry, sense}); + } + function onExampleChange(sense: ISense, example: IExampleSentence) { + newExamples = newExamples.filter(e => e.id !== example.id); + dispatch('change', {entry, sense, example}); + } export let modalMode = false; export let readonly = false; @@ -163,7 +190,7 @@
-

Sense {i + 1}

+

{fieldName({id: 'sense'}, $currentView.i18nKey)} {i + 1}


{#if !readonly} - dispatch('change', {entry, sense})}/> + onSenseChange(sense)}/>
{#each sense.exampleSentences as example, j (example.id)} @@ -186,19 +213,19 @@ -->
{#if !readonly} - moveExample(sense, example, e.detail)} - on:delete={() => deleteExample(sense, example)} - id={example.id} - /> + moveExample(sense, example, e.detail)} + on:delete={() => deleteExample(sense, example)} + id={example.id} + /> {/if}
dispatch('change', {entry, sense, example})} + on:change={() => onExampleChange(sense, example)} />
{/each} @@ -213,7 +240,7 @@ {#if !readonly}
- +
{/if}
@@ -223,12 +250,12 @@
{#if $features.history} diff --git a/frontend/viewer/src/lib/i18n.ts b/frontend/viewer/src/lib/i18n.ts index 4a891edfe..f723fe191 100644 --- a/frontend/viewer/src/lib/i18n.ts +++ b/frontend/viewer/src/lib/i18n.ts @@ -25,6 +25,9 @@ const defaultI18n: Record = { 'translation': 'Translation', 'reference': 'Reference', 'test': 'Test', + 'sense': 'Sense', + 'entry': 'Entry', + 'entries': 'Entries' }; const weSayI18n = { @@ -33,6 +36,9 @@ const weSayI18n = { 'partOfSpeechId': 'Part of speech', complexForms: 'Part of', components: 'Made of', + 'sense': 'Definition', + 'entry': 'Word', + 'entries': 'Words' } satisfies Partial>; const languageForgeI18n = { @@ -40,6 +46,9 @@ const languageForgeI18n = { 'partOfSpeechId': 'Part of speech', complexForms: 'Part of', components: 'Made of', + 'sense': 'Definition', + 'entry': 'Word', + 'entries': 'Words' } satisfies Partial>; const i18nMap: Record, Partial>> = { diff --git a/frontend/viewer/src/lib/layout/EntryList.svelte b/frontend/viewer/src/lib/layout/EntryList.svelte index d61d8256f..18abe45d5 100644 --- a/frontend/viewer/src/lib/layout/EntryList.svelte +++ b/frontend/viewer/src/lib/layout/EntryList.svelte @@ -7,6 +7,8 @@ import type { Writable } from 'svelte/store'; import { createEventDispatcher, getContext } from 'svelte'; import DictionaryEntry from '../DictionaryEntry.svelte'; + import {useCurrentView} from '$lib/services/view-service'; + import {fieldName} from '$lib/i18n'; const dispatch = createEventDispatcher<{ entrySelected: IEntry; @@ -52,6 +54,7 @@ let dictionaryMode = false; const selectedCharacter = getContext>('selectedIndexExamplar'); + const currentView = useCurrentView();
@@ -60,7 +63,7 @@
diff --git a/frontend/viewer/src/lib/layout/Toc.svelte b/frontend/viewer/src/lib/layout/Toc.svelte index 3f9c3c916..9a0f43a23 100644 --- a/frontend/viewer/src/lib/layout/Toc.svelte +++ b/frontend/viewer/src/lib/layout/Toc.svelte @@ -1,20 +1,23 @@ {#if entry} {@const _headword = headword(entry) ?? ''}
- Entry:{_headword || '—'} + {fieldName({id: 'entry'}, $currentView.i18nKey)}:{_headword || '—'} {#each entry.senses as sense, i (sense.id)} {@const _sense = firstDefOrGlossVal(sense) ?? ''} - Sense:{_sense || '—'} + {fieldName({id: 'sense'}, $currentView.i18nKey)}:{_sense || '—'} {#each sense.exampleSentences as example, j (example.id)} {@const _example = firstSentenceOrTranslationVal(example) ?? ''} - Example:{_example || '—'} + Example:{_example || '—'} {/each} {/each}
diff --git a/frontend/viewer/src/lib/layout/ViewOptionsDrawer.svelte b/frontend/viewer/src/lib/layout/ViewOptionsDrawer.svelte index 9883967bb..00d75375b 100644 --- a/frontend/viewer/src/lib/layout/ViewOptionsDrawer.svelte +++ b/frontend/viewer/src/lib/layout/ViewOptionsDrawer.svelte @@ -1,10 +1,11 @@