diff --git a/.gitmodules b/.gitmodules index ff0f22e8f..59dd7daf6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "backend/harmony"] path = backend/harmony url = https://github.com/hahn-kev/harmony.git - branch = add-crdt + branch = chore/performance-pass diff --git a/LexBox.sln b/LexBox.sln index e27567273..6ec82a3cc 100644 --- a/LexBox.sln +++ b/LexBox.sln @@ -37,6 +37,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crdt", "backend\harmony\src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crdt.Core", "backend\harmony\src\Crdt.Core\Crdt.Core.csproj", "{8B54FFB5-0BDF-403E-83CC-A3B3861EC507}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwDataMiniLcmBridge", "backend\FwDataMiniLcmBridge\FwDataMiniLcmBridge.csproj", "{279197B6-EC06-4DE0-94F8-625379C3AD83}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -95,6 +97,10 @@ Global {8B54FFB5-0BDF-403E-83CC-A3B3861EC507}.Debug|Any CPU.Build.0 = Debug|Any CPU {8B54FFB5-0BDF-403E-83CC-A3B3861EC507}.Release|Any CPU.ActiveCfg = Release|Any CPU {8B54FFB5-0BDF-403E-83CC-A3B3861EC507}.Release|Any CPU.Build.0 = Release|Any CPU + {279197B6-EC06-4DE0-94F8-625379C3AD83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {279197B6-EC06-4DE0-94F8-625379C3AD83}.Debug|Any CPU.Build.0 = Debug|Any CPU + {279197B6-EC06-4DE0-94F8-625379C3AD83}.Release|Any CPU.ActiveCfg = Release|Any CPU + {279197B6-EC06-4DE0-94F8-625379C3AD83}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {E8BB768B-C3DC-4BE6-9B9F-82319E05AF86} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} @@ -104,5 +110,6 @@ Global {7D874D9B-1CA9-49E9-8B03-91B5C324E938} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} {740C8FF5-8006-4047-8C52-53873C2DD7C4} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} {8B54FFB5-0BDF-403E-83CC-A3B3861EC507} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} + {279197B6-EC06-4DE0-94F8-625379C3AD83} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} EndGlobalSection EndGlobal diff --git a/Taskfile.yml b/Taskfile.yml index d3e1c51a0..63ac89079 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -36,6 +36,7 @@ tasks: - echo "GOOGLE_OAUTH_CLIENT_ID=__REPLACE__.apps.googleusercontent.com" >> deployment/local-dev/local.env - echo "GOOGLE_OAUTH_CLIENT_SECRET=__REPLACE__" >> deployment/local-dev/local.env - kubectl --context=docker-desktop apply -f deployment/setup/namespace.yaml + - kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.0/cert-manager.yaml - docker build -t local-dev-init data/ setup-win: platforms: [ windows ] @@ -78,5 +79,8 @@ tasks: deps: [ infra-up, api:only, k8s:infra-forward ] interactive: true + local-web-app-for-develop: + deps: [ ui:viewer-dev, api:local-web-app-for-develop, ui:https-oauth-authority ] + local-web-app: - deps: [ ui:viewer-dev, api:local-web-app ] + deps: [ ui:viewer-dev, api:local-web-app, ui:https-oauth-authority ] diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs new file mode 100644 index 000000000..2f862442c --- /dev/null +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -0,0 +1,465 @@ +using FwDataMiniLcmBridge.Api.UpdateProxy; +using Microsoft.Extensions.Logging; +using MiniLcm; +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Core.WritingSystems; +using SIL.LCModel.DomainServices; +using SIL.LCModel.Infrastructure; + +namespace FwDataMiniLcmBridge.Api; + +public class FwDataMiniLcmApi(LcmCache cache, bool onCloseSave, ILogger logger, FwDataProject project) : ILexboxApi, IDisposable +{ + public FwDataProject Project { get; } = project; + + private readonly IWritingSystemContainer _writingSystemContainer = + cache.ServiceLocator.WritingSystems; + + private readonly ILexEntryRepository _entriesRepository = + cache.ServiceLocator.GetInstance(); + + private readonly IRepository _senseRepository = + cache.ServiceLocator.GetInstance>(); + + private readonly IRepository _exampleSentenceRepository = + cache.ServiceLocator.GetInstance>(); + + private readonly ILexEntryFactory _lexEntryFactory = cache.ServiceLocator.GetInstance(); + private readonly ILexSenseFactory _lexSenseFactory = cache.ServiceLocator.GetInstance(); + + private readonly ILexExampleSentenceFactory _lexExampleSentenceFactory = + cache.ServiceLocator.GetInstance(); + + private readonly IMoMorphTypeRepository _morphTypeRepository = + cache.ServiceLocator.GetInstance(); + + private readonly ICmTranslationFactory _cmTranslationFactory = + cache.ServiceLocator.GetInstance(); + + private readonly ICmPossibilityRepository _cmPossibilityRepository = + cache.ServiceLocator.GetInstance(); + + public void Dispose() + { + if (onCloseSave) + { + Save(); + } + } + + public void Save() + { + logger.LogInformation("Saving FW data file {Name}", cache.ProjectId.Name); + cache.ActionHandlerAccessor.Commit(); + } + + public int EntryCount => _entriesRepository.Count; + + internal WritingSystemId GetWritingSystemId(int ws) + { + return cache.ServiceLocator.WritingSystemManager.Get(ws).Id; + } + + internal int GetWritingSystemHandle(WritingSystemId ws, WritingSystemType? type = null) + { + var lcmWs = GetLcmWritingSystem(ws, type) ?? throw new NullReferenceException($"Unable to find writing system with id {ws}"); + return lcmWs.Handle; + } + + internal CoreWritingSystemDefinition? GetLcmWritingSystem(WritingSystemId ws, WritingSystemType? type = null) + { + if (ws == "default") + { + return type switch + { + WritingSystemType.Analysis => _writingSystemContainer.DefaultAnalysisWritingSystem, + WritingSystemType.Vernacular => _writingSystemContainer.DefaultVernacularWritingSystem, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + } + + var lcmWs = cache.ServiceLocator.WritingSystemManager.Get(ws.Code); + if (lcmWs is not null && type is not null) + { + var validWs = type switch + { + WritingSystemType.Analysis => _writingSystemContainer.AnalysisWritingSystems, + WritingSystemType.Vernacular => _writingSystemContainer.VernacularWritingSystems, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + if (!validWs.Contains(lcmWs)) + { + throw new InvalidOperationException($"Writing system {ws} is not of the requested type: {type}."); + } + } + return lcmWs; + } + + public Task GetWritingSystems() + { + var currentVernacularWs = _writingSystemContainer + .CurrentVernacularWritingSystems + .Select(ws => ws.Id).ToHashSet(); + var currentAnalysisWs = _writingSystemContainer + .CurrentAnalysisWritingSystems + .Select(ws => ws.Id).ToHashSet(); + var writingSystems = new WritingSystems + { + Vernacular = cache.ServiceLocator.WritingSystems.VernacularWritingSystems.Select(ws => new WritingSystem + { + //todo determine current and create a property for that. + Id = ws.Id, + Name = ws.LanguageTag, + Abbreviation = ws.Abbreviation, + Font = ws.DefaultFontName, + Exemplars = ws.CharacterSets.FirstOrDefault(s => s.Type == "index")?.Characters.ToArray() ?? [] + }).ToArray(), + Analysis = cache.ServiceLocator.WritingSystems.AnalysisWritingSystems.Select(ws => new WritingSystem + { + Id = ws.Id, + Name = ws.LanguageTag, + Abbreviation = ws.Abbreviation, + Font = ws.DefaultFontName, + Exemplars = ws.CharacterSets.FirstOrDefault(s => s.Type == "index")?.Characters.ToArray() ?? [] + }).ToArray() + }; + CompleteExemplars(writingSystems); + return Task.FromResult(writingSystems); + } + + internal void CompleteExemplars(WritingSystems writingSystems) + { + var wsExemplars = writingSystems.Vernacular.Concat(writingSystems.Analysis) + .Distinct() + .ToDictionary(ws => ws, ws => ws.Exemplars.ToHashSet()); + var wsExemplarsByHandle = wsExemplars.ToDictionary(kv => GetWritingSystemHandle(kv.Key.Id), kv => kv.Value); + + foreach (var entry in _entriesRepository.AllInstances()) + { + LcmHelpers.ContributeExemplars(entry.CitationForm, wsExemplarsByHandle); + LcmHelpers.ContributeExemplars(entry.LexemeFormOA.Form, wsExemplarsByHandle); + } + + foreach (var ws in wsExemplars.Keys) + { + ws.Exemplars = [.. wsExemplars[ws].Order()]; + } + } + + public Task CreateWritingSystem(WritingSystemType type, WritingSystem writingSystem) + { + throw new NotImplementedException(); + } + + public Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) + { + throw new NotImplementedException(); + } + + private Entry FromLexEntry(ILexEntry entry) + { + return new Entry + { + Id = entry.Guid, + Note = FromLcmMultiString(entry.Comment), + LexemeForm = FromLcmMultiString(entry.LexemeFormOA.Form), + CitationForm = FromLcmMultiString(entry.CitationForm), + LiteralMeaning = FromLcmMultiString(entry.LiteralMeaning), + Senses = entry.AllSenses.Select(FromLexSense).ToList() + }; + } + + private Sense FromLexSense(ILexSense sense) + { + return new Sense + { + Id = sense.Guid, + Gloss = FromLcmMultiString(sense.Gloss), + Definition = FromLcmMultiString(sense.Definition), + PartOfSpeech = sense.SenseTypeRA?.Name.BestAnalysisVernacularAlternative.Text ?? string.Empty, + SemanticDomain = sense.SemanticDomainsRC.Select(s => s.OcmCodes).ToList(), + ExampleSentences = sense.ExamplesOS.Select(FromLexExampleSentence).ToList() + }; + } + + private ExampleSentence FromLexExampleSentence(ILexExampleSentence sentence) + { + var translation = sentence.TranslationsOC.FirstOrDefault()?.Translation; + return new ExampleSentence + { + Id = sentence.Guid, + Sentence = FromLcmMultiString(sentence.Example), + Reference = sentence.Reference.Text, + Translation = translation is null ? new MultiString() : FromLcmMultiString(translation), + }; + } + + private MultiString FromLcmMultiString(ITsMultiString multiString) + { + var result = new MultiString(); + for (var i = 0; i < multiString.StringCount; i++) + { + var tsString = multiString.GetStringFromIndex(i, out var ws); + result.Values.Add(GetWritingSystemId(ws), tsString.Text); + } + + return result; + } + + public async IAsyncEnumerable GetEntries(QueryOptions? options = null) + { + await foreach (var entry in GetEntries(null, options)) + { + yield return entry; + } + } + + public async IAsyncEnumerable GetEntries( + Func? predicate = null, QueryOptions? options = null) + { + var entries = _entriesRepository.AllInstances(); + + options ??= QueryOptions.Default; + if (predicate is not null) entries = entries.Where(e => predicate(e)); + + if (options.Exemplar is not null) + { + var ws = GetWritingSystemHandle(options.Exemplar.WritingSystem, WritingSystemType.Vernacular); + entries = entries.Where(e => (e.CitationForm.get_String(ws).Text ?? e.LexemeFormOA.Form.get_String(ws).Text)? + .Trim(LcmHelpers.WhitespaceAndFormattingChars) + .StartsWith(options.Exemplar.Value, StringComparison.InvariantCultureIgnoreCase) ?? false); + } + + var sortWs = GetWritingSystemHandle(options.Order.WritingSystem, WritingSystemType.Vernacular); + entries = entries.OrderBy(e => (e.CitationForm.get_String(sortWs).Text ?? e.LexemeFormOA.Form.get_String(sortWs).Text).Trim(LcmHelpers.WhitespaceChars)) + .Skip(options.Offset) + .Take(options.Count); + + foreach (var entry in entries) + { + yield return FromLexEntry(entry); + } + } + + public IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null) + { + var entries = GetEntries(e => + e.CitationForm.SearchValue(query) || + e.LexemeFormOA.Form.SearchValue(query) || + e.SensesOS.Any(s => s.Gloss.SearchValue(query)), options); + return entries; + } + + public Task GetEntry(Guid id) + { + return Task.FromResult(FromLexEntry(_entriesRepository.GetObject(id))); + } + + public async Task CreateEntry(Entry entry) + { + // TODO: The API requires a value and the UI assumes it has a value. How should we handle this? + // if (entry.Id != default) throw new NotSupportedException("Id must be empty"); + Guid entryId = default; + UndoableUnitOfWorkHelper.Do("Create Entry", + "Remove entry", + cache.ServiceLocator.ActionHandler, + () => + { + var rootMorphType = _morphTypeRepository.GetObject(MoMorphTypeTags.kguidMorphRoot); + var firstSense = entry.Senses.FirstOrDefault(); + var lexEntry = _lexEntryFactory.Create(new LexEntryComponents + { + MorphType = rootMorphType, + LexemeFormAlternatives = MultiStringToTsStrings(entry.LexemeForm), + GlossAlternatives = MultiStringToTsStrings(firstSense?.Gloss), + GlossFeatures = [], + MSA = null + }); + UpdateLcmMultiString(lexEntry.CitationForm, entry.CitationForm); + UpdateLcmMultiString(lexEntry.LiteralMeaning, entry.LiteralMeaning); + UpdateLcmMultiString(lexEntry.Comment, entry.Note); + if (firstSense is not null) + { + var lexSense = lexEntry.SensesOS.First(); + ApplySenseToLexSense(firstSense, lexSense); + } + + //first sense is already created + foreach (var sense in entry.Senses.Skip(1)) + { + CreateSense(lexEntry, sense); + } + + entryId = lexEntry.Guid; + }); + if (entryId == default) throw new InvalidOperationException("Entry was not created"); + + return await GetEntry(entryId) ?? throw new InvalidOperationException("Entry was not found"); + } + + private IList MultiStringToTsStrings(MultiString? multiString) + { + if (multiString is null) return []; + var result = new List(multiString.Values.Count); + foreach (var (ws, value) in multiString.Values) + { + result.Add(TsStringUtils.MakeString(value, GetWritingSystemHandle(ws))); + } + + return result; + } + + private void UpdateLcmMultiString(ITsMultiString multiString, MultiString newMultiString) + { + foreach (var (ws, value) in newMultiString.Values) + { + var writingSystemHandle = GetWritingSystemHandle(ws); + multiString.set_String(writingSystemHandle, TsStringUtils.MakeString(value, writingSystemHandle)); + } + } + + public Task UpdateEntry(Guid id, UpdateObjectInput update) + { + var lexEntry = _entriesRepository.GetObject(id); + UndoableUnitOfWorkHelper.Do("Update Entry", + "Revert entry", + cache.ServiceLocator.ActionHandler, + () => + { + var updateProxy = new UpdateEntryProxy(lexEntry, this); + update.Apply(updateProxy); + }); + return Task.FromResult(FromLexEntry(lexEntry)); + } + + public Task DeleteEntry(Guid id) + { + UndoableUnitOfWorkHelper.Do("Delete Entry", + "Revert delete", + cache.ServiceLocator.ActionHandler, + () => + { + _entriesRepository.GetObject(id).Delete(); + }); + return Task.CompletedTask; + } + + internal void CreateSense(ILexEntry lexEntry, Sense sense) + { + var lexSense = _lexSenseFactory.Create(sense.Id, lexEntry); + ApplySenseToLexSense(sense, lexSense); + } + + private void ApplySenseToLexSense(Sense sense, ILexSense lexSense) + { + UpdateLcmMultiString(lexSense.Gloss, sense.Gloss); + UpdateLcmMultiString(lexSense.Definition, sense.Definition); + foreach (var exampleSentence in sense.ExampleSentences) + { + CreateExampleSentence(lexSense, exampleSentence); + } + } + + public Task CreateSense(Guid entryId, Sense sense) + { + if (sense.Id != default) sense.Id = Guid.NewGuid(); + if (!_entriesRepository.TryGetObject(entryId, out var lexEntry)) + throw new InvalidOperationException("Entry not found"); + UndoableUnitOfWorkHelper.Do("Create Sense", + "Remove sense", + cache.ServiceLocator.ActionHandler, + () => CreateSense(lexEntry, sense)); + return Task.FromResult(FromLexSense(_senseRepository.GetObject(sense.Id))); + } + + public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) + { + var lexSense = _senseRepository.GetObject(senseId); + if (lexSense.Owner.Guid != entryId) throw new InvalidOperationException("Sense does not belong to entry"); + UndoableUnitOfWorkHelper.Do("Update Sense", + "Revert sense", + cache.ServiceLocator.ActionHandler, + () => + { + var updateProxy = new UpdateSenseProxy(lexSense, this); + update.Apply(updateProxy); + }); + return Task.FromResult(FromLexSense(lexSense)); + } + + public Task DeleteSense(Guid entryId, Guid senseId) + { + var lexSense = _senseRepository.GetObject(senseId); + if (lexSense.Owner.Guid != entryId) throw new InvalidOperationException("Sense does not belong to entry"); + UndoableUnitOfWorkHelper.Do("Delete Sense", + "Revert delete", + cache.ServiceLocator.ActionHandler, + () => lexSense.Delete()); + return Task.CompletedTask; + } + + internal void CreateExampleSentence(ILexSense lexSense, ExampleSentence exampleSentence) + { + var lexExampleSentence = _lexExampleSentenceFactory.Create(exampleSentence.Id, lexSense); + UpdateLcmMultiString(lexExampleSentence.Example, exampleSentence.Sentence); + var freeTranslationType = _cmPossibilityRepository.GetObject(CmPossibilityTags.kguidTranFreeTranslation); + var translation = _cmTranslationFactory.Create(lexExampleSentence, freeTranslationType); + UpdateLcmMultiString(translation.Translation, exampleSentence.Translation); + lexExampleSentence.Reference = TsStringUtils.MakeString(exampleSentence.Reference, + lexExampleSentence.Reference.get_WritingSystem(0)); + } + + public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence) + { + if (exampleSentence.Id != default) exampleSentence.Id = Guid.NewGuid(); + if (!_senseRepository.TryGetObject(senseId, out var lexSense)) + throw new InvalidOperationException("Sense not found"); + UndoableUnitOfWorkHelper.Do("Create Example Sentence", + "Remove example sentence", + cache.ServiceLocator.ActionHandler, + () => CreateExampleSentence(lexSense, exampleSentence)); + return Task.FromResult(FromLexExampleSentence(_exampleSentenceRepository.GetObject(exampleSentence.Id))); + } + + public Task UpdateExampleSentence(Guid entryId, + Guid senseId, + Guid exampleSentenceId, + UpdateObjectInput update) + { + var lexExampleSentence = _exampleSentenceRepository.GetObject(exampleSentenceId); + if (lexExampleSentence.Owner.Guid != senseId) + throw new InvalidOperationException("Example sentence does not belong to sense"); + if (lexExampleSentence.Owner.Owner.Guid != entryId) + throw new InvalidOperationException("Example sentence does not belong to entry"); + UndoableUnitOfWorkHelper.Do("Update Example Sentence", + "Revert example sentence", + cache.ServiceLocator.ActionHandler, + () => + { + var updateProxy = new UpdateExampleSentenceProxy(lexExampleSentence, this); + update.Apply(updateProxy); + }); + return Task.FromResult(FromLexExampleSentence(lexExampleSentence)); + } + + public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) + { + var lexExampleSentence = _exampleSentenceRepository.GetObject(exampleSentenceId); + if (lexExampleSentence.Owner.Guid != senseId) + throw new InvalidOperationException("Example sentence does not belong to sense"); + if (lexExampleSentence.Owner.Owner.Guid != entryId) + throw new InvalidOperationException("Example sentence does not belong to entry"); + UndoableUnitOfWorkHelper.Do("Delete Example Sentence", + "Revert delete", + cache.ServiceLocator.ActionHandler, + () => lexExampleSentence.Delete()); + return Task.CompletedTask; + } + + public UpdateBuilder CreateUpdateBuilder() where T : class + { + return new UpdateBuilder(); + } +} diff --git a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs new file mode 100644 index 000000000..351022e8c --- /dev/null +++ b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs @@ -0,0 +1,68 @@ +using SIL.LCModel.Core.KernelInterfaces; + +namespace FwDataMiniLcmBridge.Api; + +internal static class LcmHelpers +{ + internal static bool SearchValue(this ITsMultiString multiString, string value) + { + var valueLower = value.ToLowerInvariant(); + for (var i = 0; i < multiString.StringCount; i++) + { + var tsString = multiString.GetStringFromIndex(i, out var _); + if (tsString.Text?.ToLowerInvariant().Contains(valueLower) is true) + { + return true; + } + } + return false; + } + + internal static readonly char[] WhitespaceChars = + [ + '\u0009', // Tab + '\u000A', // Line Feed + '\u000D', // Carriage Return + '\u0020', // Space + '\u00A0', // Non-breaking Space + '\u2002', // En Space + '\u2003', // Em Space + '\u2004', // Three-Per-Em Space + '\u2005', // Four-Per-Em Space + '\u2006', // Six-Per-Em Space + '\u2007', // Figure Space + '\u2008', // Punctuation Space + '\u2009', // Thin Space + '\u200A', // Hair Space + '\u200B', // Zero Width Space + '\u200C', // Zero Width Non-Joiner + '\u200D', // Zero Width Joiner + '\u200E', // Left-to-Right Mark + '\u200F', // Right-to-Left Mark + '\u2028', // Line Separator + '\u2029', // Paragraph Separator + '\u202F', // Narrow No-Break Space + '\u205F', // Medium Mathematical Space + '\u3000', // Ideographic Space + '\uFEFF', // Zero Width No-Break Space / BOM + ]; + + internal static readonly char[] WhitespaceAndFormattingChars = + [ + .. WhitespaceChars, + '\u0640', // Arabic Tatweel + ]; + + internal static void ContributeExemplars(ITsMultiString multiString, Dictionary> wsExemplars) + { + for (var i = 0; i < multiString.StringCount; i++) + { + var tsString = multiString.GetStringFromIndex(i, out var ws); + var value = tsString.Text?.Trim(WhitespaceAndFormattingChars); + if (value?.Any() is true && wsExemplars.TryGetValue(ws, out var exemplars)) + { + exemplars.Add(value.First().ToString()); + } + } + } +} diff --git a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs new file mode 100644 index 000000000..9060ecf82 --- /dev/null +++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs @@ -0,0 +1,165 @@ +using System.Collections; +using MiniLcm; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.Text; + +namespace FwDataMiniLcmBridge.Api.UpdateProxy; + +public class UpdateDictionaryProxy(ITsMultiString multiString, FwDataMiniLcmApi lexboxLcmApi) + : IDictionary, IDictionary +{ + public void Add(KeyValuePair item) + { + Add(item.Key, item.Value); + } + + public void Add(WritingSystemId key, string value) + { + var writingSystemHandle = lexboxLcmApi.GetWritingSystemHandle(key); + multiString.set_String(writingSystemHandle, TsStringUtils.MakeString(value, writingSystemHandle)); + } + + public bool ContainsKey(WritingSystemId key) + { + if (multiString.StringCount == 0) return false; + var tsString = multiString.get_String(lexboxLcmApi.GetWritingSystemHandle(key)); + return tsString.Length > 0; + } + + public void Add(object key, object? value) + { + var valStr = value as string ?? throw new ArgumentException("unable to convert value to string", nameof(value)); + if (key is WritingSystemId keyWs) + { + Add(keyWs, valStr); + } + else if (key is string keyStr) + { + Add(keyStr, valStr); + } + else + { + throw new ArgumentException("unable to convert key to writing system id", nameof(key)); + } + } + + public bool Contains(object key) + { + return ContainsKey(key as WritingSystemId? ?? key as string ?? + throw new ArgumentException("unable to convert key to writing system id", nameof(key))); + } + + + public string this[WritingSystemId key] + { + get + { + var tsString = multiString.get_String(lexboxLcmApi.GetWritingSystemHandle(key)); + return tsString.Text; + } + set + { + var writingSystemHandle = lexboxLcmApi.GetWritingSystemHandle(key); + multiString.set_String(writingSystemHandle, TsStringUtils.MakeString(value, writingSystemHandle)); + } + } + + public object? this[object key] + { + get => + key switch + { + WritingSystemId keyWs => this[keyWs], + string keyStr => this[keyStr], + _ => throw new ArgumentException("unable to convert key to writing system id", nameof(key)) + }; + set + { + var valStr = value as string ?? + throw new ArgumentException("unable to convert value to string", nameof(value)); + if (key is WritingSystemId keyWs) + { + this[keyWs] = valStr; + } + else if (key is string keyStr) + { + this[keyStr] = valStr; + } + else + { + throw new ArgumentException("unable to convert key to writing system id", nameof(key)); + } + } + } + + IDictionaryEnumerator IDictionary.GetEnumerator() + { + throw new NotSupportedException(); + } + + public void Remove(object key) + { + } + + public bool IsFixedSize => false; + + public IEnumerator> GetEnumerator() + { + throw new NotSupportedException(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public void Clear() + { + throw new NotSupportedException(); + } + + public bool Contains(KeyValuePair item) + { + throw new NotSupportedException(); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + throw new NotSupportedException(); + } + + public bool Remove(KeyValuePair item) + { + throw new NotSupportedException(); + } + + public void CopyTo(Array array, int index) + { + } + + public int Count => throw new NotSupportedException(); + + public bool IsSynchronized => false; + + public object SyncRoot => this; + + public bool IsReadOnly => false; + + public bool Remove(WritingSystemId key) + { + throw new NotSupportedException(); + } + + public bool TryGetValue(WritingSystemId key, out string value) + { + throw new NotSupportedException(); + } + + public ICollection Keys => []; + + ICollection IDictionary.Values => Array.Empty(); + + ICollection IDictionary.Keys => Array.Empty(); + + public ICollection Values => []; +} diff --git a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs new file mode 100644 index 000000000..f48470ca1 --- /dev/null +++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs @@ -0,0 +1,59 @@ +using MiniLcm; +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; + +namespace FwDataMiniLcmBridge.Api.UpdateProxy; + +public class UpdateEntryProxy(ILexEntry lcmEntry, FwDataMiniLcmApi lexboxLcmApi) : Entry +{ + public override Guid Id + { + get => lcmEntry.Guid; + set => throw new NotImplementedException(); + } + + public override MultiString LexemeForm + { + get => new UpdateMultiStringProxy(lcmEntry.LexemeFormOA.Form, lexboxLcmApi); + set => throw new NotImplementedException(); + } + + public override MultiString CitationForm + { + get => new UpdateMultiStringProxy(lcmEntry.CitationForm, lexboxLcmApi); + set => throw new NotImplementedException(); + } + + public override MultiString LiteralMeaning + { + get => new UpdateMultiStringProxy(lcmEntry.LiteralMeaning, lexboxLcmApi); + set => throw new NotImplementedException(); + } + + public override IList Senses + { + get => + new UpdateListProxy( + sense => lexboxLcmApi.CreateSense(lcmEntry, sense), + sense => lexboxLcmApi.DeleteSense(Id, sense.Id), + i => new UpdateSenseProxy(lcmEntry.SensesOS[i], lexboxLcmApi) + ); + set => throw new NotImplementedException(); + } + + public override MultiString Note + { + get => new UpdateMultiStringProxy(lcmEntry.Comment, lexboxLcmApi); + set => throw new NotImplementedException(); + } +} + +public class UpdateMultiStringProxy(ITsMultiString multiString, FwDataMiniLcmApi lexboxLcmApi) : MultiString +{ + public override IDictionary Values { get; } = new UpdateDictionaryProxy(multiString, lexboxLcmApi); + + public override MultiString Copy() + { + return new UpdateMultiStringProxy(multiString, lexboxLcmApi); + } +} diff --git a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateExampleSentenceProxy.cs b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateExampleSentenceProxy.cs new file mode 100644 index 000000000..c764d2fae --- /dev/null +++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateExampleSentenceProxy.cs @@ -0,0 +1,36 @@ +using MiniLcm; +using SIL.LCModel; +using SIL.LCModel.Core.Text; + +namespace FwDataMiniLcmBridge.Api.UpdateProxy; + +public class UpdateExampleSentenceProxy(ILexExampleSentence sentence, FwDataMiniLcmApi lexboxLcmApi): ExampleSentence +{ + public override Guid Id + { + get => sentence.Guid; + set => throw new NotImplementedException(); + } + + public override MultiString Sentence + { + get => new UpdateMultiStringProxy(sentence.Example, lexboxLcmApi); + set => throw new NotImplementedException(); + } + + public override MultiString Translation + { + get + { + var firstTranslation = sentence.TranslationsOC.FirstOrDefault()?.Translation; + return firstTranslation is null ? new MultiString() : new UpdateMultiStringProxy(firstTranslation, lexboxLcmApi); + } + set => throw new NotImplementedException(); + } + + public override string? Reference + { + get => throw new NotImplementedException(); + set => sentence.Reference = TsStringUtils.MakeString(value, sentence.Reference.get_WritingSystem(0)); + } +} diff --git a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateListProxy.cs b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateListProxy.cs new file mode 100644 index 000000000..dc21c62bf --- /dev/null +++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateListProxy.cs @@ -0,0 +1,74 @@ +using System.Collections; + +namespace FwDataMiniLcmBridge.Api.UpdateProxy; + +public class UpdateListProxy( + Action add, + Action remove, + Func getAt) : IList +{ + public IEnumerator GetEnumerator() + { + throw new NotImplementedException(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + throw new NotImplementedException(); + } + + public void Add(T item) + { + add(item); + } + + public void Clear() + { + throw new NotImplementedException(); + } + + public bool Contains(T item) + { + throw new NotImplementedException(); + } + + public void CopyTo(T[] array, int arrayIndex) + { + throw new NotImplementedException(); + } + + public bool Remove(T item) + { + remove(item); + return false; + } + + public int Count => throw new NotImplementedException(); + + public bool IsReadOnly => false; + + public int IndexOf(T item) + { + throw new NotImplementedException(); + } + + public void Insert(int index, T item) + { + add(item); + } + + public void RemoveAt(int index) + { + Remove(getAt(index)); + } + + public T this[int index] + { + get => getAt(index); + set + { + RemoveAt(index); + Insert(index, value); + } + } +} \ No newline at end of file diff --git a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs new file mode 100644 index 000000000..83239bc88 --- /dev/null +++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs @@ -0,0 +1,48 @@ +using MiniLcm; +using SIL.LCModel; + +namespace FwDataMiniLcmBridge.Api.UpdateProxy; + +public class UpdateSenseProxy(ILexSense sense, FwDataMiniLcmApi lexboxLcmApi) : Sense +{ + public override Guid Id + { + get => sense.Guid; + set => throw new NotImplementedException(); + } + + public override MultiString Definition + { + get => new UpdateMultiStringProxy(sense.Definition, lexboxLcmApi); + set => throw new NotImplementedException(); + } + + public override MultiString Gloss + { + get => new UpdateMultiStringProxy(sense.Gloss, lexboxLcmApi); + set => throw new NotImplementedException(); + } + + public override string PartOfSpeech + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override IList SemanticDomain + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override IList ExampleSentences + { + get => + new UpdateListProxy( + sentence => lexboxLcmApi.CreateExampleSentence(sense, sentence), + sentence => lexboxLcmApi.DeleteExampleSentence(sense.Owner.Guid, Id, sentence.Id), + i => new UpdateExampleSentenceProxy(sense.ExamplesOS[i], lexboxLcmApi) + ); + set => throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/backend/FwDataMiniLcmBridge/FieldWorksProjectList.cs b/backend/FwDataMiniLcmBridge/FieldWorksProjectList.cs new file mode 100644 index 000000000..a62439796 --- /dev/null +++ b/backend/FwDataMiniLcmBridge/FieldWorksProjectList.cs @@ -0,0 +1,23 @@ +using FwDataMiniLcmBridge.LcmUtils; +using MiniLcm; + +namespace FwDataMiniLcmBridge; + +public class FieldWorksProjectList +{ + public static IEnumerable EnumerateProjects() + { + foreach (var directory in Directory.EnumerateDirectories(ProjectLoader.ProjectFolder)) + { + var projectName = Path.GetFileName(directory); + if (string.IsNullOrEmpty(projectName)) continue; + if (!File.Exists(Path.Combine(directory, projectName + ".fwdata"))) continue; + yield return new FwDataProject(projectName, projectName + ".fwdata"); + } + } + + public static FwDataProject? GetProject(string name) + { + return EnumerateProjects().OfType().FirstOrDefault(p => p.Name == name); + } +} diff --git a/backend/FwDataMiniLcmBridge/FwDataBridgeKernel.cs b/backend/FwDataMiniLcmBridge/FwDataBridgeKernel.cs new file mode 100644 index 000000000..59d8de9ac --- /dev/null +++ b/backend/FwDataMiniLcmBridge/FwDataBridgeKernel.cs @@ -0,0 +1,20 @@ +using FwDataMiniLcmBridge.LcmUtils; +using Microsoft.Extensions.DependencyInjection; +using MiniLcm; + +namespace FwDataMiniLcmBridge; + +public static class FwDataBridgeKernel +{ + public const string FwDataApiKey = "FwDataApiKey"; + public static IServiceCollection AddFwDataBridge(this IServiceCollection services) + { + services.AddMemoryCache(); + services.AddSingleton(); + //todo since this is scoped it gets created on each request (or hub method call), which opens the project file on each request + //this is not ideal since opening the project file can be slow. It should be done once per hub connection. + services.AddKeyedScoped(FwDataApiKey, (provider, o) => provider.GetRequiredService().GetCurrentFwDataMiniLcmApi(true)); + services.AddSingleton(); + return services; + } +} diff --git a/backend/FwDataMiniLcmBridge/FwDataFactory.cs b/backend/FwDataMiniLcmBridge/FwDataFactory.cs new file mode 100644 index 000000000..16497de43 --- /dev/null +++ b/backend/FwDataMiniLcmBridge/FwDataFactory.cs @@ -0,0 +1,88 @@ +using FwDataMiniLcmBridge.Api; +using FwDataMiniLcmBridge.LcmUtils; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using SIL.LCModel; + +namespace FwDataMiniLcmBridge; + +public class FwDataFactory(FwDataProjectContext context, ILogger fwdataLogger, IMemoryCache cache, ILogger logger): IDisposable +{ + public FwDataMiniLcmApi GetFwDataMiniLcmApi(string projectName, bool saveOnDispose) + { + var project = FieldWorksProjectList.GetProject(projectName) ?? throw new InvalidOperationException($"Project {projectName} not found."); + return GetFwDataMiniLcmApi(project, saveOnDispose); + } + + private string CacheKey(FwDataProject project) => $"{nameof(FwDataFactory)}|{project.FileName}"; + + public FwDataMiniLcmApi GetFwDataMiniLcmApi(FwDataProject project, bool saveOnDispose) + { + var projectService = GetProjectServiceCached(project); + return new FwDataMiniLcmApi(projectService, saveOnDispose, fwdataLogger, project); + } + + private HashSet _projects = []; + private LcmCache GetProjectServiceCached(FwDataProject project) + { + var key = CacheKey(project); + var projectService = cache.GetOrCreate(key, + entry => + { + entry.SlidingExpiration = TimeSpan.FromMinutes(30); + entry.RegisterPostEvictionCallback(OnLcmProjectCacheEviction, (logger, _projects)); + logger.LogInformation("Loading project {ProjectFileName}", project.FileName); + var projectService = ProjectLoader.LoadCache(project.FileName); + logger.LogInformation("Project {ProjectFileName} loaded", project.FileName); + _projects.Add((string)entry.Key); + return projectService; + }); + if (projectService is null) + { + throw new InvalidOperationException("Project service is null"); + } + if (projectService.IsDisposed) + { + throw new InvalidOperationException("Project service is disposed"); + } + + return projectService; + } + + private static void OnLcmProjectCacheEviction(object key, object? value, EvictionReason reason, object? state) + { + if (value is null) return; + // todo this could trigger when the service is still referenced elsewhere, for example in a long running task. + // disposing of the service while it's still in use would be bad. + // one way around this would be to return a lease object, only after a timeout and no more references to the lease object would the service be disposed. + var lcmCache = (LcmCache)value; + var (logger, projects) = ((ILogger, HashSet))state!; + var name = lcmCache.ProjectId.Name; + logger.LogInformation("Evicting project {ProjectFileName} from cache", name); + lcmCache.Dispose(); + logger.LogInformation("FW Data Project {ProjectFileName} disposed", name); + projects.Remove((string)key); + } + + public void Dispose() + { + foreach (var project in _projects) + { + var lcmCache = cache.Get(project); + if (lcmCache is null) continue; + var name = lcmCache.ProjectId.Name; + lcmCache.Dispose();//need to explicitly call dispose as that blocks, just removing from the cache does not block, meaning it will not finish disposing before the program exits. + logger.LogInformation("FW Data Project {ProjectFileName} disposed", name); + } + } + + public FwDataMiniLcmApi GetCurrentFwDataMiniLcmApi(bool saveOnDispose) + { + var fwDataProject = context.Project; + if (fwDataProject is null) + { + throw new InvalidOperationException("No project is set in the context."); + } + return GetFwDataMiniLcmApi(fwDataProject, true); + } +} diff --git a/backend/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj b/backend/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj new file mode 100644 index 000000000..f7b18e4ae --- /dev/null +++ b/backend/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/backend/FwDataMiniLcmBridge/FwDataProject.cs b/backend/FwDataMiniLcmBridge/FwDataProject.cs new file mode 100644 index 000000000..68dd25fd1 --- /dev/null +++ b/backend/FwDataMiniLcmBridge/FwDataProject.cs @@ -0,0 +1,10 @@ +using MiniLcm; + +namespace FwDataMiniLcmBridge; + +public class FwDataProject(string name, string fileName) : IProjectIdentifier +{ + public string Name { get; } = name; + public string FileName { get; } = fileName; + public string Origin { get; } = "FieldWorks"; +} diff --git a/backend/FwDataMiniLcmBridge/FwDataProjectContext.cs b/backend/FwDataMiniLcmBridge/FwDataProjectContext.cs new file mode 100644 index 000000000..b40d03407 --- /dev/null +++ b/backend/FwDataMiniLcmBridge/FwDataProjectContext.cs @@ -0,0 +1,32 @@ +namespace FwDataMiniLcmBridge; + +public class FwDataProjectContext +{ + private sealed class ProjectHolder + { + public FwDataProject? Project; + } + + private static readonly AsyncLocal _projectHolder = new(); + + public virtual FwDataProject? Project + { + get => _projectHolder.Value?.Project; + set + { + var holder = _projectHolder.Value; + if (holder != null) + { + // Clear current Project trapped in the AsyncLocals, as its done. + holder.Project = null; + } + + if (value is not null) + { + // Use an object indirection to hold the Project in the AsyncLocal, + // so it can be cleared in all ExecutionContexts when its cleared above. + _projectHolder.Value = new ProjectHolder { Project = value }; + } + } + } +} diff --git a/backend/FwDataMiniLcmBridge/LcmUtils/LcmDirectories.cs b/backend/FwDataMiniLcmBridge/LcmUtils/LcmDirectories.cs new file mode 100644 index 000000000..15df281d8 --- /dev/null +++ b/backend/FwDataMiniLcmBridge/LcmUtils/LcmDirectories.cs @@ -0,0 +1,5 @@ +using SIL.LCModel; + +namespace FwDataMiniLcmBridge.LcmUtils; + +public record LcmDirectories(string ProjectsDirectory, string TemplateDirectory) : ILcmDirectories; diff --git a/backend/FwDataMiniLcmBridge/LcmUtils/LcmThreadedProgress.cs b/backend/FwDataMiniLcmBridge/LcmUtils/LcmThreadedProgress.cs new file mode 100644 index 000000000..fe7466b00 --- /dev/null +++ b/backend/FwDataMiniLcmBridge/LcmUtils/LcmThreadedProgress.cs @@ -0,0 +1,52 @@ +using System.ComponentModel; +using SIL.LCModel.Utils; + +namespace FwDataMiniLcmBridge.LcmUtils; + +public class LcmThreadedProgress : IThreadedProgress +{ + private SingleThreadedSynchronizeInvoke _synchronizeInvoke = new(); + + public event CancelEventHandler? Canceling; // this is part of the interface + + public void Step(int amount) + { + } + + public string? Title { get; set; } + public string? Message { get; set; } + public int Position { get; set; } + public int StepSize { get; set; } + public int Minimum { get; set; } + public int Maximum { get; set; } + + public ISynchronizeInvoke SynchronizeInvoke + { + get { return _synchronizeInvoke; } + } + + public bool IsIndeterminate { get; set; } + public bool AllowCancel { get; set; } + + public object RunTask(Func backgroundTask, params object[] parameters) + { + return backgroundTask(this, parameters); + } + + public object RunTask(bool fDisplayUi, + Func backgroundTask, + params object[] parameters) + { + return backgroundTask(this, parameters); + } + + public bool Canceled + { + get { return false; } + } + + public bool IsCanceling + { + get { return false; } + } +} diff --git a/backend/FwDataMiniLcmBridge/LcmUtils/LcmUi.cs b/backend/FwDataMiniLcmBridge/LcmUtils/LcmUi.cs new file mode 100644 index 000000000..04aef60cb --- /dev/null +++ b/backend/FwDataMiniLcmBridge/LcmUtils/LcmUi.cs @@ -0,0 +1,74 @@ +using System.ComponentModel; +using SIL.LCModel; + +namespace FwDataMiniLcmBridge.LcmUtils; + +public class LfLcmUi(ISynchronizeInvoke synchronizeInvoke) : ILcmUI +{ + public void DisplayCircularRefBreakerReport(string msg, string caption) + { + Console.WriteLine(msg); + } + + public bool ConflictingSave() + { + Console.WriteLine("ConsoleLcmUI.ConflictingSave..."); + // Revert to saved state + return true; + } + + public bool ConnectionLost() + { + throw new NotImplementedException(); + } + + public FileSelection ChooseFilesToUse() + { + throw new NotImplementedException(); + } + + public bool RestoreLinkedFilesInProjectFolder() + { + throw new NotImplementedException(); + } + + public YesNoCancel CannotRestoreLinkedFilesToOriginalLocation() + { + throw new NotImplementedException(); + } + + public void DisplayMessage(MessageType type, string message, string caption, string helpTopic) + { + Console.WriteLine("{0}: {1}", type, message); + } + + public void ReportException(Exception error, bool isLethal) + { + Console.WriteLine("Got exception: {0}: {1}\n{2}", error.GetType(), error.Message, error); + } + + public void ReportDuplicateGuids(string errorText) + { + Console.WriteLine("Duplicate GUIDs: " + errorText); + } + + public bool Retry(string msg, string caption) + { + Console.WriteLine(msg); + return true; + } + + public bool OfferToRestore(string projectPath, string backupPath) + { + return false; + } + + public void Exit() + { + Console.WriteLine("Exiting"); + } + + public ISynchronizeInvoke SynchronizeInvoke => synchronizeInvoke; + + public DateTime LastActivityTime => DateTime.Now; +} diff --git a/backend/FwDataMiniLcmBridge/LcmUtils/ProjectLoader.cs b/backend/FwDataMiniLcmBridge/LcmUtils/ProjectLoader.cs new file mode 100644 index 000000000..9ab99f34b --- /dev/null +++ b/backend/FwDataMiniLcmBridge/LcmUtils/ProjectLoader.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using SIL.LCModel; +using SIL.WritingSystems; + +namespace FwDataMiniLcmBridge.LcmUtils; + +public class ProjectLoader +{ + public const string ProjectFolder = @"C:\ProgramData\SIL\FieldWorks\Projects"; + private static string TemplatesFolder { get; } = @"C:\ProgramData\SIL\FieldWorks\Templates"; + private static bool _init; + + public static void Init() + { + if (_init) + { + return; + } + + Icu.Wrapper.Init(); + Debug.Assert(Icu.Wrapper.IcuVersion == "72.1.0.3"); + Sldr.Initialize(); + _init = true; + } + + + /// + /// loads a fwdata file that lives in the project folder C:\ProgramData\SIL\FieldWorks\Projects + /// + /// could be the full path or just the file name, the path will be ignored, must include the extension + /// + public static LcmCache LoadCache(string fileName) + { + Init(); + fileName = Path.GetFileName(fileName); + var projectFilePath = Path.Combine(ProjectFolder, Path.GetFileNameWithoutExtension(fileName), fileName); + var lcmDirectories = new LcmDirectories(ProjectFolder, TemplatesFolder); + var progress = new LcmThreadedProgress(); + var cache = LcmCache.CreateCacheFromLocalProjectFile(projectFilePath, + null, + new LfLcmUi(progress.SynchronizeInvoke), + lcmDirectories, + new LcmSettings(), + progress + ); + return cache; + } +} diff --git a/backend/LcmCrdt.Tests/LexboxApiTests.cs b/backend/LcmCrdt.Tests/LexboxApiTests.cs index 0ce4fc58e..8f1f862f9 100644 --- a/backend/LcmCrdt.Tests/LexboxApiTests.cs +++ b/backend/LcmCrdt.Tests/LexboxApiTests.cs @@ -17,7 +17,7 @@ public class BasicApiTests : IAsyncLifetime private Guid _entry1Id = new Guid("a3f5aa5a-578f-4181-8f38-eaaf27f01f1c"); private Guid _entry2Id = new Guid("2de6c334-58fa-4844-b0fd-0bc2ce4ef835"); - protected readonly IServiceScope _services; + protected readonly AsyncServiceScope _services; public DataModel DataModel = null!; private readonly CrdtDbContext _crdtDbContext; @@ -28,7 +28,7 @@ public BasicApiTests() .RemoveAll(typeof(ProjectContext)) .AddSingleton(new MockProjectContext(new CrdtProject("sena-3", ":memory:"))) .BuildServiceProvider(); - _services = services.CreateScope(); + _services = services.CreateAsyncScope(); _crdtDbContext = _services.ServiceProvider.GetRequiredService(); } @@ -49,6 +49,15 @@ await _api.CreateWritingSystem(WritingSystemType.Analysis, Font = "Arial", Exemplars = [] }); + await _api.CreateWritingSystem(WritingSystemType.Vernacular, + new WritingSystem() + { + Id = "en", + Name = "English", + Abbreviation = "En", + Font = "Arial", + Exemplars = [] + }); await _api.CreateEntry(new Entry { Id = _entry1Id, @@ -124,10 +133,9 @@ await _api.CreateEntry(new() }); } - public Task DisposeAsync() + public async Task DisposeAsync() { - _services.Dispose(); - return Task.CompletedTask; + await _services.DisposeAsync(); } [Fact] diff --git a/backend/LcmCrdt/CrdtLexboxApi.cs b/backend/LcmCrdt/CrdtLexboxApi.cs index 29ff5ec80..18b5d846a 100644 --- a/backend/LcmCrdt/CrdtLexboxApi.cs +++ b/backend/LcmCrdt/CrdtLexboxApi.cs @@ -15,10 +15,10 @@ public class CrdtLexboxApi(DataModel dataModel, JsonSerializerOptions jsonOption private Guid ClientId { get; } = projectService.ProjectData.ClientId; - private IQueryable Entries => dataModel.GetLatestObjects().ToLinqToDB(); - private IQueryable Senses => dataModel.GetLatestObjects().ToLinqToDB(); - private IQueryable ExampleSentences => dataModel.GetLatestObjects().ToLinqToDB(); - private IQueryable WritingSystems => dataModel.GetLatestObjects().ToLinqToDB(); + private IQueryable Entries => dataModel.GetLatestObjects(); + private IQueryable Senses => dataModel.GetLatestObjects(); + private IQueryable ExampleSentences => dataModel.GetLatestObjects(); + private IQueryable WritingSystems => dataModel.GetLatestObjects(); public async Task GetWritingSystems() { @@ -97,13 +97,13 @@ public async Task GetWritingSystems() if (predicate is not null) queryable = queryable.Where(predicate); if (options.Exemplar is not null) { - var ws = (await GetWritingSystem(options.Exemplar.WritingSystem, WritingSystemType.Analysis))?.WsId; + var ws = (await GetWritingSystem(options.Exemplar.WritingSystem, WritingSystemType.Vernacular))?.WsId; if (ws is null) throw new NullReferenceException($"writing system {options.Exemplar.WritingSystem} not found"); queryable = queryable.Where(e => e.Headword(ws.Value).StartsWith(options.Exemplar.Value)); } - var sortWs = (await GetWritingSystem(options.Order.WritingSystem, WritingSystemType.Analysis))?.WsId; + var sortWs = (await GetWritingSystem(options.Order.WritingSystem, WritingSystemType.Vernacular))?.WsId; if (sortWs is null) throw new NullReferenceException($"sort writing system {options.Order.WritingSystem} not found"); queryable = queryable.OrderBy(e => e.Headword(sortWs.Value)) @@ -120,13 +120,13 @@ private async Task LoadSenses(Entry[] entries) { var allSenses = (await Senses .Where(s => entries.Select(e => e.Id).Contains(s.EntryId)) - .ToArrayAsync()) + .ToArrayAsyncEF()) .ToLookup(s => s.EntryId) .ToDictionary(g => g.Key, g => g.ToArray()); var allSenseIds = allSenses.Values.SelectMany(s => s, (_, sense) => sense.Id); var allExampleSentences = (await ExampleSentences .Where(e => allSenseIds.Contains(e.SenseId)) - .ToArrayAsync()) + .ToArrayAsyncEF()) .ToLookup(s => s.SenseId) .ToDictionary(g => g.Key, g => g.ToArray()); foreach (var entry in entries) @@ -143,12 +143,12 @@ private async Task LoadSenses(Entry[] entries) public async Task GetEntry(Guid id) { - var entry = await dataModel.GetLatest(id); + var entry = await Entries.SingleOrDefaultAsync(e => e.Id == id); if (entry is null) return null; var senses = await Senses .Where(s => s.EntryId == id).ToArrayAsyncLinqToDB(); var exampleSentences = (await ExampleSentences - .Where(e => senses.Select(s => s.Id).Contains(e.SenseId)).ToArrayAsyncLinqToDB()) + .Where(e => senses.Select(s => s.Id).Contains(e.SenseId)).ToArrayAsyncEF()) .ToLookup(e => e.SenseId) .ToDictionary(g => g.Key, g => g.ToArray()); entry.Senses = senses; @@ -160,6 +160,21 @@ private async Task LoadSenses(Entry[] entries) return entry; } + /// + /// does not return the newly created entry, used for importing a large amount of data + /// + /// + public async Task CreateEntryLite(MiniLcm.Entry entry) + { + await dataModel.AddChanges(ClientId, + [ + new CreateEntryChange(entry), + ..entry.Senses.Select(s => new CreateSenseChange(s, entry.Id)), + ..entry.Senses.SelectMany(s => s.ExampleSentences, + (sense, sentence) => new CreateExampleSentenceChange(sentence, sense.Id)) + ], deferCommit: true); + } + public async Task CreateEntry(MiniLcm.Entry entry) { await dataModel.AddChanges(ClientId, diff --git a/backend/LcmCrdt/CrdtProject.cs b/backend/LcmCrdt/CrdtProject.cs index 9c4795d36..2c5020e9e 100644 --- a/backend/LcmCrdt/CrdtProject.cs +++ b/backend/LcmCrdt/CrdtProject.cs @@ -1,4 +1,5 @@ -using MiniLcm; +using System.Diagnostics.CodeAnalysis; +using MiniLcm; namespace LcmCrdt; @@ -9,4 +10,10 @@ public class CrdtProject(string name, string dbPath) : IProjectIdentifier public string DbPath { get; } = dbPath; } -public record ProjectData(string Name, Guid Id, string? OriginDomain, Guid ClientId); +public record ProjectData(string Name, Guid Id, string? OriginDomain, Guid ClientId) +{ + public static string? GetOriginDomain(Uri? uri) + { + return uri?.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped); + } +} diff --git a/backend/LcmCrdt/CurrentProjectService.cs b/backend/LcmCrdt/CurrentProjectService.cs index a1cba62c5..1828a1601 100644 --- a/backend/LcmCrdt/CurrentProjectService.cs +++ b/backend/LcmCrdt/CurrentProjectService.cs @@ -25,7 +25,6 @@ public async ValueTask GetProjectData() if (result is null) throw new InvalidOperationException("Project data not found"); return (ProjectData)result; - } private static string CacheKey(CrdtProject project) @@ -38,4 +37,28 @@ public async ValueTask PopulateProjectDataCache() var projectData = await GetProjectData(); return projectData; } + + private void RemoveProjectDataCache() + { + memoryCache.Remove(CacheKey(Project)); + } + + public async Task SetProjectSyncOrigin(Uri domain, Guid? id) + { + var originDomain = ProjectData.GetOriginDomain(domain); + if (id is null) + { + await dbContext.Set() + .ExecuteUpdateAsync(calls => calls.SetProperty(p => p.OriginDomain, originDomain)); + } + else + { + await dbContext.Set() + .ExecuteUpdateAsync(calls => calls.SetProperty(p => p.OriginDomain, originDomain) + .SetProperty(p => p.Id, id)); + } + + RemoveProjectDataCache(); + await PopulateProjectDataCache(); + } } diff --git a/backend/LcmCrdt/ProjectsService.cs b/backend/LcmCrdt/ProjectsService.cs index fb1e8d181..bf567f84b 100644 --- a/backend/LcmCrdt/ProjectsService.cs +++ b/backend/LcmCrdt/ProjectsService.cs @@ -1,5 +1,6 @@ using Crdt.Db; using Microsoft.Extensions.DependencyInjection; +using MiniLcm; namespace LcmCrdt; @@ -28,15 +29,15 @@ public bool ProjectExists(string name) public async Task CreateProject(string name, Guid? id = null, - string? domain = null, + Uri? domain = null, Func? afterCreate = null) { var sqliteFile = $"{name}.sqlite"; if (File.Exists(sqliteFile)) throw new InvalidOperationException("Project already exists"); var crdtProject = new CrdtProject(name, sqliteFile); - using var serviceScope = CreateProjectScope(crdtProject); + await using var serviceScope = CreateProjectScope(crdtProject); var db = serviceScope.ServiceProvider.GetRequiredService(); - await InitProjectDb(db, new ProjectData(name, id ?? Guid.NewGuid(), domain, Guid.NewGuid())); + await InitProjectDb(db, new ProjectData(name, id ?? Guid.NewGuid(), ProjectData.GetOriginDomain(domain), Guid.NewGuid())); await serviceScope.ServiceProvider.GetRequiredService().PopulateProjectDataCache(); await (afterCreate?.Invoke(serviceScope.ServiceProvider, crdtProject) ?? Task.CompletedTask); return crdtProject; @@ -49,9 +50,9 @@ internal static async Task InitProjectDb(CrdtDbContext db, ProjectData data) await db.SaveChangesAsync(); } - public IServiceScope CreateProjectScope(CrdtProject crdtProject) + public AsyncServiceScope CreateProjectScope(CrdtProject crdtProject) { - var serviceScope = provider.CreateScope(); + var serviceScope = provider.CreateAsyncScope(); SetProjectScope(crdtProject); return serviceScope; } diff --git a/backend/LexBoxApi/Controllers/CrdtController.cs b/backend/LexBoxApi/Controllers/CrdtController.cs new file mode 100644 index 000000000..5fb268925 --- /dev/null +++ b/backend/LexBoxApi/Controllers/CrdtController.cs @@ -0,0 +1,63 @@ +using Crdt.Core; +using LexBoxApi.Auth.Attributes; +using LexData; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LexBoxApi.Controllers; + +[ApiController] +[Route("/api/crdt")] +[AdminRequired] +public class CrdtController(LexBoxDbContext dbContext): ControllerBase +{ + [HttpGet("{projectId}/get")] + public async Task> GetSyncState(Guid projectId) + { + return await dbContext.Set().Where(c => c.ProjectId == projectId).GetSyncState(); + } + + [HttpPost("{projectId}/add")] + public async Task Add(Guid projectId, [FromBody] ServerCommit[] commits) + { + foreach (var commit in commits) + { + commit.ProjectId = projectId; + dbContext.Add(commit); //todo should only add if not exists, based on commit id + } + + await dbContext.SaveChangesAsync(); + return Ok(); + } + + [HttpPost("{projectId}/changes")] + public async Task>> Changes(Guid projectId, [FromBody] SyncState clientHeads) + { + var commits = dbContext.Set().Where(c => c.ProjectId == projectId); + return await commits.GetChanges(clientHeads); + } + + public record LexboxCrdtProject(Guid Id, string Name); + [HttpGet("listProjects")] + public async Task> ListProjects() + { + return await dbContext.Projects + .Where(p => dbContext.Set().Any(c => c.ProjectId == p.Id)) + .Select(p => new LexboxCrdtProject(p.Id, p.Code)) + .ToArrayAsync(); + } + + [HttpGet("lookupProjectId")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + public async Task> GetProjectId(string code) + { + var project = await dbContext.Projects.FirstOrDefaultAsync(p => p.Code == code); + if (project == null) + { + return NotFound(); + } + return Ok(project.Id); + } +} diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 889b02293..c867153ca 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -162,7 +162,6 @@ app.MapQuartzUI("/api/quartz").RequireAuthorization(new AdminRequiredAttribute()); app.MapControllers(); app.MapLfClassicApi().RequireAuthorization(new AdminRequiredAttribute()).WithOpenApi(); -app.MapSyncApi().WithOpenApi(); app.MapTus("/api/tus-test", async context => await context.RequestServices.GetRequiredService().GetTestConfig(context)) .RequireAuthorization(new AdminRequiredAttribute()); diff --git a/backend/LexBoxApi/Services/CrdtSyncRoutes.cs b/backend/LexBoxApi/Services/CrdtSyncRoutes.cs deleted file mode 100644 index c3bb92f1c..000000000 --- a/backend/LexBoxApi/Services/CrdtSyncRoutes.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Crdt.Core; -using LexBoxApi.Auth.Attributes; -using LexData; -using LexData.Entities; - -namespace LexBoxApi.Services; - -public static class CrdtSyncRoutes -{ - public static IEndpointConventionBuilder MapSyncApi(this IEndpointRouteBuilder endpoints, - string path = "/api/sync/{id}") - { - //todo determine if the user has permission to access the project, for now lock down to admin only - var group = endpoints.MapGroup(path).RequireAuthorization(new AdminRequiredAttribute()); - group.MapGet("/get", - async (Guid id, LexBoxDbContext dbContext) => - { - return await dbContext.Set().Where(c => c.ProjectId == id).GetSyncState(); - }); - group.MapPost("/add", - async (Guid id, ServerCommit[] commits, LexBoxDbContext dbContext) => - { - foreach (var commit in commits) - { - commit.ProjectId = id; - dbContext.Add(commit);//todo should only add if not exists, based on commit id - } - - await dbContext.SaveChangesAsync(); - }); - group.MapPost("/changes", - async (Guid id, SyncState clientHeads, LexBoxDbContext dbContext) => - { - var commits = dbContext.Set().Where(c => c.ProjectId == id); - return await commits.GetChanges(clientHeads); - }); - - return group; - } -} diff --git a/backend/LexData/Entities/CommitEntityConfiguration.cs b/backend/LexData/Entities/CommitEntityConfiguration.cs index 0dcc47117..d40b04341 100644 --- a/backend/LexData/Entities/CommitEntityConfiguration.cs +++ b/backend/LexData/Entities/CommitEntityConfiguration.cs @@ -15,9 +15,6 @@ public void Configure(EntityTypeBuilder builder) { builder.ToTable("CrdtCommits"); builder.HasKey(c => c.Id); - //hashes aren't serialized, so they can be null on the server - builder.Property(c => c.Hash).IsRequired(false); - builder.Property(c => c.ParentHash).IsRequired(false); builder.ComplexProperty(c => c.HybridDateTime); builder.HasOne().WithMany() .HasPrincipalKey(f => f.ProjectId) diff --git a/backend/LexData/Migrations/20240611215238_RemoveCrdtHashFields.Designer.cs b/backend/LexData/Migrations/20240611215238_RemoveCrdtHashFields.Designer.cs new file mode 100644 index 000000000..0589338a9 --- /dev/null +++ b/backend/LexData/Migrations/20240611215238_RemoveCrdtHashFields.Designer.cs @@ -0,0 +1,1267 @@ +// +using System; +using System.Collections.Generic; +using LexData; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LexData.Migrations +{ + [DbContext(typeof(LexBoxDbContext))] + [Migration("20240611215238_RemoveCrdtHashFields")] + partial class RemoveCrdtHashFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", "quartz"); + }); + + modelBuilder.Entity("Crdt.Core.ServerCommit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.ComplexProperty>("HybridDateTime", "Crdt.Core.ServerCommit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("bigint"); + + b1.Property("DateTime") + .HasColumnType("timestamp with time zone"); + }); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("CrdtCommits", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsConfidential") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectManagerId") + .HasColumnType("uuid"); + + b.Property("RetentionPolicy") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProjectManagerId"); + + b.ToTable("DraftProjects"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("LexEntryCount") + .HasColumnType("integer"); + + b.HasKey("ProjectId"); + + b.ToTable("FlexProjectMetadata"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrgId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrgId"); + + b.HasIndex("UserId", "OrgId") + .IsUnique(); + + b.ToTable("OrgMembers", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Orgs", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsConfidential") + .HasColumnType("boolean"); + + b.Property("LastCommit") + .HasColumnType("timestamp with time zone"); + + b.Property("MigratedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ProjectOrigin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ResetStatus") + .HasColumnType("integer"); + + b.Property("RetentionPolicy") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserId", "ProjectId") + .IsUnique(); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanCreateProjects") + .HasColumnType("boolean"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("GoogleId") + .HasColumnType("text"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("LastActive") + .HasColumnType("timestamp with time zone"); + + b.Property("LocalizationCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("en"); + + b.Property("Locked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordStrength") + .HasColumnType("integer"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Username") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("Crdt.Core.ServerCommit", b => + { + b.HasOne("LexCore.Entities.FlexProjectMetadata", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Crdt.Core.ChangeEntity", "ChangeEntities", b1 => + { + b1.Property("ServerCommitId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b1.Property("Change") + .HasColumnType("text"); + + b1.Property("CommitId") + .HasColumnType("uuid"); + + b1.Property("EntityId") + .HasColumnType("uuid"); + + b1.Property("Index") + .HasColumnType("integer"); + + b1.HasKey("ServerCommitId", "Id"); + + b1.ToTable("CrdtCommits"); + + b1.ToJson("ChangeEntities"); + + b1.WithOwner() + .HasForeignKey("ServerCommitId"); + }); + + b.Navigation("ChangeEntities"); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.HasOne("LexCore.Entities.User", "ProjectManager") + .WithMany() + .HasForeignKey("ProjectManagerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ProjectManager"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithOne("FlexProjectMetadata") + .HasForeignKey("LexCore.Entities.FlexProjectMetadata", "ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.HasOne("LexCore.Entities.Organization", "Organization") + .WithMany("Members") + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Organizations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany("Users") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Projects") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.HasOne("LexCore.Entities.User", "CreatedBy") + .WithMany("UsersICreated") + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Navigation("FlexProjectMetadata"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Navigation("Organizations"); + + b.Navigation("Projects"); + + b.Navigation("UsersICreated"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/LexData/Migrations/20240611215238_RemoveCrdtHashFields.cs b/backend/LexData/Migrations/20240611215238_RemoveCrdtHashFields.cs new file mode 100644 index 000000000..412b215de --- /dev/null +++ b/backend/LexData/Migrations/20240611215238_RemoveCrdtHashFields.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LexData.Migrations +{ + /// + public partial class RemoveCrdtHashFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Hash", + table: "CrdtCommits"); + + migrationBuilder.DropColumn( + name: "ParentHash", + table: "CrdtCommits"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Hash", + table: "CrdtCommits", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ParentHash", + table: "CrdtCommits", + type: "text", + nullable: true); + } + } +} diff --git a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs index 426925a0e..755a1ffe0 100644 --- a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs +++ b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs @@ -476,16 +476,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ClientId") .HasColumnType("uuid"); - b.Property("Hash") - .HasColumnType("text"); - b.Property("Metadata") .IsRequired() .HasColumnType("text"); - b.Property("ParentHash") - .HasColumnType("text"); - b.Property("ProjectId") .HasColumnType("uuid"); diff --git a/backend/LocalWebApp/Auth/AuthHelpers.cs b/backend/LocalWebApp/Auth/AuthHelpers.cs index 6b12e2a7d..1fa078c68 100644 --- a/backend/LocalWebApp/Auth/AuthHelpers.cs +++ b/backend/LocalWebApp/Auth/AuthHelpers.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Security.Cryptography; using LocalWebApp.Routes; using Microsoft.Extensions.Options; @@ -8,6 +8,7 @@ namespace LocalWebApp.Auth; /// +/// when injected directly it will use the authority of the current project, to get a different authority use /// helper class for using MSAL.net /// docs: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/overview /// @@ -21,21 +22,25 @@ public class AuthHelpers private readonly OAuthService _oAuthService; private readonly UrlContext _urlContext; private readonly Uri _authority; + private readonly ILogger _logger; private readonly IPublicClientApplication _application; AuthenticationResult? _authResult; - public AuthHelpers(LoggerAdapter logger, + public AuthHelpers(LoggerAdapter loggerAdapter, IHttpMessageHandlerFactory httpMessageHandlerFactory, IOptions options, LinkGenerator linkGenerator, OAuthService oAuthService, UrlContext urlContext, - Uri authority) + Uri authority, + ILogger logger, + IHostEnvironment hostEnvironment) { _httpMessageHandlerFactory = httpMessageHandlerFactory; _oAuthService = oAuthService; _urlContext = urlContext; _authority = authority; + _logger = logger; (var hostUrl, _isRedirectHostGuess) = urlContext.GetUrl(); _redirectHost = HostString.FromUriComponent(hostUrl); var redirectUri = linkGenerator.GetUriByRouteValues(AuthRoutes.CallbackRoute, new RouteValueDictionary(), hostUrl.Scheme, _redirectHost); @@ -44,7 +49,7 @@ public AuthHelpers(LoggerAdapter logger, //https://github.com/AzureAD/microsoft-authentication-extensions-for-dotnet/wiki/Cross-platform-Token-Cache _application = PublicClientApplicationBuilder.Create(optionsValue.ClientId) .WithExperimentalFeatures() - .WithLogging(logger) + .WithLogging(loggerAdapter, hostEnvironment.IsDevelopment()) .WithHttpClientFactory(new HttpClientFactoryAdapter(httpMessageHandlerFactory)) .WithRedirectUri(redirectUri) .WithOidcAuthority(authority.ToString()) @@ -98,9 +103,9 @@ public HttpClient GetHttpClient() } } - public async Task SignIn() + public async Task SignIn(CancellationToken cancellation = default) { - var authUri = await _oAuthService.SubmitLoginRequest(_application); + var authUri = await _oAuthService.SubmitLoginRequest(_application, cancellation); return authUri.ToString(); } @@ -124,7 +129,16 @@ public async Task Logout() var accounts = await _application.GetAccountsAsync(); var account = accounts.FirstOrDefault(); if (account is null) return null; - _authResult = await _application.AcquireTokenSilent(DefaultScopes, account).ExecuteAsync(); + try + { + _authResult = await _application.AcquireTokenSilent(DefaultScopes, account).ExecuteAsync(); + } + catch (MsalServiceException e) when (e.InnerException is HttpRequestException) + { + _logger.LogWarning(e, "Failed to acquire token silently"); + await _application.RemoveAsync(account);//todo might not be the best way to handle this, maybe it's a transient error? + _authResult = null; + } return _authResult; } diff --git a/backend/LocalWebApp/Auth/AuthHelpersFactory.cs b/backend/LocalWebApp/Auth/AuthHelpersFactory.cs index ef774f165..2776ced84 100644 --- a/backend/LocalWebApp/Auth/AuthHelpersFactory.cs +++ b/backend/LocalWebApp/Auth/AuthHelpersFactory.cs @@ -15,7 +15,6 @@ public class AuthHelpersFactory( /// /// gets the default (as configured in the options) Auth Helper, usually for lexbox.org /// - /// public AuthHelpers GetDefault() { return GetHelper(options.Value.DefaultAuthority); diff --git a/backend/LocalWebApp/Auth/OAuthService.cs b/backend/LocalWebApp/Auth/OAuthService.cs index 4587187e7..092d197bc 100644 --- a/backend/LocalWebApp/Auth/OAuthService.cs +++ b/backend/LocalWebApp/Auth/OAuthService.cs @@ -1,5 +1,6 @@ -using System.Threading.Channels; +using System.Threading.Channels; using System.Web; +using LocalWebApp.Utils; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensibility; @@ -7,25 +8,24 @@ namespace LocalWebApp.Auth; //this class is commented with a number of step comments, these are the steps in the OAuth flow //if a step comes before a method that means it awaits that call, if it comes after that means it resumes after the above await -public class OAuthService : BackgroundService +public class OAuthService(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService { - public async Task SubmitLoginRequest(IPublicClientApplication application) + public async Task SubmitLoginRequest(IPublicClientApplication application, CancellationToken cancellation) { var request = new OAuthLoginRequest(application); if (!_requestChannel.Writer.TryWrite(request)) { throw new InvalidOperationException("Only one request at a time"); } - //step 1 - var uri = await request.GetAuthUri(); + var uri = await request.GetAuthUri(applicationLifetime.ApplicationStopping.Merge(cancellation)); //step 4 if (request.State is null) throw new InvalidOperationException("State is null"); _oAuthLoginRequests[request.State] = request; return uri; } - public async Task FinishLoginRequest(Uri uri) + public async Task FinishLoginRequest(Uri uri, CancellationToken cancellation = default) { var queryString = HttpUtility.ParseQueryString(uri.Query); var state = queryString.Get("state") ?? throw new InvalidOperationException("State is null"); @@ -33,7 +33,7 @@ public async Task FinishLoginRequest(Uri uri) throw new InvalidOperationException("Invalid state"); //step 5 request.SetReturnUri(uri); - return await request.GetAuthenticationResult(); + return await request.GetAuthenticationResult(applicationLifetime.ApplicationStopping.Merge(cancellation)); //step 8 } @@ -50,6 +50,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) try { + //todo we can get stuck here if the user doesn't complete the login, this basically bricks the login at the moment. We need a timeout or something //step 2 var result = await loginRequest.Application.AcquireTokenInteractive(AuthHelpers.DefaultScopes) .WithCustomWebUi(loginRequest) @@ -59,6 +60,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception e) { + logger.LogError(e, "Error getting token"); loginRequest.SetException(e); } @@ -85,6 +87,7 @@ public async Task AcquireAuthorizationCodeAsync(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken) { + cancellationToken.Register(_resultTcs.SetCanceled); State = HttpUtility.ParseQueryString(authorizationUri.Query).Get("state"); //triggers step 1 to finish awaiting _authUriTcs.SetResult(authorizationUri); @@ -94,7 +97,7 @@ public async Task AcquireAuthorizationCodeAsync(Uri authorizationUri, //step 6 } - public async Task GetAuthUri() => await _authUriTcs.Task; + public Task GetAuthUri(CancellationToken cancellation) => _authUriTcs.Task.WaitAsync(cancellation); public void SetReturnUri(Uri uri) => _returnUriTcs.SetResult(uri); public void SetAuthenticationResult(AuthenticationResult result) => _resultTcs.SetResult(result); public void SetException(Exception e) @@ -105,5 +108,5 @@ public void SetException(Exception e) _authUriTcs.SetException(e); } - public Task GetAuthenticationResult() => _resultTcs.Task; + public Task GetAuthenticationResult(CancellationToken cancellation) => _resultTcs.Task.WaitAsync(cancellation); } diff --git a/backend/LocalWebApp/BackgroundSyncService.cs b/backend/LocalWebApp/BackgroundSyncService.cs index 311fadebc..4205b8fa8 100644 --- a/backend/LocalWebApp/BackgroundSyncService.cs +++ b/backend/LocalWebApp/BackgroundSyncService.cs @@ -39,19 +39,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await SyncProject(crdtProject); } - if (!projectsService.ProjectExists("sena-3")) - { - await projectsService.CreateProject("sena-3", - new Guid("0ebc5976-058d-4447-aaa7-297f8569f968"), //same as sena 3 project id in SeedDatabase - options.Value.DefaultAuthority.ToString(), - async (provider, project) => - { - var (_, _, isSynced) = await SyncProject(project); - //seed will only create if missing, so seed anyway since the project should exist on the server always - await SeedDb(provider.GetRequiredService()); - }); - } - await foreach (var project in _syncResultsChannel.Reader.ReadAllAsync(stoppingToken)) { //todo, this might not be required, but I can't remember why I added it @@ -62,65 +49,8 @@ await projectsService.CreateProject("sena-3", private async Task SyncProject(CrdtProject crdtProject) { - using var serviceScope = projectsService.CreateProjectScope(crdtProject); + await using var serviceScope = projectsService.CreateProjectScope(crdtProject); var syncService = serviceScope.ServiceProvider.GetRequiredService(); return await syncService.ExecuteSync(); } - - private async Task SeedDb(ILexboxApi lexboxApi) - { - //id is fixed to prevent duplicates - var id = new Guid("c7328f18-118a-4f83-9d88-c408778b7f63"); - if (await lexboxApi.GetEntry(id) is null) - { - await lexboxApi.CreateEntry(new() - { - Id = id, - LexemeForm = { Values = { { "en", "Kevin" } } }, - Note = { Values = { { "en", "this is a test note from Kevin" } } }, - CitationForm = { Values = { { "en", "Kevin" } } }, - LiteralMeaning = { Values = { { "en", "Kevin" } } }, - Senses = - [ - new() - { - Gloss = { Values = { { "en", "Kevin" } } }, - Definition = { Values = { { "en", "Kevin" } } }, - SemanticDomain = ["Person"], - ExampleSentences = - [ - new() { Sentence = { Values = { { "en", "Kevin is a good guy" } } } } - ] - } - ] - }); - } - - var writingSystems = await lexboxApi.GetWritingSystems(); - if (writingSystems.Analysis.Length == 0) - { - await lexboxApi.CreateWritingSystem(WritingSystemType.Analysis, - new() - { - Id = "en", - Name = "English", - Abbreviation = "en", - Font = "Arial", - Exemplars = WritingSystem.LatinExemplars - }); - } - - if (writingSystems.Vernacular.Length == 0) - { - await lexboxApi.CreateWritingSystem(WritingSystemType.Vernacular, - new() - { - Id = "en", - Name = "English", - Abbreviation = "en", - Font = "Arial", - Exemplars = WritingSystem.LatinExemplars - }); - } - } } diff --git a/backend/LocalWebApp/CrdtHttpSyncService.cs b/backend/LocalWebApp/CrdtHttpSyncService.cs index a051bd086..649790f4c 100644 --- a/backend/LocalWebApp/CrdtHttpSyncService.cs +++ b/backend/LocalWebApp/CrdtHttpSyncService.cs @@ -13,6 +13,7 @@ public class CrdtHttpSyncService(AuthHelpersFactory authHelpersFactory, ILogger< private bool? _isHealthy; private DateTimeOffset _lastHealthCheck = DateTimeOffset.MinValue; + //todo pull this out into a service wrapped around auth helpers so that any service making requests can use it public async ValueTask ShouldSync(ISyncHttp syncHttp) { if (_isHealthy is not null && _lastHealthCheck + TimeSpan.FromMinutes(30) > DateTimeOffset.UtcNow) @@ -102,12 +103,12 @@ public interface ISyncHttp [Get("/api/AuthTesting/requires-auth")] Task HealthCheck(); - [Post("/api/sync/{id}/add")] + [Post("/api/crdt/{id}/add")] internal Task AddRange(Guid id, IEnumerable commits); - [Get("/api/sync/{id}/get")] + [Get("/api/crdt/{id}/get")] internal Task GetSyncState(Guid id); - [Post("/api/sync/{id}/changes")] + [Post("/api/crdt/{id}/changes")] internal Task> GetChanges(Guid id, SyncState otherHeads); } diff --git a/backend/LocalWebApp/HttpHelpers.cs b/backend/LocalWebApp/HttpHelpers.cs index 4bc327df3..73774c7e0 100644 --- a/backend/LocalWebApp/HttpHelpers.cs +++ b/backend/LocalWebApp/HttpHelpers.cs @@ -1,10 +1,18 @@ -namespace LocalWebApp; +using LocalWebApp.Hubs; + +namespace LocalWebApp; public static class HttpHelpers { public static string? GetProjectName(this HttpContext? context) { - var name = context?.Request.RouteValues.GetValueOrDefault(LexboxApiHub.ProjectRouteKey, null)?.ToString(); + var name = context?.Request.RouteValues.GetValueOrDefault(CrdtMiniLcmApiHub.ProjectRouteKey, null)?.ToString(); + return string.IsNullOrWhiteSpace(name) ? null : name; + } + + public static string? GetFwDataName(this HttpContext? context) + { + var name = context?.Request.RouteValues.GetValueOrDefault(FwDataMiniLcmHub.ProjectRouteKey, null)?.ToString(); return string.IsNullOrWhiteSpace(name) ? null : name; } } diff --git a/backend/LocalWebApp/LexboxApiHub.cs b/backend/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs similarity index 98% rename from backend/LocalWebApp/LexboxApiHub.cs rename to backend/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs index 502dd04fd..72c600fdb 100644 --- a/backend/LocalWebApp/LexboxApiHub.cs +++ b/backend/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs @@ -4,14 +4,14 @@ using MiniLcm; using SystemTextJsonPatch; -namespace LocalWebApp; +namespace LocalWebApp.Hubs; public interface ILexboxClient { Task OnEntryUpdated(Entry entry); } -public class LexboxApiHub( +public class CrdtMiniLcmApiHub( ILexboxApi lexboxApi, IOptions jsonOptions, BackgroundSyncService backgroundSyncService, diff --git a/backend/LocalWebApp/Hubs/FwDataMiniLcmHub.cs b/backend/LocalWebApp/Hubs/FwDataMiniLcmHub.cs new file mode 100644 index 000000000..3c5527320 --- /dev/null +++ b/backend/LocalWebApp/Hubs/FwDataMiniLcmHub.cs @@ -0,0 +1,118 @@ +using FwDataMiniLcmBridge; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; +using MiniLcm; +using SystemTextJsonPatch; + +namespace LocalWebApp.Hubs; + +public class FwDataMiniLcmHub([FromKeyedServices(FwDataBridgeKernel.FwDataApiKey)] ILexboxApi lexboxApi) : Hub +{ + public const string ProjectRouteKey = "fwdata"; + public override async Task OnConnectedAsync() + { + } + + public async Task GetWritingSystems() + { + return await lexboxApi.GetWritingSystems(); + } + + public async Task CreateWritingSystem(WritingSystemType type, WritingSystem writingSystem) + { + var newWritingSystem = await lexboxApi.CreateWritingSystem(type, writingSystem); + return newWritingSystem; + } + + public async Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, JsonPatchDocument update) + { + var writingSystem = await lexboxApi.UpdateWritingSystem(id, type, new JsonPatchUpdateInput(update)); + return writingSystem; + } + + public IAsyncEnumerable GetEntriesForExemplar(string exemplar, QueryOptions? options = null) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable GetEntries(QueryOptions? options = null) + { + return lexboxApi.GetEntries(options); + } + + public IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null) + { + return lexboxApi.SearchEntries(query, options); + } + + public async Task GetEntry(Guid id) + { + return await lexboxApi.GetEntry(id); + } + + public async Task CreateEntry(Entry entry) + { + var newEntry = await lexboxApi.CreateEntry(entry); + await NotifyEntryUpdated(newEntry); + return newEntry; + } + + public async Task UpdateEntry(Guid id, JsonPatchDocument update) + { + var entry = await lexboxApi.UpdateEntry(id, new JsonPatchUpdateInput(update)); + await NotifyEntryUpdated(entry); + return entry; + } + + public async Task DeleteEntry(Guid id) + { + await lexboxApi.DeleteEntry(id); + } + + public async Task CreateSense(Guid entryId, Sense sense) + { + var createdSense = await lexboxApi.CreateSense(entryId, sense); + return createdSense; + } + + public async Task UpdateSense(Guid entryId, Guid senseId, JsonPatchDocument update) + { + var sense = await lexboxApi.UpdateSense(entryId, senseId, new JsonPatchUpdateInput(update)); + return sense; + } + + public async Task DeleteSense(Guid entryId, Guid senseId) + { + await lexboxApi.DeleteSense(entryId, senseId); + } + + public async Task CreateExampleSentence(Guid entryId, + Guid senseId, + ExampleSentence exampleSentence) + { + var createdSentence = await lexboxApi.CreateExampleSentence(entryId, senseId, exampleSentence); + return createdSentence; + } + + public async Task UpdateExampleSentence(Guid entryId, + Guid senseId, + Guid exampleSentenceId, + JsonPatchDocument update) + { + var sentence = await lexboxApi.UpdateExampleSentence(entryId, + senseId, + exampleSentenceId, + new JsonPatchUpdateInput(update)); + return sentence; + } + + public async Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) + { + await lexboxApi.DeleteExampleSentence(entryId, senseId, exampleSentenceId); + } + + private async Task NotifyEntryUpdated(Entry entry) + { + await Clients.Others.OnEntryUpdated(entry); + } +} diff --git a/backend/LocalWebApp/LocalAppKernel.cs b/backend/LocalWebApp/LocalAppKernel.cs index 03e744710..421def69b 100644 --- a/backend/LocalWebApp/LocalAppKernel.cs +++ b/backend/LocalWebApp/LocalAppKernel.cs @@ -1,6 +1,8 @@ using System.Text.Json; using Crdt; +using FwDataMiniLcmBridge; using LcmCrdt; +using LocalWebApp.Services; using LocalWebApp.Auth; using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.SignalR; @@ -11,16 +13,20 @@ namespace LocalWebApp; public static class LocalAppKernel { - public static void AddLocalAppServices(this IServiceCollection services, IHostEnvironment environment) + public static IServiceCollection AddLocalAppServices(this IServiceCollection services, IHostEnvironment environment) { services.AddHttpContextAccessor(); services.AddHttpClient(); services.AddAuthHelpers(environment); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); services.AddLcmCrdtClient(); + services.AddFwDataBridge(); + services.AddOptions().PostConfigure>((jsonOptions, crdtConfig) => { jsonOptions.SerializerOptions.TypeInfoResolver = crdtConfig.Value.MakeJsonTypeResolver(); @@ -41,6 +47,7 @@ public static void AddLocalAppServices(this IServiceCollection services, IHostEn }) }); services.AddSingleton(); + return services; } private static void AddAuthHelpers(this IServiceCollection services, IHostEnvironment environment) diff --git a/backend/LocalWebApp/LocalWebApp.csproj b/backend/LocalWebApp/LocalWebApp.csproj index 17f58681d..f103c49ab 100644 --- a/backend/LocalWebApp/LocalWebApp.csproj +++ b/backend/LocalWebApp/LocalWebApp.csproj @@ -29,6 +29,7 @@ + diff --git a/backend/LocalWebApp/LocalWebApp.http b/backend/LocalWebApp/LocalWebApp.http index 09e2099db..1e49aa754 100644 --- a/backend/LocalWebApp/LocalWebApp.http +++ b/backend/LocalWebApp/LocalWebApp.http @@ -1,6 +1,7 @@ -@LocalWebApp_HostAddress = http://localhost:5137 +@LocalWebApp_HostAddress = http://localhost:5173 -GET {{LocalWebApp_HostAddress}}/weatherforecast/ +GET {{LocalWebApp_HostAddress}}/api/auth/login/default Accept: application/json + ### diff --git a/backend/LocalWebApp/Program.cs b/backend/LocalWebApp/Program.cs index cf6c481b5..2f88cf432 100644 --- a/backend/LocalWebApp/Program.cs +++ b/backend/LocalWebApp/Program.cs @@ -1,5 +1,8 @@ +using FwDataMiniLcmBridge; +using FwDataMiniLcmBridge.LcmUtils; using LcmCrdt; using LocalWebApp; +using LocalWebApp.Hubs; using LocalWebApp.Auth; using LocalWebApp.Routes; using LocalWebApp.Utils; @@ -9,8 +12,14 @@ var builder = WebApplication.CreateBuilder(args); if (!builder.Environment.IsDevelopment()) builder.WebHost.UseUrls("http://127.0.0.1:0"); +if (builder.Environment.IsDevelopment()) +{ + //do this early so we catch bugs on startup + ProjectLoader.Init(); +} builder.ConfigureDev(config => config.DefaultAuthority = new("https://lexbox.dev.languagetechnology.org")); -builder.ConfigureProd(config => config.DefaultAuthority = new("https://lexbox.org")); +//for now prod builds will also use lt dev until we deploy oauth to prod +builder.ConfigureProd(config => config.DefaultAuthority = new("https://lexbox.dev.languagetechnology.org")); builder.Services.Configure(c => c.ClientId = "becf2856-0690-434b-b192-a4032b72067f"); builder.Services.AddLocalAppServices(builder.Environment); @@ -42,12 +51,22 @@ await context.RequestServices.GetRequiredService().PopulateProjectDataCache(); } + var fwData = context.GetFwDataName(); + if (!string.IsNullOrWhiteSpace(fwData)) + { + var fwDataProjectContext = context.RequestServices.GetRequiredService(); + fwDataProjectContext.Project = FieldWorksProjectList.GetProject(fwData) ?? throw new InvalidOperationException($"FwData {fwData} not found"); + } + await next(context); }); -app.MapHub($"/api/hub/{{{LexboxApiHub.ProjectRouteKey}}}/lexbox"); +app.MapHub($"/api/hub/{{{CrdtMiniLcmApiHub.ProjectRouteKey}}}/lexbox"); +app.MapHub($"/api/hub/{{{FwDataMiniLcmHub.ProjectRouteKey}}}/fwdata"); app.MapHistoryRoutes(); app.MapActivities(); app.MapProjectRoutes(); +app.MapTest(); +app.MapImport(); app.MapAuthRoutes(); await using (app) diff --git a/backend/LocalWebApp/Routes/ActivityRoutes.cs b/backend/LocalWebApp/Routes/ActivityRoutes.cs index 9c3f02439..f1526a30e 100644 --- a/backend/LocalWebApp/Routes/ActivityRoutes.cs +++ b/backend/LocalWebApp/Routes/ActivityRoutes.cs @@ -1,6 +1,7 @@ using Crdt.Changes; using Crdt.Core; using Crdt.Db; +using LocalWebApp.Hubs; using Microsoft.EntityFrameworkCore; using Microsoft.OpenApi.Models; @@ -14,7 +15,7 @@ public static IEndpointConventionBuilder MapActivities(this WebApplication app) { operation.Parameters.Add(new OpenApiParameter() { - Name = LexboxApiHub.ProjectRouteKey, + Name = CrdtMiniLcmApiHub.ProjectRouteKey, In = ParameterLocation.Path, Required = true }); diff --git a/backend/LocalWebApp/Routes/AuthRoutes.cs b/backend/LocalWebApp/Routes/AuthRoutes.cs index 469268b6d..5f98d80bb 100644 --- a/backend/LocalWebApp/Routes/AuthRoutes.cs +++ b/backend/LocalWebApp/Routes/AuthRoutes.cs @@ -1,4 +1,4 @@ -using System.Security.AccessControl; +using System.Security.AccessControl; using System.Web; using LocalWebApp.Auth; diff --git a/backend/LocalWebApp/Routes/HistoryRoutes.cs b/backend/LocalWebApp/Routes/HistoryRoutes.cs index e59afea59..09f6236b9 100644 --- a/backend/LocalWebApp/Routes/HistoryRoutes.cs +++ b/backend/LocalWebApp/Routes/HistoryRoutes.cs @@ -5,6 +5,7 @@ using Crdt.Entities; using LinqToDB; using LinqToDB.EntityFrameworkCore; +using LocalWebApp.Hubs; using Microsoft.OpenApi.Models; namespace LocalWebApp.Routes; @@ -17,7 +18,7 @@ public static IEndpointConventionBuilder MapHistoryRoutes(this WebApplication ap { operation.Parameters.Add(new OpenApiParameter() { - Name = LexboxApiHub.ProjectRouteKey, In = ParameterLocation.Path, Required = true + Name = CrdtMiniLcmApiHub.ProjectRouteKey, In = ParameterLocation.Path, Required = true }); return operation; }); diff --git a/backend/LocalWebApp/Routes/ImportRoutes.cs b/backend/LocalWebApp/Routes/ImportRoutes.cs new file mode 100644 index 000000000..7d43edf4d --- /dev/null +++ b/backend/LocalWebApp/Routes/ImportRoutes.cs @@ -0,0 +1,17 @@ + using Crdt.Db; + using LocalWebApp.Services; + using Microsoft.OpenApi.Models; +using MiniLcm; + +namespace LocalWebApp.Routes; + +public static class ImportRoutes +{ + public static IEndpointConventionBuilder MapImport(this WebApplication app) + { + var group = app.MapGroup("/api/import"); + group.MapPost("/fwdata/{fwDataProjectName}", + async (string fwDataProjectName, ImportFwdataService importService) => await importService.Import(fwDataProjectName)); + return group; + } +} diff --git a/backend/LocalWebApp/Routes/ProjectRoutes.cs b/backend/LocalWebApp/Routes/ProjectRoutes.cs index 5a569f16c..00630fc58 100644 --- a/backend/LocalWebApp/Routes/ProjectRoutes.cs +++ b/backend/LocalWebApp/Routes/ProjectRoutes.cs @@ -1,21 +1,51 @@ using System.Text.RegularExpressions; +using FwDataMiniLcmBridge; using LcmCrdt; using LocalWebApp.Auth; +using LocalWebApp.Hubs; +using LocalWebApp.Services; +using Microsoft.Extensions.Options; using MiniLcm; namespace LocalWebApp.Routes; -public static class ProjectRoutes +public static partial class ProjectRoutes { public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication app) { var group = app.MapGroup("/api").WithOpenApi(); group.MapGet("/projects", - async (ProjectsService projectService) => + async (ProjectsService projectService, LexboxProjectService lexboxProjectService) => + { + var crdtProjects = await projectService.ListProjects(); + var projects = crdtProjects.ToDictionary(p => p.Name, p => new ProjectModel(p.Name, true, false)); + //basically populate projects and indicate if they are lexbox or fwdata + foreach (var p in FieldWorksProjectList.EnumerateProjects()) { - return await projectService.ListProjects(); - }); - Regex alphaNumericRegex = new Regex("^[a-zA-Z0-9]*$"); + if (projects.TryGetValue(p.Name, out var project)) + { + projects[p.Name] = project with { Fwdata = true }; + } + else + { + projects.Add(p.Name, new ProjectModel(p.Name, false, true)); + } + } + //todo split this out into it's own request so we can return other project types right away + foreach (var lexboxProject in await lexboxProjectService.GetLexboxProjects()) + { + if (projects.TryGetValue(lexboxProject.Name, out var project)) + { + projects[lexboxProject.Name] = project with { Lexbox = true }; + } + else + { + projects.Add(lexboxProject.Name, new ProjectModel(lexboxProject.Name, false, false, true)); + } + } + + return projects.Values; + }); group.MapPost("/project", async (ProjectsService projectService, string name) => { @@ -23,32 +53,68 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap return Results.BadRequest("Project name is required"); if (projectService.ProjectExists(name)) return Results.BadRequest("Project already exists"); - if (!alphaNumericRegex.IsMatch(name)) - return Results.BadRequest("Project name must be alphanumeric"); + if (!ProjectName().IsMatch(name)) + return Results.BadRequest("Only letters, numbers, '-' and '_' are allowed"); await projectService.CreateProject(name, afterCreate: AfterCreate); return TypedResults.Ok(); }); + group.MapPost($"/upload/crdt/{{{CrdtMiniLcmApiHub.ProjectRouteKey}}}", + async (LexboxProjectService lexboxProjectService, + SyncService syncService, + IOptions options, + CurrentProjectService currentProjectService) => + { + //todo let the user pick a project to upload to instead of matching the name with the project code. + var foundProjectGuid = + await lexboxProjectService.GetLexboxProjectId(currentProjectService.ProjectData.Name); + if (foundProjectGuid is null) + return Results.BadRequest( + $"Project code {currentProjectService.ProjectData.Name} not found on lexbox"); + await currentProjectService.SetProjectSyncOrigin(options.Value.DefaultAuthority, foundProjectGuid); + await syncService.ExecuteSync(); + return TypedResults.Ok(); + }); + group.MapPost("/download/crdt/{newProjectName}", + async (LexboxProjectService lexboxProjectService, + IOptions options, + ProjectsService projectService, + string newProjectName + ) => + { + if (!ProjectName().IsMatch(newProjectName)) + return Results.BadRequest("Project name is invalid"); + var foundProjectGuid = await lexboxProjectService.GetLexboxProjectId(newProjectName); + if (foundProjectGuid is null) + return Results.BadRequest($"Project code {newProjectName} not found on lexbox"); + await projectService.CreateProject(newProjectName, foundProjectGuid.Value, options.Value.DefaultAuthority, + async (provider, project) => + { + await provider.GetRequiredService().ExecuteSync(); + }); + return TypedResults.Ok(); + }); return group; } + public record ProjectModel(string Name, bool Crdt, bool Fwdata, bool Lexbox = false); + private static async Task AfterCreate(IServiceProvider provider, CrdtProject project) { var lexboxApi = provider.GetRequiredService(); await lexboxApi.CreateEntry(new() { Id = Guid.NewGuid(), - LexemeForm = { Values = { { "en", "Kevin" } } }, - Note = { Values = { { "en", "this is a test note from Kevin" } } }, - CitationForm = { Values = { { "en", "Kevin" } } }, - LiteralMeaning = { Values = { { "en", "Kevin" } } }, + LexemeForm = { Values = { { "en", "Apple" } } }, + CitationForm = { Values = { { "en", "Apple" } } }, + LiteralMeaning = { Values = { { "en", "Fruit" } } }, Senses = [ new() { - Gloss = { Values = { { "en", "Kevin" } } }, - Definition = { Values = { { "en", "Kevin" } } }, - SemanticDomain = ["Person"], - ExampleSentences = [new() { Sentence = { Values = { { "en", "Kevin is a good guy" } } } }] + Gloss = { Values = { { "en", "Fruit" } } }, + Definition = { Values = { { "en", "fruit with red, yellow, or green skin with a sweet or tart crispy white flesh" } } }, + SemanticDomain = ["Fruit"], + ExampleSentences = [new() { Sentence = { Values = { { "en", "We ate an apple" } } } }] } ] }); @@ -73,4 +139,7 @@ await lexboxApi.CreateWritingSystem(WritingSystemType.Analysis, Exemplars = WritingSystem.LatinExemplars }); } + + [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")] + private static partial Regex ProjectName(); } diff --git a/backend/LocalWebApp/Routes/TestRoutes.cs b/backend/LocalWebApp/Routes/TestRoutes.cs new file mode 100644 index 000000000..70383654c --- /dev/null +++ b/backend/LocalWebApp/Routes/TestRoutes.cs @@ -0,0 +1,33 @@ +using Crdt.Core; +using Crdt.Db; +using LocalWebApp.Hubs; +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; +using MiniLcm; +using Entry = LcmCrdt.Objects.Entry; + +namespace LocalWebApp.Routes; + +public static class TestRoutes +{ + public static IEndpointConventionBuilder MapTest(this WebApplication app) + { + var group = app.MapGroup("/api/test/{project}").WithOpenApi(operation => + { + operation.Parameters.Add(new OpenApiParameter() + { + Name = CrdtMiniLcmApiHub.ProjectRouteKey, + In = ParameterLocation.Path, + Required = true + }); + return operation; + }); + group.MapGet("/entries", + (CrdtDbContext dbContext, ILexboxApi api) => + { + return api.GetEntries(); + return dbContext.Set().Take(1000).AsAsyncEnumerable(); + }); + return group; + } +} diff --git a/backend/LocalWebApp/Services/ImportFwdataService.cs b/backend/LocalWebApp/Services/ImportFwdataService.cs new file mode 100644 index 000000000..5b9cb3b0a --- /dev/null +++ b/backend/LocalWebApp/Services/ImportFwdataService.cs @@ -0,0 +1,59 @@ +using FwDataMiniLcmBridge; +using FwDataMiniLcmBridge.Api; +using FwDataMiniLcmBridge.LcmUtils; +using LcmCrdt; +using MiniLcm; + +namespace LocalWebApp.Services; + +public class ImportFwdataService(ProjectsService projectsService, ILogger logger, FwDataFactory fwDataFactory) +{ + public async Task Import(string projectName) + { + var fwDataProject = FieldWorksProjectList.GetProject(projectName); + if (fwDataProject is null) + { + throw new InvalidOperationException($"Project {projectName} not found."); + } + using var fwDataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, false); + var project = await projectsService.CreateProject(fwDataProject.Name, + afterCreate: async (provider, project) => + { + var crdtApi = provider.GetRequiredService(); + await ImportProject(crdtApi, fwDataApi, fwDataApi.EntryCount); + }); + logger.LogInformation("Import of {ProjectName} complete!", fwDataApi.Project.Name); + return project; + } + + async Task ImportProject(ILexboxApi importTo, ILexboxApi importFrom, int entryCount) + { + var writingSystems = await importFrom.GetWritingSystems(); + foreach (var ws in writingSystems.Analysis) + { + await importTo.CreateWritingSystem(WritingSystemType.Analysis, ws); + logger.LogInformation("Imported ws {WsId}", ws.Id); + } + + foreach (var ws in writingSystems.Vernacular) + { + await importTo.CreateWritingSystem(WritingSystemType.Vernacular, ws); + logger.LogInformation("Imported ws {WsId}", ws.Id); + } + + var index = 0; + await foreach (var entry in importFrom.GetEntries(new QueryOptions(Count: 100_000, Offset: 0))) + { + if (importTo is CrdtLexboxApi crdtLexboxApi) + { + await crdtLexboxApi.CreateEntryLite(entry); + } + else + { + await importTo.CreateEntry(entry); + } + + logger.LogInformation("Imported entry, {Index} of {Count} {Id}", index++, entryCount, entry.Id); + } + } +} diff --git a/backend/LocalWebApp/Services/LexboxProjectService.cs b/backend/LocalWebApp/Services/LexboxProjectService.cs new file mode 100644 index 000000000..c8abc02be --- /dev/null +++ b/backend/LocalWebApp/Services/LexboxProjectService.cs @@ -0,0 +1,38 @@ +using LocalWebApp.Auth; + +namespace LocalWebApp.Services; + +public class LexboxProjectService(AuthHelpersFactory helpersFactory, ILogger logger) +{ + public record LexboxCrdtProject(Guid Id, string Name); + + public async Task GetLexboxProjects() + { + var httpClient = await helpersFactory.GetDefault().CreateClient(); + if (httpClient is null) return []; + try + { + return await httpClient.GetFromJsonAsync("api/crdt/listProjects") ?? []; + } + catch (HttpRequestException e) + { + logger.LogError(e, "Error getting lexbox projects"); + return []; + } + } + + public async Task GetLexboxProjectId(string code) + { + var httpClient = await helpersFactory.GetDefault().CreateClient(); + if (httpClient is null) return null; + try + { + return (await httpClient.GetFromJsonAsync($"api/crdt/lookupProjectId?code={code}")); + } + catch (HttpRequestException e) + { + logger.LogError(e, "Error getting lexbox project id"); + return null; + } + } +} diff --git a/backend/LocalWebApp/Utils/CancellationTokenExtensions.cs b/backend/LocalWebApp/Utils/CancellationTokenExtensions.cs new file mode 100644 index 000000000..35da0129f --- /dev/null +++ b/backend/LocalWebApp/Utils/CancellationTokenExtensions.cs @@ -0,0 +1,10 @@ +namespace LocalWebApp.Utils; + +public static class CancellationTokenExtensions +{ + public static CancellationToken Merge(this CancellationToken token1, CancellationToken token2) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(token1, token2); + return cts.Token; + } +} diff --git a/backend/MiniLcm/MultiString.cs b/backend/MiniLcm/MultiString.cs index 8946807a2..6f7e96e1f 100644 --- a/backend/MiniLcm/MultiString.cs +++ b/backend/MiniLcm/MultiString.cs @@ -14,13 +14,13 @@ public class MultiString: IDictionary { public MultiString() { - _values = new MultiStringDict(); + Values = new MultiStringDict(); } [JsonConstructor] public MultiString(IDictionary values) { - _values = new MultiStringDict(values); + Values = new MultiStringDict(values); } public virtual MultiString Copy() @@ -28,13 +28,12 @@ public virtual MultiString Copy() return new(Values); } - private readonly MultiStringDict _values; - public virtual IDictionary Values => _values; + public virtual IDictionary Values { get; } public string this[WritingSystemId key] { - get => _values[key]; - set => _values[key] = value; + get => Values[key]; + set => Values[key] = value; } private class MultiStringDict : Dictionary, #pragma warning disable CS8644 // Type does not implement interface member. Nullability of reference types in interface implemented by the base type doesn't match. @@ -79,42 +78,42 @@ void IDictionary.Add(object key, object? value) void IDictionary.Add(object key, object? value) { - ((IDictionary)_values).Add(key, value); + ((IDictionary)Values).Add(key, value); } void IDictionary.Clear() { - ((IDictionary)_values).Clear(); + ((IDictionary)Values).Clear(); } bool IDictionary.Contains(object key) { - return ((IDictionary)_values).Contains(key); + return ((IDictionary)Values).Contains(key); } IDictionaryEnumerator IDictionary.GetEnumerator() { - return ((IDictionary)_values).GetEnumerator(); + return ((IDictionary)Values).GetEnumerator(); } void IDictionary.Remove(object key) { - ((IDictionary)_values).Remove(key); + ((IDictionary)Values).Remove(key); } - bool IDictionary.IsFixedSize => ((IDictionary)_values).IsFixedSize; + bool IDictionary.IsFixedSize => ((IDictionary)Values).IsFixedSize; - bool IDictionary.IsReadOnly => ((IDictionary)_values).IsReadOnly; + bool IDictionary.IsReadOnly => ((IDictionary)Values).IsReadOnly; object? IDictionary.this[object key] { - get => ((IDictionary)_values)[key]; - set => ((IDictionary)_values)[key] = value; + get => ((IDictionary)Values)[key]; + set => ((IDictionary)Values)[key] = value; } - ICollection IDictionary.Keys => ((IDictionary)_values).Keys; + ICollection IDictionary.Keys => ((IDictionary)Values).Keys; - ICollection IDictionary.Values => ((IDictionary)_values).Values; + ICollection IDictionary.Values => ((IDictionary)Values).Values; IEnumerator IEnumerable.GetEnumerator() { @@ -123,14 +122,14 @@ IEnumerator IEnumerable.GetEnumerator() void ICollection.CopyTo(Array array, int index) { - ((IDictionary)_values).CopyTo(array, index); + ((IDictionary)Values).CopyTo(array, index); } - int ICollection.Count => ((IDictionary)_values).Count; + int ICollection.Count => ((IDictionary)Values).Count; - bool ICollection.IsSynchronized => ((IDictionary)_values).IsSynchronized; + bool ICollection.IsSynchronized => ((IDictionary)Values).IsSynchronized; - object ICollection.SyncRoot => ((IDictionary)_values).SyncRoot; + object ICollection.SyncRoot => ((IDictionary)Values).SyncRoot; } public static class MultiStringExtensions diff --git a/backend/Taskfile.yml b/backend/Taskfile.yml index 21ac8a733..c0e4637ae 100644 --- a/backend/Taskfile.yml +++ b/backend/Taskfile.yml @@ -91,14 +91,14 @@ tasks: cmds: - kubectl port-forward service/db 27018:27017 -n languageforge --context dallas-rke - local-web-app: + local-web-app-for-develop: label: dotnet dir: ./LocalWebApp cmd: dotnet watch --no-hot-reload - local-web-app-with-local-lexbox: + local-web-app: label: Run LocalWebApp with Local LexBox env: - Auth__DefaultAuthority: "https://localhost:3000" + Auth__DefaultAuthority: "https://localhost:3050" dir: ./LocalWebApp cmd: dotnet watch --no-hot-reload diff --git a/backend/harmony b/backend/harmony index 26d825dd8..41d6c7f9f 160000 --- a/backend/harmony +++ b/backend/harmony @@ -1 +1 @@ -Subproject commit 26d825dd8509a4a0793f71cc17ff367327599363 +Subproject commit 41d6c7f9fb04620793a82b3926472a193dfb1789 diff --git a/deployment/local-dev/app-config.yaml b/deployment/local-dev/app-config.yaml new file mode 100644 index 000000000..5a08624a5 --- /dev/null +++ b/deployment/local-dev/app-config.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config +data: + enable-oauth: "true" diff --git a/deployment/local-dev/ingress-config.patch.yaml b/deployment/local-dev/ingress-config.patch.yaml index e4678f4d5..a035e1fa2 100644 --- a/deployment/local-dev/ingress-config.patch.yaml +++ b/deployment/local-dev/ingress-config.patch.yaml @@ -1,7 +1,7 @@ -- op: replace - path: /spec/tls/0/secretName - value: null -- op: add +- op: add # ~1 gets replaced with a / in the path path: /metadata/annotations/nginx.ingress.kubernetes.io~1ssl-redirect value: "false" +- op: add + path: /metadata/annotations/cert-manager.io~1cluster-issuer + value: selfsigned-issuer diff --git a/deployment/local-dev/kustomization.yaml b/deployment/local-dev/kustomization.yaml index da53d7e91..37d03473b 100644 --- a/deployment/local-dev/kustomization.yaml +++ b/deployment/local-dev/kustomization.yaml @@ -8,6 +8,7 @@ resources: - ingress-deployment.yaml - db-secrets.yaml - lf-classic-secrets.yaml +- self-signed-ssl.yaml components: - ../init-repos @@ -36,6 +37,7 @@ patches: kind: Certificate path: delete-oauth-certs.yaml + - path: app-config.yaml - path: lexbox-deployment.patch.yaml - path: ui-deployment.patch.yaml - path: hg-repos-pvc.patch.yaml diff --git a/deployment/local-dev/self-signed-ssl.yaml b/deployment/local-dev/self-signed-ssl.yaml new file mode 100644 index 000000000..2ed283b50 --- /dev/null +++ b/deployment/local-dev/self-signed-ssl.yaml @@ -0,0 +1,6 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: selfsigned-issuer +spec: + selfSigned: { } diff --git a/frontend/Taskfile.yml b/frontend/Taskfile.yml index b5bee46f7..e72e0c6a6 100644 --- a/frontend/Taskfile.yml +++ b/frontend/Taskfile.yml @@ -56,3 +56,20 @@ tasks: dir: ./viewer deps: [ install-viewer ] cmd: pnpm run dev-app + + + install-https-proxy: + dir: ./https-proxy + method: checksum + sources: + - package.json + cmds: + - corepack enable || true + - pnpm install + https-proxy: + dir: ./https-proxy + desc: "MSAL requires the oauth authority to be available over https. That's why this is here. As a bonus it dynamically looks for the UI either locally or in k8s." + aliases: [ https-oauth-authority ] + deps: [ install-https-proxy ] + cmd: pnpm run dev + diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 739761c65..2e376186d 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -20,7 +20,8 @@ export default [ 'playwright.config.ts', '.svelte-kit/**', '**/generated/**', - 'viewer/' + 'viewer/', + 'https-proxy/', ], }, js.configs.recommended, diff --git a/frontend/https-proxy/.gitignore b/frontend/https-proxy/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/frontend/https-proxy/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/https-proxy/package.json b/frontend/https-proxy/package.json new file mode 100644 index 000000000..2e333b1be --- /dev/null +++ b/frontend/https-proxy/package.json @@ -0,0 +1,15 @@ +{ + "name": "https-proxy", + "version": "0.0.1", + "private": true, + "packageManager": "pnpm@8.15.1", + "type": "module", + "scripts": { + "dev": "vite" + }, + "devDependencies": { + "typescript": "^5.2.2", + "vite": "^5.2.0", + "@vitejs/plugin-basic-ssl": "^1.1.0" + } +} diff --git a/frontend/https-proxy/vite.config.ts b/frontend/https-proxy/vite.config.ts new file mode 100644 index 000000000..79b3c0227 --- /dev/null +++ b/frontend/https-proxy/vite.config.ts @@ -0,0 +1,64 @@ +import basicSsl from '@vitejs/plugin-basic-ssl'; +import {defineConfig, type ProxyOptions} from 'vite'; +import http from 'http'; + +async function checkTargetAvailability(url: string): Promise { + return new Promise((resolve) => { + const req = http.get(url, (res) => { + resolve(!!res.statusCode && res.statusCode < 400); + }); + + req.on('error', () => { + resolve(false); + }); + + req.end(); + }); +} + +const targets = ['http://localhost:3000', 'http://localhost']; + +const lexboxServer: ProxyOptions = { + target: targets[0], + secure: false, + changeOrigin: false, + autoRewrite: true, + protocolRewrite: 'https', + headers: { + 'x-forwarded-proto': 'https', + }, + configure: async (proxy, options) => { + let availableTarget: string | undefined = undefined; + + proxy.on('proxyReq', function () { + if (!availableTarget) console.warn(`Request before target (${lexboxServer.target}) was confirmed to be available.`); + }); + + while (!availableTarget) { + for (const target of targets) { + const isAvailable = await checkTargetAvailability(target); + if (isAvailable) { + options.target = availableTarget = target; + console.log('Will proxy to available target:', target); + return; + } + } + console.warn('No target available, retrying in 5s'); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + }, +}; + +export default defineConfig({ + plugins: [ + basicSsl(), + ], + server: { + port: 3050, + host: true, + strictPort: true, + proxy: { + '/': lexboxServer, + } + }, +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e43a78222..1fed2c423 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -223,6 +223,18 @@ importers: specifier: ^4.4.2 version: 4.4.2 + https-proxy: + devDependencies: + '@vitejs/plugin-basic-ssl': + specifier: ^1.1.0 + version: 1.1.0(vite@5.2.13) + typescript: + specifier: ^5.2.2 + version: 5.3.3 + vite: + specifier: ^5.2.0 + version: 5.2.13(@types/node@20.12.12) + viewer: dependencies: '@microsoft/dotnet-js-interop': @@ -247,8 +259,8 @@ importers: specifier: ^2.12.0 version: 2.13.0 svelte-ux: - specifier: ^0.66.4 - version: 0.66.7(@babel/core@7.24.7)(postcss@8.4.38)(svelte@4.2.17) + specifier: ^0.66.8 + version: 0.66.8(@babel/core@7.24.7)(postcss@8.4.38)(svelte@4.2.17) type-fest: specifier: ^4.18.2 version: 4.18.2 @@ -4506,7 +4518,6 @@ packages: vite: ^3.0.0 || ^4.0.0 || ^5.0.0 dependencies: vite: 5.2.13(@types/node@20.12.12) - dev: false /@vitest/expect@1.6.0: resolution: {integrity: sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==} @@ -9597,8 +9608,8 @@ packages: turnstile-types: 1.2.0 dev: true - /svelte-ux@0.66.7(@babel/core@7.24.7)(postcss@8.4.38)(svelte@4.2.17): - resolution: {integrity: sha512-x4so3Aoj4wK7ZmhBYuq/Al2jq3dPWAvGvSSZy47Tux3YhF6vRB/aseDASqVKOl6gMSo9VuB79XzJOpFFl82O7w==} + /svelte-ux@0.66.8(@babel/core@7.24.7)(postcss@8.4.38)(svelte@4.2.17): + resolution: {integrity: sha512-PaDLLSHnktN1uv/Viu8joHidvQ2UPh3AEB8JxeWgGH/GPwirYfa7jAferad+TMrv5Cvp9V124T559ySaLThxJg==} peerDependencies: svelte: ^3.56.0 || ^4.0.0 || ^5.0.0-next.120 dependencies: diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 79ed274d5..9616a464a 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,3 +1,3 @@ packages: - - 'frontend' + - 'https-proxy' - 'viewer' diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index 675906af3..7ae3f6c34 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -45,7 +45,7 @@ "postcss": "^8.4.38", "svelte-preprocess": "^5.1.4", "svelte-routing": "^2.12.0", - "svelte-ux": "^0.66.4", + "svelte-ux": "^0.66.8", "type-fest": "^4.18.2" } } diff --git a/frontend/viewer/src/App.svelte b/frontend/viewer/src/App.svelte index 30ed431d4..cd9fe9df9 100644 --- a/frontend/viewer/src/App.svelte +++ b/frontend/viewer/src/App.svelte @@ -1,37 +1,12 @@  @@ -43,44 +18,19 @@ {/key} + + {#key params.name} + + {/key} + - - - - - - - {#if username} -

