diff --git a/.gitignore b/.gitignore index 2dc27a053..d122f98c6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,6 @@ dump.sql test-results/ **/*.sqlite **/*.sqlite-* +msal.json msal.cache artifacts/ diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs index 419972130..cc951c27d 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs @@ -1,4 +1,5 @@ using FwDataMiniLcmBridge.LcmUtils; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace FwDataMiniLcmBridge.Tests.Fixtures; @@ -8,6 +9,7 @@ public static class FwDataTestsKernel public static IServiceCollection AddTestFwDataBridge(this IServiceCollection services) { services.AddFwDataBridge(); + services.AddSingleton(_ => new ConfigurationRoot([])); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj b/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj index 5f06ced72..e5ee57ffb 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj @@ -18,6 +18,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 7301442fd..201af20fc 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -123,7 +123,7 @@ private WritingSystem FromLcmWritingSystem(CoreWritingSystemDefinition ws) internal void CompleteExemplars(WritingSystems writingSystems) { var wsExemplars = writingSystems.Vernacular.Concat(writingSystems.Analysis) - .Distinct() + .DistinctBy(ws => ws.Id) .ToDictionary(ws => ws, ws => ws.Exemplars.Select(s => s[0]).ToHashSet()); var wsExemplarsByHandle = wsExemplars.ToFrozenDictionary(kv => GetWritingSystemHandle(kv.Key.Id), kv => kv.Value); diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs b/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs index 9fdebebf5..0e20c2cf4 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs @@ -11,7 +11,7 @@ public static IServiceCollection AddFwDataBridge(this IServiceCollection service { services.AddMemoryCache(); services.AddLogging(); - services.AddOptions(); + services.AddOptions().BindConfiguration("FwDataBridge"); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj index 606eb038f..43a840e1c 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj @@ -11,6 +11,7 @@ + diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj b/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj index fecb71fee..2c5b24013 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj @@ -65,7 +65,7 @@ - + diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs index 921c28a32..bcb4ab704 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs @@ -2,6 +2,7 @@ using LcmCrdt; using LocalWebApp.Auth; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NReco.Logging.File; @@ -15,7 +16,11 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services, { services.AddSingleton(); - var serverManager = new ServerManager(webAppBuilder => + string environment = "Production"; +#if DEBUG + environment = "Development"; +#endif + var serverManager = new ServerManager(environment, webAppBuilder => { webAppBuilder.Logging.AddFile(Path.Combine(FileSystem.AppDataDirectory, "web-app.log")); webAppBuilder.Services.Configure(config => @@ -31,9 +36,11 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services, //using a lambda here means that the serverManager will be disposed when the app is disposed services.AddSingleton(_ => serverManager); services.AddSingleton(_ => _.GetRequiredService()); + services.AddSingleton(_ => _.GetRequiredService().WebServices.GetRequiredService()); configuration.Add(source => source.ServerManager = serverManager); services.AddOptions().BindConfiguration("LocalWebApp"); logging.AddFile(Path.Combine(FileSystem.AppDataDirectory, "app.log")); + logging.AddConsole(); #if DEBUG logging.AddDebug(); #endif diff --git a/backend/FwLite/FwLiteDesktop/MainPage.xaml b/backend/FwLite/FwLiteDesktop/MainPage.xaml index dca7fed91..37d1dd4d0 100644 --- a/backend/FwLite/FwLiteDesktop/MainPage.xaml +++ b/backend/FwLite/FwLiteDesktop/MainPage.xaml @@ -3,6 +3,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="FwLiteDesktop.MainPage"> - + diff --git a/backend/FwLite/FwLiteDesktop/MainPage.xaml.cs b/backend/FwLite/FwLiteDesktop/MainPage.xaml.cs index 317a41583..c4e45f04d 100644 --- a/backend/FwLite/FwLiteDesktop/MainPage.xaml.cs +++ b/backend/FwLite/FwLiteDesktop/MainPage.xaml.cs @@ -1,12 +1,18 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace FwLiteDesktop; public partial class MainPage : ContentPage { - public MainPage(IOptionsMonitor options, ILogger logger) + private readonly ILogger _logger; + private readonly IHostEnvironment _environment; + + public MainPage(IOptionsMonitor options, ILogger logger, IHostEnvironment environment) { + _logger = logger; + _environment = environment; InitializeComponent(); options.OnChange(o => { @@ -39,7 +45,24 @@ public MainPage(IOptionsMonitor options, ILogger lo logger.LogWarning("Too many navigations, stopping"); } } + else if (!args.Url.StartsWith("https://appdir")) + { + NavigationSuccess(); + } }; } -} + private void NavigationSuccess() + { + webView.IsVisible = true; + if (_environment.IsDevelopment()) + { + _logger.LogInformation("Enabling dev mode in browser"); + //lang=js + webView.Eval(""" + localStorage.setItem('devMode', 'true'); + if (enableDevMode) enableDevMode(); + """); + } + } +} diff --git a/backend/FwLite/FwLiteDesktop/Resources/Splash/splash.svg b/backend/FwLite/FwLiteDesktop/Resources/Splash/splash.svg index 21dfb25f1..fb45d5837 100644 --- a/backend/FwLite/FwLiteDesktop/Resources/Splash/splash.svg +++ b/backend/FwLite/FwLiteDesktop/Resources/Splash/splash.svg @@ -1,8 +1,135 @@ - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/FwLite/FwLiteDesktop/ServerBridge/ServerManager.cs b/backend/FwLite/FwLiteDesktop/ServerBridge/ServerManager.cs index 3893bf0e0..4cfb2ccd3 100644 --- a/backend/FwLite/FwLiteDesktop/ServerBridge/ServerManager.cs +++ b/backend/FwLite/FwLiteDesktop/ServerBridge/ServerManager.cs @@ -6,7 +6,7 @@ namespace FwLiteDesktop.ServerBridge; -public class ServerManager(Action? configure = null) : IMauiInitializeService +public class ServerManager(string environment, Action? configure = null) : IMauiInitializeService { private readonly TaskCompletionSource _started = new(); public Task Started => _started.Task; @@ -14,6 +14,7 @@ public class ServerManager(Action? configure = null) : IM private ILogger? _logger; private Thread? _thread; private readonly ManualResetEvent _stop = new(false); + public IServiceProvider WebServices => _webApp?.Services ?? throw new ApplicationException("initlize not yet called"); public void Initialize(IServiceProvider services) { @@ -23,7 +24,7 @@ public void Initialize(IServiceProvider services) private void Start(ILogger logger) { _logger = logger; - _webApp = LocalWebAppServer.SetupAppServer([], configure); + _webApp = LocalWebAppServer.SetupAppServer(new WebApplicationOptions(){EnvironmentName = environment}, configure); _thread = new Thread(() => { _ = Task.Run(async () => diff --git a/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs b/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs new file mode 100644 index 000000000..6429701f7 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs @@ -0,0 +1,65 @@ +using LcmCrdt.Data; +using MiniLcm.Models; +using Entry = LcmCrdt.Objects.Entry; + +namespace LcmCrdt.Tests.Data; + +public class FilteringTests +{ + private readonly List _entries; + + public FilteringTests() + { + _entries = + [ + new Entry { LexemeForm = { { "en", "123" } }, }, + new Entry { LexemeForm = { { "en", "456" } }, } + ]; + } + + [Theory] + [InlineData("1")] + [InlineData("9")] + [InlineData("4")] + public void WhereExemplar_CompiledFilter_ShouldReturnSameResults(string exemplar) + { + WritingSystemId ws = "en"; + + var expected = _entries.AsQueryable().WhereExemplar(ws, exemplar).ToArray(); + var actual = _entries.Where(Filtering.CompiledFilter(null, ws, exemplar)).ToArray(); + + actual.Should().BeEquivalentTo(expected); + } + + [Theory] + [InlineData("2")] + [InlineData("5")] + [InlineData("9")] + public void SearchFilter_CompiledFilter_ShouldReturnSameResults(string query) + { + var expected = _entries.AsQueryable().Where(Filtering.SearchFilter(query)).ToList(); + + var actual = _entries.Where(Filtering.CompiledFilter(query, "en", null)).ToList(); + + actual.Should().BeEquivalentTo(expected); + } + + [Theory] + [InlineData("1", "12")] + [InlineData("9", "9")] + [InlineData("4", "45")] + [InlineData("1", "45")] + public void CombinedFilter_CompiledFilter_ShouldReturnSameResults(string exemplar, string query) + { + WritingSystemId ws = "en"; + + var expected = _entries.AsQueryable() + .WhereExemplar(ws, exemplar) + .Where(Filtering.SearchFilter(query)) + .ToList(); + + var actual = _entries.Where(Filtering.CompiledFilter(query, ws, exemplar)).ToList(); + + actual.Should().BeEquivalentTo(expected); + } +} diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index a74fd2d46..ac9fa69b7 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -4,6 +4,7 @@ using SIL.Harmony; using SIL.Harmony.Changes; using LcmCrdt.Changes; +using LcmCrdt.Data; using MiniLcm; using LinqToDB; using LinqToDB.EntityFrameworkCore; @@ -103,11 +104,7 @@ public async Task BulkImportSemanticDomains(IEnumerable e.LexemeForm.SearchValue(query) - || e.CitationForm.SearchValue(query) - || e.Senses.Any(s => s.Gloss.SearchValue(query)) - - , options); + return GetEntriesAsyncEnum(Filtering.SearchFilter(query), options); } private async IAsyncEnumerable GetEntriesAsyncEnum( @@ -134,7 +131,7 @@ public async Task BulkImportSemanticDomains(IEnumerable e.Headword(ws.Value).StartsWith(options.Exemplar.Value)); + queryable = queryable.WhereExemplar(ws.Value, options.Exemplar.Value); } var sortWs = (await GetWritingSystem(options.Order.WritingSystem, WritingSystemType.Vernacular))?.WsId; diff --git a/backend/FwLite/LcmCrdt/CrdtProject.cs b/backend/FwLite/LcmCrdt/CrdtProject.cs index 5e378998a..cbd62ee60 100644 --- a/backend/FwLite/LcmCrdt/CrdtProject.cs +++ b/backend/FwLite/LcmCrdt/CrdtProject.cs @@ -7,6 +7,7 @@ public class CrdtProject(string name, string dbPath) : IProjectIdentifier public string Name { get; } = name; public string Origin { get; } = "CRDT"; public string DbPath { get; } = dbPath; + public ProjectData? Data { get; set; } } public record ProjectData(string Name, Guid Id, string? OriginDomain, Guid ClientId) diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index 62c460848..da3202174 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -36,6 +36,11 @@ private static string CacheKey(Guid projectId) return $"ProjectData|{projectId}"; } + public static ProjectData? LookupProjectData(IMemoryCache memoryCache, string projectName) + { + return memoryCache.Get(projectName + "|ProjectData"); + } + public static ProjectData? LookupProjectById(IMemoryCache memoryCache, Guid projectId) { return memoryCache.Get(CacheKey(projectId)); diff --git a/backend/FwLite/LcmCrdt/Data/Filtering.cs b/backend/FwLite/LcmCrdt/Data/Filtering.cs new file mode 100644 index 000000000..856241723 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Data/Filtering.cs @@ -0,0 +1,39 @@ +using System.Linq.Expressions; +using MiniLcm.Models; + +namespace LcmCrdt.Data; + +public static class Filtering +{ + public static IQueryable WhereExemplar( + this IQueryable query, + WritingSystemId ws, + string exemplar) + { + return query.Where(e => e.Headword(ws).StartsWith(exemplar)); + } + + public static Expression> SearchFilter(string query) + { + return e => e.LexemeForm.SearchValue(query) + || e.CitationForm.SearchValue(query) + || e.Senses.Any(s => s.Gloss.SearchValue(query)); + } + + public static Func CompiledFilter(string? query, WritingSystemId ws, string? exemplar) + { + query = string.IsNullOrEmpty(query) ? null : query; + return (query, exemplar) switch + { + (null, null) => _ => true, + (not null, null) => e => e.LexemeForm.SearchValue(query) + || e.CitationForm.SearchValue(query) + || e.Senses.Any(s => s.Gloss.SearchValue(query)), + (null, not null) => e => e.Headword(ws).StartsWith(exemplar), + (_, _) => e => e.Headword(ws).StartsWith(exemplar) + && (e.LexemeForm.SearchValue(query) + || e.CitationForm.SearchValue(query) + || e.Senses.Any(s => s.Gloss.SearchValue(query))) + }; + } +} diff --git a/backend/FwLite/LcmCrdt/LcmCrdt.csproj b/backend/FwLite/LcmCrdt/LcmCrdt.csproj index de3206339..68176ba8f 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdt.csproj +++ b/backend/FwLite/LcmCrdt/LcmCrdt.csproj @@ -15,6 +15,7 @@ + diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 9f8a0792d..c1a70cb50 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -26,7 +26,7 @@ public static IServiceCollection AddLcmCrdtClient(this IServiceCollection servic LinqToDBForEFTools.Initialize(); services.AddMemoryCache(); services.AddDbContext(ConfigureDbOptions); - services.AddOptions(); + services.AddOptions().BindConfiguration("LcmCrdt"); services.AddCrdtData( ConfigureCrdt diff --git a/backend/FwLite/LcmCrdt/ProjectsService.cs b/backend/FwLite/LcmCrdt/ProjectsService.cs index aaa2d9f07..71f5eb9dc 100644 --- a/backend/FwLite/LcmCrdt/ProjectsService.cs +++ b/backend/FwLite/LcmCrdt/ProjectsService.cs @@ -1,6 +1,7 @@ using SIL.Harmony; using SIL.Harmony.Db; using LcmCrdt.Utils; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -9,14 +10,17 @@ namespace LcmCrdt; -public class ProjectsService(IServiceProvider provider, ProjectContext projectContext, ILogger logger, IOptions config) +public class ProjectsService(IServiceProvider provider, ProjectContext projectContext, ILogger logger, IOptions config, IMemoryCache memoryCache) { public Task ListProjects() { return Task.FromResult(Directory.EnumerateFiles(config.Value.ProjectPath, "*.sqlite").Select(file => { var name = Path.GetFileNameWithoutExtension(file); - return new CrdtProject(name, file); + return new CrdtProject(name, file) + { + Data = CurrentProjectService.LookupProjectData(memoryCache, name) + }; }).ToArray()); } diff --git a/backend/FwLite/LcmCrdt/SqlHelpers.cs b/backend/FwLite/LcmCrdt/SqlHelpers.cs index 1c1d8183f..e1f2de676 100644 --- a/backend/FwLite/LcmCrdt/SqlHelpers.cs +++ b/backend/FwLite/LcmCrdt/SqlHelpers.cs @@ -16,8 +16,8 @@ public static bool HasValue(this MultiString ms, string value) [Sql.Expression("exists(select 1 from json_each({0}, '$') where value like '%' || {1} || '%')", PreferServerSide = true, IsPredicate = true)] - public static bool SearchValue(this MultiString ms, string value) + public static bool SearchValue(this MultiString ms, string search) { - return ms.Values.Values.Contains(value); + return ms.Values.Any(pair => pair.Value.Contains(search)); } } diff --git a/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs b/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs index 5b5f05a41..4e11f8706 100644 --- a/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs +++ b/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs @@ -11,6 +11,10 @@ public class AuthConfig public bool SystemWebViewLogin { get; set; } = false; public LexboxServer DefaultServer => LexboxServers.First(); + public LexboxServer GetServerByAuthority(string authority) + { + return LexboxServers.FirstOrDefault(s => s.Authority.Authority == authority) ?? throw new ArgumentException($"Server {authority} not found"); + } public LexboxServer GetServer(string serverName) { return LexboxServers.FirstOrDefault(s => s.DisplayName == serverName) ?? throw new ArgumentException($"Server {serverName} not found"); diff --git a/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs b/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs index 85943b2e9..b243a98dd 100644 --- a/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs +++ b/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs @@ -1,6 +1,7 @@ using LcmCrdt; +using LcmCrdt.Data; using LocalWebApp.Services; -using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Caching.Memory; using MiniLcm; using MiniLcm.Models; using SystemTextJsonPatch; @@ -13,20 +14,65 @@ public class CrdtMiniLcmApiHub( SyncService syncService, ChangeEventBus changeEventBus, CurrentProjectService projectContext, - LexboxProjectService lexboxProjectService) : MiniLcmApiHubBase(miniLcmApi) + LexboxProjectService lexboxProjectService, + IMemoryCache memoryCache) : MiniLcmApiHubBase(miniLcmApi) { public const string ProjectRouteKey = "project"; public static string ProjectGroup(string projectName) => "crdt-" + projectName; + private IDisposable[] Cleanup + { + get => Context.Items["cleanup"] as IDisposable[] ?? []; + set => Context.Items["cleanup"] = value; + } public override async Task OnConnectedAsync() { await Groups.AddToGroupAsync(Context.ConnectionId, ProjectGroup(projectContext.Project.Name)); await syncService.ExecuteSync(); - changeEventBus.SetupGlobalSignalRSubscription(); + Cleanup = + [ + changeEventBus.ListenForEntryChanges(projectContext.Project.Name, Context.ConnectionId) + ]; await lexboxProjectService.ListenForProjectChanges(projectContext.ProjectData, Context.ConnectionAborted); } + public override async Task OnDisconnectedAsync(Exception? exception) + { + await base.OnDisconnectedAsync(exception); + foreach (var disposable in Cleanup) + { + disposable.Dispose(); + } + memoryCache.Remove($"CurrentFilter|HubConnectionId={Context.ConnectionId}"); + } + + private Func CurrentFilter + { + set => memoryCache.Set($"CurrentFilter|HubConnectionId={Context.ConnectionId}", value); + } + + public static Func CurrentProjectFilter(IMemoryCache memoryCache, string connectionId) + { + return memoryCache.Get>( + $"CurrentFilter|HubConnectionId={connectionId}") ?? (_ => true); + } + + public override IAsyncEnumerable GetEntries(QueryOptions? options = null) + { + CurrentFilter = + Filtering.CompiledFilter(null, options?.Exemplar?.WritingSystem ?? "default", options?.Exemplar?.Value); + return base.GetEntries(options); + } + + public override IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null) + { + CurrentFilter = Filtering.CompiledFilter(query, + options?.Exemplar?.WritingSystem ?? "default", + options?.Exemplar?.Value); + return base.SearchEntries(query, options); + } + public override async Task CreateWritingSystem(WritingSystemType type, WritingSystem writingSystem) { var newWritingSystem = await base.CreateWritingSystem(type, writingSystem); diff --git a/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs b/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs index dfa0db2c3..2d704f891 100644 --- a/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs +++ b/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs @@ -38,12 +38,12 @@ public IAsyncEnumerable GetSemanticDomains() return miniLcmApi.GetSemanticDomains(); } - public IAsyncEnumerable GetEntries(QueryOptions? options = null) + public virtual IAsyncEnumerable GetEntries(QueryOptions? options = null) { return miniLcmApi.GetEntries(options); } - public IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null) + public virtual IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null) { return miniLcmApi.SearchEntries(query, options); } diff --git a/backend/FwLite/LocalWebApp/LocalWebAppServer.cs b/backend/FwLite/LocalWebApp/LocalWebAppServer.cs index 88bab2760..1080b9485 100644 --- a/backend/FwLite/LocalWebApp/LocalWebAppServer.cs +++ b/backend/FwLite/LocalWebApp/LocalWebAppServer.cs @@ -14,9 +14,9 @@ namespace LocalWebApp; public static class LocalWebAppServer { - public static WebApplication SetupAppServer(string[] args, Action? configure = null) + public static WebApplication SetupAppServer(WebApplicationOptions options, Action? configure = null) { - var builder = WebApplication.CreateBuilder(args); + var builder = WebApplication.CreateBuilder(options); if (!builder.Environment.IsDevelopment()) builder.WebHost.UseUrls("http://127.0.0.1:0"); if (builder.Environment.IsDevelopment()) @@ -28,13 +28,13 @@ public static WebApplication SetupAppServer(string[] args, Action(config => config.LexboxServers = [ new (new("https://lexbox.dev.languagetechnology.org"), "Lexbox Dev"), - new (new("https://localhost:3000"), "Lexbox Local") + new (new("https://localhost:3000"), "Lexbox Local"), + new (new("https://staging.languagedepot.org"), "Lexbox Staging") ]); -//for now prod builds will also use lt dev until we deploy oauth to prod builder.ConfigureProd(config => - config.LexboxServers = [new(new("https://lexbox.dev.languagetechnology.org"), "Lexbox Dev")]); + config.LexboxServers = [new(new("https://staging.languagedepot.org"), "Lexbox Staging")]); builder.Services.Configure(c => c.ClientId = "becf2856-0690-434b-b192-a4032b72067f"); - + builder.Logging.AddDebug(); builder.Services.AddLocalAppServices(builder.Environment); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/backend/FwLite/LocalWebApp/Program.cs b/backend/FwLite/LocalWebApp/Program.cs index 99f815b90..22babdafb 100644 --- a/backend/FwLite/LocalWebApp/Program.cs +++ b/backend/FwLite/LocalWebApp/Program.cs @@ -1,7 +1,7 @@ using LocalWebApp; -var app = LocalWebAppServer.SetupAppServer(args); +var app = LocalWebAppServer.SetupAppServer(new() {Args = args}); await using (app) { await app.StartAsync(); diff --git a/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs b/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs index 8578502f4..2cd52d1e8 100644 --- a/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs @@ -8,7 +8,7 @@ namespace LocalWebApp.Routes; public static class AuthRoutes { public const string CallbackRoute = "AuthRoutes_Callback"; - public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs); + public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs, string? Authority); public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) { var group = app.MapGroup("/api/auth").WithOpenApi(); @@ -19,13 +19,13 @@ public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) var currentName = await factory.GetHelper(s).GetCurrentName(); return new ServerStatus(s.DisplayName, !string.IsNullOrEmpty(currentName), - currentName); + currentName, s.Authority.Authority); }); }); - group.MapGet("/login/{server}", - async (AuthHelpersFactory factory, string server, IOptions options) => + group.MapGet("/login/{authority}", + async (AuthHelpersFactory factory, string authority, IOptions options) => { - var result = await factory.GetHelper(options.Value.GetServer(server)).SignIn(); + var result = await factory.GetHelper(options.Value.GetServerByAuthority(authority)).SignIn(); if (result.HandledBySystemWebView) { return Results.Redirect("/"); @@ -46,15 +46,15 @@ public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) await oAuthService.FinishLoginRequest(uriBuilder.Uri); return Results.Redirect("/"); }).WithName(CallbackRoute); - group.MapGet("/me/{server}", - async (AuthHelpersFactory factory, string server, IOptions options) => + group.MapGet("/me/{authority}", + async (AuthHelpersFactory factory, string authority, IOptions options) => { - return new { name = await factory.GetHelper(options.Value.GetServer(server)).GetCurrentName() }; + return new { name = await factory.GetHelper(options.Value.GetServerByAuthority(authority)).GetCurrentName() }; }); - group.MapGet("/logout/{server}", - async (AuthHelpersFactory factory, string server, IOptions options) => + group.MapGet("/logout/{authority}", + async (AuthHelpersFactory factory, string authority, IOptions options) => { - await factory.GetHelper(options.Value.GetServer(server)).Logout(); + await factory.GetHelper(options.Value.GetServerByAuthority(authority)).Logout(); return Results.Redirect("/"); }); return group; diff --git a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs index f4ff49d14..095078c88 100644 --- a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs @@ -24,7 +24,7 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap foreach (var server in options.Value.LexboxServers) { var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server); - serversProjects.Add(server.DisplayName, lexboxProjects.Select(p => new ProjectModel(p.Name, false, false, true, server.DisplayName, p.Id)).ToArray()); + serversProjects.Add(server.Authority.Authority, lexboxProjects.Select(p => new ProjectModel(p.Name, false, false, true, server.Authority.Authority, p.Id)).ToArray()); } return serversProjects; @@ -35,8 +35,17 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap FieldWorksProjectList fieldWorksProjectList) => { var crdtProjects = await projectService.ListProjects(); - //todo get project Id and use that to specify the Id in the model - var projects = crdtProjects.ToDictionary(p => p.Name, p => new ProjectModel(p.Name, true, false)); + //todo get project Id and use that to specify the Id in the model. Also pull out server + var projects = crdtProjects.ToDictionary(p => p.Name, p => + { + var uri = p.Data?.OriginDomain is not null ? new Uri(p.Data.OriginDomain) : null; + return new ProjectModel(p.Name, + true, + false, + p.Data?.OriginDomain is not null, + uri?.Authority, + p.Data?.Id); + }); //basically populate projects and indicate if they are lexbox or fwdata foreach (var p in fieldWorksProjectList.EnumerateProjects()) { @@ -63,14 +72,14 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap await projectService.CreateProject(new(name, AfterCreate: AfterCreate)); return TypedResults.Ok(); }); - group.MapPost($"/upload/crdt/{{serverName}}/{{{CrdtMiniLcmApiHub.ProjectRouteKey}}}", + group.MapPost($"/upload/crdt/{{serverAuthority}}/{{{CrdtMiniLcmApiHub.ProjectRouteKey}}}", async (LexboxProjectService lexboxProjectService, SyncService syncService, IOptions options, CurrentProjectService currentProjectService, - string serverName) => + string serverAuthority) => { - var server = options.Value.GetServer(serverName); + var server = options.Value.GetServerByAuthority(serverAuthority); var foundProjectGuid = await lexboxProjectService.GetLexboxProjectId(server, currentProjectService.ProjectData.Name); if (foundProjectGuid is null) @@ -80,17 +89,17 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap await syncService.ExecuteSync(); return TypedResults.Ok(); }); - group.MapPost("/download/crdt/{serverName}/{newProjectName}", + group.MapPost("/download/crdt/{serverAuthority}/{newProjectName}", async (LexboxProjectService lexboxProjectService, IOptions options, ProjectsService projectService, string newProjectName, - string serverName + string serverAuthority ) => { if (!ProjectName().IsMatch(newProjectName)) return Results.BadRequest("Project name is invalid"); - var server = options.Value.GetServer(serverName); + var server = options.Value.GetServerByAuthority(serverAuthority); var foundProjectGuid = await lexboxProjectService.GetLexboxProjectId(server,newProjectName); if (foundProjectGuid is null) return Results.BadRequest($"Project code {newProjectName} not found on lexbox"); @@ -107,7 +116,7 @@ await projectService.CreateProject(new(newProjectName, return group; } - public record ProjectModel(string Name, bool Crdt, bool Fwdata, bool Lexbox = false, string? Server = null, Guid? Id = null); + public record ProjectModel(string Name, bool Crdt, bool Fwdata, bool Lexbox = false, string? ServerAuthority = null, Guid? Id = null); private static async Task AfterCreate(IServiceProvider provider, CrdtProject project) { diff --git a/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs b/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs index c5f6e3a2e..a3352d83b 100644 --- a/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs @@ -31,6 +31,13 @@ public static IEndpointConventionBuilder MapTest(this WebApplication app) if (entry is Entry crdtEntry) eventBus.NotifyEntryUpdated(crdtEntry); }); + group.MapPost("/add-new-entry", + async (IMiniLcmApi api, ChangeEventBus eventBus, MiniLcm.Models.Entry entry) => + { + var createdEntry = await api.CreateEntry(entry); + if (createdEntry is Entry crdtEntry) + eventBus.NotifyEntryUpdated(crdtEntry); + }); return group; } } diff --git a/backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs b/backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs index d82fa9e4a..97954f5c1 100644 --- a/backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs +++ b/backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs @@ -4,22 +4,29 @@ using LcmCrdt.Objects; using LocalWebApp.Hubs; using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Caching.Memory; namespace LocalWebApp.Services; -public class ChangeEventBus(ProjectContext projectContext, IHubContext hubContext, ILogger logger) +public class ChangeEventBus( + ProjectContext projectContext, + IHubContext hubContext, + ILogger logger, + IMemoryCache cache) : IDisposable { - private IDisposable? _subscription; + public IDisposable ListenForEntryChanges(string projectName, string connectionId) => + _entryUpdated + .Where(n => n.ProjectName == projectName) + .Subscribe(n => OnEntryChangedExternal(n.Entry, connectionId)); - public void SetupGlobalSignalRSubscription() + private void OnEntryChangedExternal(Entry e, string connectionId) { - if (_subscription is not null) return; - _subscription = _entryUpdated.Subscribe(notification => + var currentFilter = CrdtMiniLcmApiHub.CurrentProjectFilter(cache, connectionId); + if (currentFilter.Invoke(e)) { - logger.LogInformation("Sending notification for {EntryId} to {ProjectName}", notification.Entry.Id, notification.ProjectName); - _ = hubContext.Clients.Group(CrdtMiniLcmApiHub.ProjectGroup(notification.ProjectName)).OnEntryUpdated(notification.Entry); - }); + _ = hubContext.Clients.Client(connectionId).OnEntryUpdated(e); + } } private record struct ChangeNotification(Entry Entry, string ProjectName); @@ -45,6 +52,7 @@ public void NotifyEntryUpdated(Entry entry) public void Dispose() { - _subscription?.Dispose(); + _entryUpdated.OnCompleted(); + _entryUpdated.Dispose(); } } diff --git a/backend/FwLite/LocalWebApp/appsettings.json b/backend/FwLite/LocalWebApp/appsettings.json index 356485704..8171300d2 100644 --- a/backend/FwLite/LocalWebApp/appsettings.json +++ b/backend/FwLite/LocalWebApp/appsettings.json @@ -7,5 +7,15 @@ "Microsoft.EntityFrameworkCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "LcmCrdt": { + // uncomment the following line to set the path where sqlite files are loaded and saved. + // by default windows uses AppData\Local\SIL\FwLiteDesktop + //"ProjectPath": "" + }, + "FwDataBridge": { + // uncomment the following line to set the path fwdata projects are loaded + // by default we load them from the ProgramData folder where FW stores it's projects +// "ProjectsFolder": "" + } } diff --git a/backend/LexBoxApi/Controllers/CrdtController.cs b/backend/LexBoxApi/Controllers/CrdtController.cs index bbc5d4c98..c982eb99f 100644 --- a/backend/LexBoxApi/Controllers/CrdtController.cs +++ b/backend/LexBoxApi/Controllers/CrdtController.cs @@ -14,7 +14,6 @@ namespace LexBoxApi.Controllers; [ApiController] [Route("/api/crdt")] -[AdminRequired] [ApiExplorerSettings(GroupName = LexBoxKernel.OpenApiPublicDocumentName)] public class CrdtController( LexBoxDbContext dbContext, diff --git a/deployment/staging/app-config.yaml b/deployment/staging/app-config.yaml index 57a4435ea..cb2b2308a 100644 --- a/deployment/staging/app-config.yaml +++ b/deployment/staging/app-config.yaml @@ -5,3 +5,4 @@ metadata: data: environment-name: "Staging" hg-domain: "hg-staging.languageforge.org" + enable-oauth: "true" diff --git a/frontend/viewer/src/HomeView.svelte b/frontend/viewer/src/HomeView.svelte index 3f01d8032..f619eb159 100644 --- a/frontend/viewer/src/HomeView.svelte +++ b/frontend/viewer/src/HomeView.svelte @@ -11,37 +11,20 @@ import {Button, Card, type ColumnDef, Table, TextField, tableCell, Icon, ProgressCircle} from 'svelte-ux'; import flexLogo from './lib/assets/flex-logo.png'; import DevContent, {isDev} from './lib/layout/DevContent.svelte'; + import {useProjectsService, type Project, type ServerStatus} from './lib/services/projects-service'; + import {onMount} from 'svelte'; - type Project = { - name: string; - crdt: boolean; - fwdata: boolean; - lexbox: boolean, - server: string | null, - id: string | null - }; + const projectsService = useProjectsService(); let newProjectName = ''; let createError: string; async function createProject() { - createError = ''; - if (!newProjectName) { - createError = 'Project name is required.'; - return; - } - const response = await fetch(`/api/project?name=${newProjectName}`, { - method: 'POST', - }); - - if (!response.ok) { - createError = await response.json(); - return; - } - - createError = ''; + const response = await projectsService.createProject(newProjectName); + createError = response.error ?? ''; + if (createError) return; newProjectName = ''; void refreshProjects(); } @@ -51,9 +34,7 @@ async function importFwDataProject(name: string) { importing = name; - await fetch(`/api/import/fwdata/${name}`, { - method: 'POST', - }); + await projectsService.importFwDataProject(name); await refreshProjects(); importing = ''; } @@ -62,32 +43,17 @@ async function downloadCrdtProject(project: Project) { downloading = project.name; - await fetch(`/api/download/crdt/${project.server}/${project.name}`, {method: 'POST'}); + await projectsService.downloadCrdtProject(project); await refreshProjects(); downloading = ''; } - let uploading = ''; - - async function uploadCrdtProject(project: Project) { - uploading = project.name; - await fetch(`/api/upload/crdt/${project.server}/${project.name}`, {method: 'POST'}); - await refreshProjects(); - uploading = ''; - } - - let projectsPromise = fetchProjects(); + let projectsPromise = projectsService.fetchProjects().then(p => projects = p); let projects: Project[] = []; - async function fetchProjects() { - let r = await fetch('/api/localProjects'); - projects = (await r.json()) as Project[]; - return projects; - } - async function refreshProjects() { - let promise = fetchProjects(); - await promise;//avoids clearing out the list until the new list is fetched + let promise = projectsService.fetchProjects(); + projects = await promise;//avoids clearing out the list until the new list is fetched projectsPromise = promise; } @@ -95,21 +61,14 @@ let loadingRemoteProjects = false; async function fetchRemoteProjects() { loadingRemoteProjects = true; - let r = await fetch('/api/remoteProjects'); - remoteProjects = (await r.json()) as { [server: string]: Project[] }; + remoteProjects = await projectsService.fetchRemoteProjects(); loadingRemoteProjects = false; } fetchRemoteProjects(); - type ServerStatus = { displayName: string; loggedIn: boolean; loggedInAs: string | null }; - let servers: ServerStatus[] = []; - async function fetchServers() { - let r = await fetch('/api/auth/servers'); - servers = await r.json(); - } - - fetchServers(); + let servers: ServerStatus[] = []; + onMount(async () => servers = await projectsService.fetchServers()); $: columns = [ { @@ -141,18 +100,28 @@ function matchesProject(projects: Project[], project: Project) { let matches: Project | undefined = undefined; if (project.id) { - matches = projects.find(p => p.id == project.id); + matches = projects.find(p => p.id == project.id && p.serverAuthority == project.serverAuthority); } //for now the local project list does not include the id, so fallback to the name if (!matches) { - matches = projects.find(p => p.name === project.name); + matches = projects.find(p => p.name === project.name && p.serverAuthority == project.serverAuthority); } return matches; } - function syncedServer(serversProjects: { [server: string]: Project[] }, project: Project): string | null { - return Object.entries(serversProjects) - .find(([server, projects]) => matchesProject(projects, project))?.[0] ?? null; + function syncedServer(serversProjects: { [server: string]: Project[] }, project: Project): ServerStatus | undefined { + //this may be null, even if the project is synced, when the project info isn't cached on the server yet. + if (project.serverAuthority) { + return servers.find(s => s.authority == project.serverAuthority) ?? { + displayName: 'Unknown server ' + project.serverAuthority, + loggedIn: false, + loggedInAs: null, + authority: project.serverAuthority + }; + } + let authority = Object.entries(serversProjects) + .find(([server, projects]) => matchesProject(projects, project))?.[0]; + return authority ? servers.find(s => s.authority == authority) : undefined; } @@ -184,43 +153,43 @@ data={projects.filter((p) => $isDev || p.fwdata).sort((p1, p2) => p1.name.localeCompare(p2.name))} classes={{ th: 'p-4' }}> - {#each data ?? [] as rowData, rowIndex} + {#each data ?? [] as project, rowIndex} {#each columns as column (column.name)} - + {#if column.name === 'fwdata'} - {#if rowData.fwdata} - {/if} {:else if column.name === 'lexbox'} - {@const server = syncedServer(remoteProjects, rowData)} - {#if rowData.crdt && server} - + {@const server = syncedServer(remoteProjects, project)} + {#if project.crdt && server} + {/if} {:else if column.name === 'crdt'} - {#if rowData.crdt} + {#if project.crdt} - {:else if rowData.fwdata} + {:else if project.fwdata} {/if} {:else} - {getCellContent(column, rowData, rowIndex)} + {getCellContent(column, project, rowIndex)} {/if} {/each} @@ -259,17 +228,18 @@

{server.loggedInAs}

{/if} {#if server.loggedIn} - + {:else} - + {/if} - {@const serverProjects = remoteProjects[server.displayName] ?? []} + {@const serverProjects = remoteProjects[server.authority] ?? []} {#each serverProjects as project} + {@const localProject = matchesProject(projects, project)}

{project.name}

- {#if matchesProject(projects, project)?.crdt} + {#if localProject?.crdt} {:else}
{/if} -
+
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 d6728d998..7d7821196 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte @@ -147,7 +147,7 @@ {/if}
- dispatch('change', {entry, sense})}/> + dispatch('change', {entry, sense})}/>
{#each sense.exampleSentences as example, j (example.id)} diff --git a/frontend/viewer/src/lib/layout/EntryList.svelte b/frontend/viewer/src/lib/layout/EntryList.svelte index e453ecf99..d6bf68569 100644 --- a/frontend/viewer/src/lib/layout/EntryList.svelte +++ b/frontend/viewer/src/lib/layout/EntryList.svelte @@ -72,7 +72,7 @@ rounded on:click={() => dictionaryMode = !dictionaryMode}> -