Logged in as {username}

- - {:else} - - {/if} -
- -
- {#await projectsPromise} -

loading...

- {:then projects} - {#each projects as project} - navigate(`/project/${project.name}`)}> - - {/each} - navigate('/testing/project-view')}/> - {/await} -
-
+ +
+ + {setTimeout(() => navigate("/", { replace: true }))}
diff --git a/frontend/viewer/src/CrdtProjectView.svelte b/frontend/viewer/src/CrdtProjectView.svelte index a7f0d8b81..ca9fc1585 100644 --- a/frontend/viewer/src/CrdtProjectView.svelte +++ b/frontend/viewer/src/CrdtProjectView.svelte @@ -15,7 +15,10 @@ .catch(err => console.error(err)); onDestroy(() => connection.stop()); setContext('project-name', projectName); - SetupSignalR(connection); + SetupSignalR(connection, { + history: true, + write: true, + }); let connected = false; diff --git a/frontend/viewer/src/FwDataProjectView.svelte b/frontend/viewer/src/FwDataProjectView.svelte new file mode 100644 index 000000000..3b7fff518 --- /dev/null +++ b/frontend/viewer/src/FwDataProjectView.svelte @@ -0,0 +1,24 @@ + + diff --git a/frontend/viewer/src/HomeView.svelte b/frontend/viewer/src/HomeView.svelte new file mode 100644 index 000000000..dcf49bf7e --- /dev/null +++ b/frontend/viewer/src/HomeView.svelte @@ -0,0 +1,218 @@ + + +
+
+ + + + + + {#if loggedIn} +

{username}

+ + {:else} + + {/if} +
+ +
+ +
+ {#await projectsPromise} +

loading...

+ {:then projects} + + + + {#each data ?? [] as rowData, rowIndex} + + {#each columns as column (column.name)} + {@const value = getCellValue(column, rowData, rowIndex)} + + + {/each} + + {/each} + +
+ {#if column.name === "fwdata"} + {#if rowData.fwdata} + + {/if} + {:else if column.name === "lexbox"} + {#if rowData.lexbox && !rowData.crdt} + + {:else if !rowData.lexbox && rowData.crdt && loggedIn} + + {:else if rowData.lexbox && rowData.crdt} + + {/if} + {:else if column.name === "crdt"} + {#if rowData.crdt} + + {:else if rowData.fwdata} + + {/if} + {:else} + {getCellContent(column, rowData, rowIndex)} + {/if} +
+ {/await} + + navigate('/testing/project-view')}/> +
+
+ +
diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index 367f912ca..d8ea67ca4 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -73,16 +73,24 @@ trigger.update(t => t + 1); } - const entries = deriveAsync(derived([search, connected, selectedIndexExemplar, trigger], s => s), ([s, isConnected, exemplar]) => { + const _entries = deriveAsync(derived([search, connected, selectedIndexExemplar, trigger], s => s), ([s, isConnected, exemplar]) => { return fetchEntries(s, isConnected, exemplar); }, undefined, 200); + // TODO: replace with either + // 1 something like setContext('editorEntry') that even includes unsaved changes + // 2 somehow use selectedEntry in components that need to refresh on changes + // 3 combine 1 into 2 + // Used for triggering rerendering when display values of the current entry change (e.g. the headword in the list view) + const entries = writable(); + $: $entries = $_entries; + function fetchEntries(s: string, isConnected: boolean, exemplar: string | undefined) { if (!isConnected) return Promise.resolve([]); return lexboxApi.SearchEntries(s ?? '', { offset: 0, // we always load full exampelar lists for now, so we can guaruntee that the selected entry is in the list - count: exemplar ? Infinity : 1000, + count: exemplar ? 1_000_000_000 : 1000, order: {field: 'headword', writingSystem: 'default'}, exemplar: exemplar ? {value: exemplar, writingSystem: 'default'} : undefined }); @@ -150,11 +158,11 @@
-
+
navigateToEntry(e.detail)} />
-
+
{#if !$viewConfig.readonly} onEntryCreated(e.detail.entry)} /> @@ -197,6 +205,7 @@ { $selectedEntry = $selectedEntry; + $entries = $entries; }} on:delete={e => { $selectedEntry = undefined; diff --git a/frontend/viewer/src/lib/Editor.svelte b/frontend/viewer/src/lib/Editor.svelte index 2cd523380..dc291a4b8 100644 --- a/frontend/viewer/src/lib/Editor.svelte +++ b/frontend/viewer/src/lib/Editor.svelte @@ -16,13 +16,12 @@ }>(); export let entry: IEntry; - let initialEntry = JSON.parse(JSON.stringify(entry)) as IEntry; + $: initialEntry = JSON.parse(JSON.stringify(entry)) as IEntry; + function updateInitialEntry() { initialEntry = JSON.parse(JSON.stringify(entry)) as IEntry; } - - const viewConfig = getContext>('viewConfig'); function withoutSenses(entry: IEntry): Omit { diff --git a/frontend/viewer/src/lib/assets/flex-logo.png b/frontend/viewer/src/lib/assets/flex-logo.png new file mode 100644 index 000000000..664eb1937 Binary files /dev/null and b/frontend/viewer/src/lib/assets/flex-logo.png differ diff --git a/frontend/viewer/src/lib/config-data.ts b/frontend/viewer/src/lib/config-data.ts index c6ad48845..96403c001 100644 --- a/frontend/viewer/src/lib/config-data.ts +++ b/frontend/viewer/src/lib/config-data.ts @@ -72,7 +72,7 @@ function configure>(fieldConfig: T, props: ViewCon export const views: ViewConfig[] = [ { - label: 'Everything (FLEx)', + label: 'Everything (FieldWorks)', ...allFieldConfigs, entry: { ...allFieldConfigs.entry, diff --git a/frontend/viewer/src/lib/layout/ViewOptionsDrawer.svelte b/frontend/viewer/src/lib/layout/ViewOptionsDrawer.svelte index f4ae8fb58..1518fe8b7 100644 --- a/frontend/viewer/src/lib/layout/ViewOptionsDrawer.svelte +++ b/frontend/viewer/src/lib/layout/ViewOptionsDrawer.svelte @@ -13,13 +13,13 @@ const viewConfig = getContext>('viewConfig'); - +
({ value: view, label: view.label }))} + label="Fields" + options={views.map((view) => ({ value: view, label: view.label, group: view.label }))} bind:value={$options.activeView} - classes={{root: 'view-select w-auto'}} + classes={{root: 'view-select w-auto', options: 'view-select-options'}} clearable={false} labelPlacement="top" clearSearchOnOpen={false} @@ -65,8 +65,15 @@
- diff --git a/frontend/viewer/src/lib/services/service-provider-signalr.ts b/frontend/viewer/src/lib/services/service-provider-signalr.ts index 790270921..8e2b7812a 100644 --- a/frontend/viewer/src/lib/services/service-provider-signalr.ts +++ b/frontend/viewer/src/lib/services/service-provider-signalr.ts @@ -5,16 +5,13 @@ import type { HubConnection } from '@microsoft/signalr'; import type { LexboxApiFeatures, LexboxApiMetadata } from './lexbox-api'; import {LexboxService} from './service-provider'; -export function SetupSignalR(connection: HubConnection) { +export function SetupSignalR(connection: HubConnection, features: LexboxApiFeatures) { const hubFactory = getHubProxyFactory('ILexboxApiHub'); const hubProxy = hubFactory.createHubProxy(connection); const lexboxApiHubProxy = Object.assign(hubProxy, { SupportedFeatures(): LexboxApiFeatures { - return { - history: true, - write: true, - }; + return features; } } satisfies LexboxApiMetadata); getReceiverRegister('ILexboxClient').register(connection, { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3879e3cbf..8705018c9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,14 +7,11 @@ import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'; import {type ProxyOptions, searchForWorkspaceRoot} from 'vite'; import { sveltekit } from '@sveltejs/kit/vite'; - - - - +const inDocker = process.env['DockerDev'] === 'true'; const exposeServer = false; const lexboxServer: ProxyOptions = { target: 'http://localhost:5158', - secure: false + secure: false, }; export default defineConfig({ @@ -50,7 +47,7 @@ export default defineConfig({ searchForWorkspaceRoot(process.cwd()) ] }, - proxy: process.env['DockerDev'] ? undefined : { + proxy: inDocker ? undefined : { '/v1/traces': 'http://localhost:4318', '/api': lexboxServer, '/hg': lexboxServer,