From 755fbe08d9ca2e55279e1bfff9f8b5c6c8dd3abe Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Mon, 1 Jul 2024 15:56:37 -0400 Subject: [PATCH] Support custom semantic domains ending in 0 (#3081) --- .../Controllers/LiftControllerTests.cs | 6 - .../SemanticDomainControllerTests.cs | 4 +- Backend.Tests/Models/ProjectTests.cs | 37 +- Backend.Tests/Models/SemanticDomainTest.cs | 48 +++ Backend.Tests/Util.cs | 10 +- Backend/Controllers/LiftController.cs | 9 + Backend/Interfaces/ILiftService.cs | 3 +- Backend/Models/Project.cs | 3 +- Backend/Models/SemanticDomain.cs | 80 +++- Backend/Services/LiftService.cs | 103 +++-- deploy/scripts/sem_dom_import.py | 4 +- docs/user_guide/docs/project.es.md | 6 +- docs/user_guide/docs/project.md | 6 +- docs/user_guide/docs/project.zh.md | 6 +- public/locales/en/translation.json | 16 + src/api/models/project.ts | 6 +- src/api/models/semantic-domain-full.ts | 6 + src/components/DataEntry/index.tsx | 23 +- .../ProjectSettings/ProjectDomains.tsx | 377 ++++++++++++++++++ .../ProjectSettings/ProjectLanguages.tsx | 153 +++---- src/components/ProjectSettings/index.tsx | 50 ++- .../tests/ProjectDomains.test.tsx | 105 +++++ .../tests/ProjectLanguages.test.tsx | 39 +- .../ProjectSettings/tests/SettingsTabTypes.ts | 6 + .../TreeView/Redux/TreeViewActions.ts | 14 +- .../TreeView/Redux/TreeViewReduxTypes.tsx | 4 +- .../Redux/tests/TreeViewActions.test.tsx | 5 +- .../TreeDepiction/DomainTileButton.tsx | 7 +- src/components/TreeView/TreeSearch.tsx | 23 +- src/components/TreeView/index.tsx | 13 +- .../TreeView/tests/TreeSearch.test.tsx | 3 +- .../TreeView/tests/utilities.test.ts | 82 ++++ src/components/TreeView/utilities.ts | 75 ++++ src/types/semanticDomain.ts | 16 +- 34 files changed, 1138 insertions(+), 210 deletions(-) create mode 100644 src/components/ProjectSettings/ProjectDomains.tsx create mode 100644 src/components/ProjectSettings/tests/ProjectDomains.test.tsx create mode 100644 src/components/TreeView/tests/utilities.test.ts create mode 100644 src/components/TreeView/utilities.ts diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index b3b0ffb596..f6ab750ae3 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -587,12 +587,6 @@ public void TestRoundtrip(RoundTripObj roundTripObj) } } - // Assert that the first SemanticDomain doesn't have an empty MongoId. - if (allWords[0].Senses.Count > 0 && allWords[0].Senses[0].SemanticDomains.Count > 0) - { - Assert.That(allWords[0].Senses[0].SemanticDomains[0].MongoId, Is.Not.Empty); - } - // Export. var exportedFilePath = _liftController.CreateLiftExport(proj1.Id).Result; var exportedDirectory = FileOperations.ExtractZipFile(exportedFilePath, null); diff --git a/Backend.Tests/Controllers/SemanticDomainControllerTests.cs b/Backend.Tests/Controllers/SemanticDomainControllerTests.cs index cdf611d4b5..61d970efd1 100644 --- a/Backend.Tests/Controllers/SemanticDomainControllerTests.cs +++ b/Backend.Tests/Controllers/SemanticDomainControllerTests.cs @@ -31,7 +31,7 @@ protected virtual void Dispose(bool disposing) private const string Id = "1"; private const string Lang = "en"; private const string Name = "Universe"; - private readonly SemanticDomainFull _semDom = new() { Id = Id, Lang = Lang, Name = Name }; + private readonly SemanticDomain _semDom = new() { Id = Id, Lang = Lang, Name = Name }; [SetUp] public void Setup() @@ -60,7 +60,7 @@ public void GetAllSemanticDomainNamesNotFound() [Test] public void GetSemanticDomainFullDomainFound() { - ((SemanticDomainRepositoryMock)_semDomRepository).SetNextResponse(_semDom); + ((SemanticDomainRepositoryMock)_semDomRepository).SetNextResponse(new SemanticDomainFull(_semDom)); var domain = (SemanticDomainFull?)( (ObjectResult)_semDomController.GetSemanticDomainFull(Id, Lang).Result).Value; Assert.That(domain?.Id, Is.EqualTo(Id)); diff --git a/Backend.Tests/Models/ProjectTests.cs b/Backend.Tests/Models/ProjectTests.cs index 175a92a523..59e73a98fd 100644 --- a/Backend.Tests/Models/ProjectTests.cs +++ b/Backend.Tests/Models/ProjectTests.cs @@ -56,11 +56,11 @@ public void TestNotEquals() Assert.That(project.Equals(project2), Is.False); project2 = project.Clone(); - project2.AnalysisWritingSystems.Add(new WritingSystem()); + project2.AnalysisWritingSystems.Add(new()); Assert.That(project.Equals(project2), Is.False); project2 = project.Clone(); - project2.SemanticDomains.Add(new SemanticDomain()); + project2.SemanticDomains.Add(new()); Assert.That(project.Equals(project2), Is.False); project2 = project.Clone(); @@ -74,7 +74,7 @@ public void TestNotEquals() Assert.That(project.Equals(project2), Is.False); project2 = project.Clone(); - project2.CustomFields.Add(new CustomField()); + project2.CustomFields.Add(new()); Assert.That(project.Equals(project2), Is.False); project2 = project.Clone(); @@ -99,16 +99,27 @@ public void TestNotEquals() [Test] public void TestClone() { - var system = new WritingSystem("en", "WritingSystemName", "calibri"); - var project = new Project { Name = "ProjectName", VernacularWritingSystem = system }; - var domain = new SemanticDomain { Name = "SemanticDomainName", Id = "1" }; - project.SemanticDomains.Add(domain); - - var customField = new CustomField { Name = "CustomFieldName", Type = "type" }; - project.CustomFields.Add(customField); - - var emailInvite = new EmailInvite(10, "user@combine.org", Role.Harvester); - project.InviteTokens.Add(emailInvite); + var project = new Project + { + Id = "ProjectId", + Name = "ProjectName", + IsActive = true, + LiftImported = true, + DefinitionsEnabled = true, + GrammaticalInfoEnabled = true, + AutocompleteSetting = AutocompleteSetting.On, + SemDomWritingSystem = new("fr", "Français"), + VernacularWritingSystem = new("en", "English", "Calibri"), + AnalysisWritingSystems = new() { new("es", "Español") }, + SemanticDomains = new() { new() { Name = "SemanticDomainName", Id = "1" } }, + ValidCharacters = new() { "a", "b", "c" }, + RejectedCharacters = new() { "X", "Y", "Z" }, + CustomFields = new() { new() { Name = "CustomFieldName", Type = "type" } }, + WordFields = new() { "some field string" }, + PartsOfSpeech = new() { "noun", "verb" }, + InviteTokens = new() { new(10, "user@combine.org", Role.Harvester) }, + WorkshopSchedule = new() { new(2222, 2, 22), }, + }; var project2 = project.Clone(); Assert.That(project, Is.EqualTo(project2)); diff --git a/Backend.Tests/Models/SemanticDomainTest.cs b/Backend.Tests/Models/SemanticDomainTest.cs index ad1c2d9a3c..ae33127491 100644 --- a/Backend.Tests/Models/SemanticDomainTest.cs +++ b/Backend.Tests/Models/SemanticDomainTest.cs @@ -41,6 +41,54 @@ public void TestHashCode() Is.Not.EqualTo(new SemanticDomain { Name = Guid.NewGuid().ToString() }.GetHashCode()) ); } + + private static readonly List _invalidIds = new() + { + "", + "a", + "123", + "1.42.9", + ".1.3.6", + "9.9.r", + "1.2.3..4" + }; + + [TestCaseSource(nameof(_invalidIds))] + public void TestIsValidIdInvalid(string id) + { + Assert.That(SemanticDomain.IsValidId(id), Is.False); + Assert.That(SemanticDomain.IsValidId(id, true), Is.False); + } + + private static readonly List _validIds = new() + { + "6", + "3.7", + "1.2.9", + "9.9.9.1", + }; + + [TestCaseSource(nameof(_validIds))] + public void TestIsValidIdValid(string id) + { + Assert.That(SemanticDomain.IsValidId(id), Is.True); + Assert.That(SemanticDomain.IsValidId(id, true), Is.True); + } + + private static readonly List _customIds = new() + { + "0", + "3.0", + "1.2.0", + "9.9.9.0", + }; + + [TestCaseSource(nameof(_customIds))] + public void TestIsValidIdCustom(string id) + { + Assert.That(SemanticDomain.IsValidId(id), Is.False); + Assert.That(SemanticDomain.IsValidId(id, true), Is.True); + } } public class SemanticDomainFullTests diff --git a/Backend.Tests/Util.cs b/Backend.Tests/Util.cs index b5fe213319..c830aab253 100644 --- a/Backend.Tests/Util.cs +++ b/Backend.Tests/Util.cs @@ -110,20 +110,20 @@ public static Project RandomProject() { Name = RandString(), VernacularWritingSystem = RandomWritingSystem(), - AnalysisWritingSystems = new List { RandomWritingSystem() }, - SemanticDomains = new List() + AnalysisWritingSystems = new() { RandomWritingSystem() }, + SemanticDomains = new() }; const int numSemanticDomains = 3; foreach (var i in Range(1, numSemanticDomains)) { - project.SemanticDomains.Add(RandomSemanticDomain($"{i}")); + project.SemanticDomains.Add(new(RandomSemanticDomain($"{i}"))); foreach (var j in Range(1, numSemanticDomains)) { - project.SemanticDomains.Add(RandomSemanticDomain($"{i}.{j}")); + project.SemanticDomains.Add(new(RandomSemanticDomain($"{i}.{j}"))); foreach (var k in Range(1, numSemanticDomains)) { - project.SemanticDomains.Add(RandomSemanticDomain($"{i}.{j}.{k}")); + project.SemanticDomains.Add(new(RandomSemanticDomain($"{i}.{j}.{k}"))); } } } diff --git a/Backend/Controllers/LiftController.cs b/Backend/Controllers/LiftController.cs index f9a6401090..487cbe4a6a 100644 --- a/Backend/Controllers/LiftController.cs +++ b/Backend/Controllers/LiftController.cs @@ -267,6 +267,15 @@ private async Task AddImportToProject(string liftStoragePath, str project.DefinitionsEnabled = doesImportHaveDefinitions; project.GrammaticalInfoEnabled = doesImportHaveGrammaticalInfo; + // Add new custom domains to the project + liftMerger.GetCustomSemanticDomains().ForEach(customDom => + { + if (!project.SemanticDomains.Any(dom => dom.Id == customDom.Id && dom.Lang == customDom.Lang)) + { + project.SemanticDomains.Add(customDom); + } + }); + // Store that we have imported LIFT data already for this project // to signal the frontend not to attempt to import again in this project. project.LiftImported = true; diff --git a/Backend/Interfaces/ILiftService.cs b/Backend/Interfaces/ILiftService.cs index 55c68d8f36..23d91c86e6 100644 --- a/Backend/Interfaces/ILiftService.cs +++ b/Backend/Interfaces/ILiftService.cs @@ -10,7 +10,7 @@ public interface ILiftService ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IWordRepository wordRepo); Task LdmlImport(string dirPath, IProjectRepository projRepo, Project project); Task LiftExport(string projectId, IWordRepository wordRepo, IProjectRepository projRepo); - Task CreateLiftRanges(List projDoms, string rangesDest); + Task CreateLiftRanges(List projDoms, string rangesDest); // Methods to store, retrieve, and delete an export string in a common dictionary. void StoreExport(string userId, string filePath); @@ -27,6 +27,7 @@ public interface ILiftMerger : ILexiconMerger GetCustomSemanticDomains(); List GetImportAnalysisWritingSystems(); Task> SaveImportEntries(); } diff --git a/Backend/Models/Project.cs b/Backend/Models/Project.cs index 3725c8b390..ba6c3fe6f8 100644 --- a/Backend/Models/Project.cs +++ b/Backend/Models/Project.cs @@ -52,9 +52,10 @@ public class Project [BsonElement("analysisWritingSystems")] public List AnalysisWritingSystems { get; set; } + /// Custom, project-specific semantic domains. [Required] [BsonElement("semanticDomains")] - public List SemanticDomains { get; set; } + public List SemanticDomains { get; set; } [Required] [BsonElement("validCharacters")] diff --git a/Backend/Models/SemanticDomain.cs b/Backend/Models/SemanticDomain.cs index f359a995f7..76c3ce9229 100644 --- a/Backend/Models/SemanticDomain.cs +++ b/Backend/Models/SemanticDomain.cs @@ -13,18 +13,22 @@ public class SemanticDomain [BsonId] [BsonElement("_id")] [BsonRepresentation(BsonType.ObjectId)] - public string MongoId { get; set; } + public string? MongoId { get; set; } + [Required] [BsonElement("guid")] #pragma warning disable CA1720 public string Guid { get; set; } #pragma warning restore CA1720 + [Required] [BsonElement("name")] public string Name { get; set; } + [Required] [BsonElement("id")] public string Id { get; set; } + [Required] [BsonElement("lang")] public string Lang { get; set; } @@ -37,7 +41,6 @@ public class SemanticDomain public SemanticDomain() { - MongoId = ""; Guid = System.Guid.Empty.ToString(); Name = ""; Id = ""; @@ -78,6 +81,29 @@ public override int GetHashCode() { return HashCode.Combine(Name, Id, Lang, Guid, UserId, Created); } + + /// + /// Check if given id string is a valid id: single non-0 digits divided by periods. + /// If allowCustom is set to true, allow the final digit to be 0. + /// + public static bool IsValidId(string id, bool allowCustom = false) + { + // Ensure the id is nonempty and composed of digits and periods + if (string.IsNullOrEmpty(id) || !id.All(c => char.IsDigit(c) || c == '.')) + { + return false; + } + + // Check that each number between periods is a single non-zero digit + var parts = id.Split("."); + var allSingleDigit = parts.All(d => d.Length == 1); + if (allowCustom) + { + // Custom domains may have 0 as the final digit + parts = parts.Take(parts.Length - 1).ToArray(); + } + return allSingleDigit && parts.All(d => d != "0"); + } } public class SemanticDomainFull : SemanticDomain @@ -85,25 +111,45 @@ public class SemanticDomainFull : SemanticDomain [Required] [BsonElement("description")] public string Description { get; set; } + + [Required] + [BsonElement("parentId")] + public string ParentId { get; set; } + [Required] [BsonElement("questions")] public List Questions { get; set; } - public SemanticDomainFull() + public SemanticDomainFull() : base() { - Name = ""; - Id = ""; Description = ""; + ParentId = ""; + Questions = new(); + } + + public SemanticDomainFull(SemanticDomain semDom) + { + MongoId = semDom.MongoId; + Guid = semDom.Guid; + Name = semDom.Name; + Id = semDom.Id; + Lang = semDom.Lang; + UserId = semDom.UserId; + Created = semDom.Created; + + Description = ""; + ParentId = ""; Questions = new(); - Lang = ""; } public new SemanticDomainFull Clone() { - var clone = (SemanticDomainFull)base.Clone(); - clone.Description = Description; - clone.Questions = Questions.Select(q => q).ToList(); - return clone; + return new(base.Clone()) + { + Description = Description, + ParentId = ParentId, + Questions = Questions.Select(q => q).ToList() + }; } public override bool Equals(object? obj) @@ -116,13 +162,14 @@ public override bool Equals(object? obj) return base.Equals(other) && Description.Equals(other.Description, StringComparison.Ordinal) && + ParentId.Equals(other.ParentId, StringComparison.Ordinal) && Questions.Count == other.Questions.Count && Questions.All(other.Questions.Contains); } public override int GetHashCode() { - return HashCode.Combine(Name, Id, Description, Questions); + return HashCode.Combine(Name, Id, Description, ParentId, Questions); } } @@ -134,36 +181,43 @@ public class SemanticDomainTreeNode [BsonId] [BsonElement("_id")] [BsonRepresentation(BsonType.ObjectId)] - public string MongoId { get; set; } + public string? MongoId { get; set; } [Required] [BsonElement("lang")] public string Lang { get; set; } + [Required] [BsonElement("guid")] #pragma warning disable CA1720 public string Guid { get; set; } #pragma warning restore CA1720 + [Required] [BsonElement("name")] public string Name { get; set; } + [Required] [BsonElement("id")] public string Id { get; set; } + [BsonElement("prev")] public SemanticDomain? Previous { get; set; } + [BsonElement("next")] public SemanticDomain? Next { get; set; } + [BsonElement("parent")] public SemanticDomain? Parent { get; set; } + [Required] [BsonElement("children")] public List Children { get; set; } public SemanticDomainTreeNode(SemanticDomain sd) { - Guid = sd.Guid; MongoId = sd.MongoId; + Guid = sd.Guid; Lang = sd.Lang; Name = sd.Name; Id = sd.Id; diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index 08448451f1..68100f6ed9 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -367,7 +367,7 @@ public async Task LiftExport( } } - // Export semantic domains to lift-ranges + // Export custom semantic domains to lift-ranges if (proj.SemanticDomains.Count != 0 || CopyLiftRanges(proj.Id, rangesDest) is null) { await CreateLiftRanges(proj.SemanticDomains, rangesDest); @@ -412,7 +412,7 @@ public async Task LiftExport( } /// Export English semantic domains (along with any custom domains) to lift-ranges. - public async Task CreateLiftRanges(List projDoms, string rangesDest) + public async Task CreateLiftRanges(List projDoms, string rangesDest) { await using var liftRangesWriter = XmlWriter.Create(rangesDest, new XmlWriterSettings { @@ -425,8 +425,9 @@ public async Task CreateLiftRanges(List projDoms, string rangesD liftRangesWriter.WriteStartElement("range"); liftRangesWriter.WriteAttributeString("id", "semantic-domain-ddp4"); - (await _semDomRepo.GetAllSemanticDomainTreeNodes("en"))? - .ForEach(sd => { WriteRangeElement(liftRangesWriter, sd.Id, sd.Guid, sd.Name, sd.Lang); }); + var englishDomains = await _semDomRepo.GetAllSemanticDomainTreeNodes("en") ?? new(); + + englishDomains.ForEach(sd => { WriteRangeElement(liftRangesWriter, sd.Id, sd.Guid, sd.Name, sd.Lang); }); // Pull from new semantic domains in project foreach (var sd in projDoms) @@ -434,7 +435,9 @@ public async Task CreateLiftRanges(List projDoms, string rangesD var guid = string.IsNullOrEmpty(sd.Guid) || sd.Guid == Guid.Empty.ToString() ? Guid.NewGuid().ToString() : sd.Guid; - WriteRangeElement(liftRangesWriter, sd.Id, guid, sd.Name, sd.Lang); + + var parent = $"{sd.ParentId} {englishDomains.Find(d => d.Id == sd.ParentId)?.Name}".Trim(); + WriteRangeElement(liftRangesWriter, sd.Id, guid, sd.Name, sd.Lang, sd.Description, parent); } await liftRangesWriter.WriteEndElementAsync(); //end semantic-domain-ddp4 range @@ -527,7 +530,7 @@ private static void AddSenses(LexEntry entry, Word wordEntry, Dictionary( LexSense.WellKnownProperties.SemanticDomainDdp4, orc)); } @@ -621,33 +624,41 @@ public ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IW return new LiftMerger(projectId, vernLang, wordRepo); } - private static void WriteRangeElement( - XmlWriter liftRangesWriter, string id, string guid, string name, string lang) + private static void WriteRangeElement(XmlWriter liftRangesWriter, + string id, string guid, string name, string lang, string description = "", string parent = "") + { + liftRangesWriter.WriteStartElement("range-element"); // start range element + liftRangesWriter.WriteAttributeString("id", $"{id} {name}"); // add id to element + liftRangesWriter.WriteAttributeString("guid", guid); // add guid to element + if (!string.IsNullOrEmpty(parent)) + { + liftRangesWriter.WriteAttributeString("parent", $"{parent}"); // add parent to element + } + + WriteFormElement(liftRangesWriter, "label", lang, name); // write label + WriteFormElement(liftRangesWriter, "abbrev", lang, id); // write abbrev/id + if (!string.IsNullOrEmpty(description)) + { + WriteFormElement(liftRangesWriter, "description", lang, description); // write description + } + + liftRangesWriter.WriteEndElement(); // end range element + } + + private static void WriteFormElement(XmlWriter liftRangesWriter, string element, string language, string text) { - liftRangesWriter.WriteStartElement("range-element"); - liftRangesWriter.WriteAttributeString("id", $"{id} {name}"); - liftRangesWriter.WriteAttributeString("guid", guid); - - liftRangesWriter.WriteStartElement("label"); - liftRangesWriter.WriteAttributeString("lang", lang); - liftRangesWriter.WriteStartElement("text"); - liftRangesWriter.WriteString(name); - liftRangesWriter.WriteEndElement(); //end text - liftRangesWriter.WriteEndElement(); //end label - - liftRangesWriter.WriteStartElement("abbrev"); - liftRangesWriter.WriteAttributeString("lang", lang); - liftRangesWriter.WriteStartElement("text"); - liftRangesWriter.WriteString(id); - liftRangesWriter.WriteEndElement(); //end text - liftRangesWriter.WriteEndElement(); //end label - - liftRangesWriter.WriteEndElement(); //end range element + liftRangesWriter.WriteStartElement(element); // start element + liftRangesWriter.WriteStartElement("form"); // start form + liftRangesWriter.WriteAttributeString("lang", language); // add language to form + liftRangesWriter.WriteElementString("text", text); // write text + liftRangesWriter.WriteEndElement(); // end form + liftRangesWriter.WriteEndElement(); // end element } private sealed class LiftMerger : ILiftMerger { private readonly string _projectId; + private readonly List _customSemDoms = new(); private readonly string _vernLang; private readonly IWordRepository _wordRepo; private readonly List _importEntries = new(); @@ -679,6 +690,12 @@ public bool DoesImportHaveGrammaticalInfo() s.GrammaticalInfo.CatGroup != GramCatGroup.Unspecified)); } + /// Get custom semantic domains found in the lift ranges. + public List GetCustomSemanticDomains() + { + return _customSemDoms; + } + /// /// Get all analysis languages found in senses in the private field /// @@ -977,16 +994,34 @@ public void MergeInGrammaticalInfo(LiftObject senseOrReversal, string val, List< } } - /// Adds in each semantic domain to a list + /// Adds in custom semantic domains public void ProcessRangeElement(string range, string id, string guid, string parent, LiftMultiText description, LiftMultiText label, LiftMultiText abbrev, string rawXml) { - /*uncomment this if you want to import semantic domains from a lift-ranges file*/ - //if (range == "semantic-domain-ddp4") - //{ - // _sdList.Add(new SemanticDomain { - // Name = label.ElementAt(0).Value.Text, Id = abbrev.First().Value.Text }); - //} + if (range == "semantic-domain-ddp4" && abbrev.Count > 0) + { + var domainId = abbrev.First().Value.Text; + + // If we allow custom subdomains with id not ending in "0", + // we'll need to change the `domainId.Last() == '0'` check + // to verifying that the id doesn't conflict with the standard domains. + if (SemanticDomain.IsValidId(domainId, true) && domainId.Last() == '0') + { + foreach (var nameLabel in label) + { + description.TryGetValue(nameLabel.Key, out var descriptionText); + _customSemDoms.Add(new() + { + Guid = guid, + Id = domainId, + Lang = nameLabel.Key, + Name = nameLabel.Value.Text, + Description = descriptionText?.Text ?? "", + ParentId = string.IsNullOrEmpty(parent) ? "" : parent.Split(" ")[0] + }); + } + } + } } // The following are unused and are not implemented, but may still be called by the Lexicon Merger diff --git a/deploy/scripts/sem_dom_import.py b/deploy/scripts/sem_dom_import.py index d93f5a5db4..d434655288 100755 --- a/deploy/scripts/sem_dom_import.py +++ b/deploy/scripts/sem_dom_import.py @@ -191,9 +191,9 @@ def save_domain( def get_sem_doms(node: ElementTree.Element, parent: SemDomTreeMap, prev: SemDomMap) -> SemDomMap: """ - Recursively parse domains and sub-domains. + Recursively parse domains and subdomains. - The domains and sub-domains that are extracted by get_sem_doms are placed into two global + The domains and subdomains that are extracted by get_sem_doms are placed into two global structures: 1. domain_nodes is a list of SemanticDomainFull elements that contain the full information about the domain. diff --git a/docs/user_guide/docs/project.es.md b/docs/user_guide/docs/project.es.md index 7a95394c2c..3f23426c44 100644 --- a/docs/user_guide/docs/project.es.md +++ b/docs/user_guide/docs/project.es.md @@ -169,7 +169,7 @@ descargar. When the data is gathered, the download will begin automatically. El !!! note "Nota" - Project settings, project users, and word flags are not exported. + Project settings, project users, word flags, and custom semantic domain questions are not exported. #### Exportar locutores de las pronunciaciones @@ -215,3 +215,7 @@ Gráficos lineales que muestran las palabras recogidas durante los días especif Gráficos lineales que muestran las palabras acumuladas a lo largo de los días del [Calendario de talleres](#workshop-schedule), así como las previsiones para el resto del taller. + +## Semantic Domains + +Details about custom semantic domains are coming soon. diff --git a/docs/user_guide/docs/project.md b/docs/user_guide/docs/project.md index 525e622f87..4daf4d2163 100644 --- a/docs/user_guide/docs/project.md +++ b/docs/user_guide/docs/project.md @@ -167,7 +167,7 @@ download. When the data is gathered, the download will begin automatically. The !!! note "Note" - Project settings, project users, and word flags are not exported. + Project settings, project users, word flags, and custom semantic domain questions are not exported. #### Export pronunciation speakers @@ -212,3 +212,7 @@ Line graphs showing words collected during the days specified in the [Workshop S Line graphs showing cumulative words collected across the days of the [Workshop Schedule](#workshop-schedule), as well as projections for remainder of the workshop. + +## Semantic Domains + +Details about custom semantic domains are coming soon. diff --git a/docs/user_guide/docs/project.zh.md b/docs/user_guide/docs/project.zh.md index 41efa40616..d99d4ed9b5 100644 --- a/docs/user_guide/docs/project.zh.md +++ b/docs/user_guide/docs/project.zh.md @@ -152,7 +152,7 @@ automatically. The filename is the project id. !!! note "笔记" - Project settings, project users, and word flags are not exported. + Project settings, project users, word flags, and custom semantic domain questions are not exported. #### Export pronunciation speakers @@ -193,3 +193,7 @@ not be imported into FieldWorks. ### 工作坊进度 线型图显示了 [工作坊日程](#workshop-schedule)多天所累积的词以及对余下时间的预测。 + +## Semantic Domains + +Details about custom semantic domains are coming soon. diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 618aa41ebc..69b9b69a0f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -139,6 +139,7 @@ "project": "Project:", "tab": { "basic": "Basic Settings", + "domains": "Semantic Domains", "export": "Export", "importExport": "Import/Export", "languages": "Languages", @@ -253,6 +254,21 @@ "emailLabel": "Email", "toastSuccess": "User added to the project.", "toastFail": "Failed to add user to the project." + }, + "domains": { + "semDomLanguage": "Semantic Domains Language", + "label": "Custom Semantic Domains", + "add": "Add a custom subdomain", + "parent": "Parent domain: ", + "name": "Subdomain name: ", + "description": "Description: ", + "questions": "Questions: ", + "deleteConfirm": "Are you sure you want to delete this custom domain?", + "addError": { + "name": "Please enter a name for the custom domain.", + "customParent": "The parent domain cannot be a custom subdomain. Please choose a different parent domain.", + "takenParent": "The selected parent domain already has a custom subdomain." + } } }, "goal": { diff --git a/src/api/models/project.ts b/src/api/models/project.ts index 15c22f5698..0958b4d28c 100644 --- a/src/api/models/project.ts +++ b/src/api/models/project.ts @@ -15,7 +15,7 @@ import { AutocompleteSetting } from "./autocomplete-setting"; import { CustomField } from "./custom-field"; import { EmailInvite } from "./email-invite"; -import { SemanticDomain } from "./semantic-domain"; +import { SemanticDomainFull } from "./semantic-domain-full"; import { WritingSystem } from "./writing-system"; /** @@ -86,10 +86,10 @@ export interface Project { analysisWritingSystems: Array; /** * - * @type {Array} + * @type {Array} * @memberof Project */ - semanticDomains: Array; + semanticDomains: Array; /** * * @type {Array} diff --git a/src/api/models/semantic-domain-full.ts b/src/api/models/semantic-domain-full.ts index dd8791bade..4bd5f74956 100644 --- a/src/api/models/semantic-domain-full.ts +++ b/src/api/models/semantic-domain-full.ts @@ -66,6 +66,12 @@ export interface SemanticDomainFull { * @memberof SemanticDomainFull */ description: string; + /** + * + * @type {string} + * @memberof SemanticDomainFull + */ + parentId: string; /** * * @type {Array} diff --git a/src/components/DataEntry/index.tsx b/src/components/DataEntry/index.tsx index eb8e423721..95d6667047 100644 --- a/src/components/DataEntry/index.tsx +++ b/src/components/DataEntry/index.tsx @@ -47,6 +47,10 @@ export default function DataEntry(): ReactElement { const { currentDomain, open } = useAppSelector( (state: StoreState) => state.treeViewState ); + const customDomains = useAppSelector( + (state: StoreState) => state.currentProjectState.project.semanticDomains + ); + const { id, lang, name } = currentDomain; /* This ref is for a container of both the and , @@ -80,12 +84,19 @@ export default function DataEntry(): ReactElement { // When domain changes, fetch full domain details. useEffect(() => { - getSemanticDomainFull(id, lang).then((fullDomain) => { - if (fullDomain) { - setDomain(fullDomain); - } - }); - }, [id, lang]); + const customDomain = customDomains.find( + (d) => d.id === id && d.lang === lang + ); + if (customDomain) { + setDomain(customDomain); + } else { + getSemanticDomainFull(id, lang).then((fullDomain) => { + if (fullDomain) { + setDomain(fullDomain); + } + }); + } + }, [customDomains, id, lang]); // Recalculate height if something changed that might affect it. useEffect(() => { diff --git a/src/components/ProjectSettings/ProjectDomains.tsx b/src/components/ProjectSettings/ProjectDomains.tsx new file mode 100644 index 0000000000..a6388b9096 --- /dev/null +++ b/src/components/ProjectSettings/ProjectDomains.tsx @@ -0,0 +1,377 @@ +import { Add, Check, Close, Delete } from "@mui/icons-material"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + Chip, + Dialog, + DialogContent, + DialogTitle, + Grid, + IconButton, + Stack, + Typography, +} from "@mui/material"; +import { type ReactElement, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; + +import { type SemanticDomain, type SemanticDomainFull } from "api"; +import { IconButtonWithTooltip } from "components/Buttons"; +import { CancelConfirmDialog } from "components/Dialogs"; +import { type ProjectSettingProps } from "components/ProjectSettings/ProjectSettingsTypes"; +import TreeView from "components/TreeView"; +import i18n from "i18n"; +import { newSemanticDomain } from "types/semanticDomain"; +import { TextFieldWithFont } from "utilities/fontComponents"; + +export enum ProjectDomainsId { + ButtonDomainAdd = "custom-domain-add", + ButtonDomainAddDialogCancel = "custom-domain-add-cancel", + ButtonDomainAddDialogConfirm = "custom-domain-add-confirm", + ButtonDomainAddDialogParentAdd = "custom-domain-add-parent-add", + FieldDomainAddDialogName = "custom-domain-add-name", +} + +export const trimDomain = (domain: SemanticDomainFull): SemanticDomainFull => ({ + ...domain, + description: domain.description.trim(), + name: domain.name.trim(), + questions: domain.questions.map((q) => q.trim()).filter((q) => q), +}); + +/** A project settings component for managing custom semantic domains. */ +export default function ProjectDomains( + props: ProjectSettingProps +): ReactElement { + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [domains, setDomains] = useState([]); + const [lang, setLang] = useState(""); + + useEffect(() => { + setLang( + props.project.semDomWritingSystem.bcp47 || i18n.language.split("-")[0] + ); + }, [props.project.semDomWritingSystem.bcp47]); + + useEffect(() => { + setDomains( + props.project.semanticDomains + .filter((d) => d.lang === lang) + .sort((a, b) => a.id.localeCompare(b.id)) + ); + }, [lang, props.project.semanticDomains]); + + /** Add the specified custom domain to the current project: + * if `undefined`, close the dialog without adding anything, then return `true`; + * if custom domain already exists with the same id and lang, return `false`; + * otherwise, update the project with the new domain, then return `true`. */ + const addDomain = (domain?: SemanticDomainFull): boolean => { + if (!domain) { + setAddDialogOpen(false); + return true; + } + + if (domains.some((d) => d.id === domain.id && d.lang === domain.lang)) { + return false; + } + + props.setProject({ + ...props.project, + semanticDomains: [...props.project.semanticDomains, domain], + }); + return true; + }; + + /** Update the current project with the new list of custom domains. */ + const updateDomains = ( + updateFunction: (doms: SemanticDomainFull[]) => SemanticDomainFull[] + ): void => { + props.setProject({ + ...props.project, + semanticDomains: updateFunction(props.project.semanticDomains), + }); + }; + + return ( + + {domains.map((d) => ( + + ))} + + } + buttonId={ProjectDomainsId.ButtonDomainAdd} + onClick={() => setAddDialogOpen(true)} + textId="projectSettings.domains.add" + /> + + + + ); +} + +interface CustomDomainProps { + domain: SemanticDomainFull; + updateDomains: ( + updateFunction: (doms: SemanticDomainFull[]) => SemanticDomainFull[] + ) => void; +} + +/** Component for managing a single custom domain of a project. */ +function CustomDomain(props: CustomDomainProps): ReactElement { + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [domain, setDomain] = useState(props.domain); + + const { t } = useTranslation(); + + useEffect(() => { + setDomain(trimDomain(props.domain)); + }, [props.domain]); + + /** Delete the custom domain from the project. */ + const deleteDomain = (): void => + props.updateDomains((doms) => + doms.filter( + (d) => !(d.id === props.domain.id && d.lang === props.domain.lang) + ) + ); + + /** Update the custom domain in the project. */ + const updateDomain = (domain: SemanticDomainFull): void => + props.updateDomains((doms) => + doms.map((d) => + d.id === domain.id && d.lang === domain.lang ? domain : d + ) + ); + + const updateName = (name: string): void => { + setDomain((prev) => ({ ...prev, name })); + }; + const updateDescription = (description: string): void => { + setDomain((prev) => ({ ...prev, description })); + }; + const addQuestion = (): void => { + setDomain((prev) => ({ ...prev, questions: [...prev.questions, ""] })); + }; + const updateQuestion = (index: number, question: string): void => { + setDomain((prev) => ({ + ...prev, + questions: prev.questions.map((q, i) => (i === index ? question : q)), + })); + }; + + /** Check if the current domain in the component's state + * is substantively different from the project domain passed in via the props. */ + const isDomainChanged = (): boolean => { + const dom = trimDomain(domain); + const old = trimDomain(props.domain); + return !( + dom.name === old.name && + dom.description === old.description && + dom.questions.length === old.questions.length && + dom.questions.every((q, i) => q === old.questions[i]) + ); + }; + + /** Save the changes to the custom domain, or delete it if all content was removed. */ + const saveChanges = (): void => { + const dom = trimDomain(domain); + if (!(dom.name || dom.description || dom.questions.length)) { + deleteDomain(); + } else { + updateDomain(dom); + } + }; + + return ( + + + + {props.domain.id} + {" : "} + {props.domain.name} + + } + onClick={() => setDeleteDialogOpen(true)} + size="small" + /> + setDeleteDialogOpen(false)} + handleConfirm={() => deleteDomain()} + open={deleteDialogOpen} + text={t("projectSettings.domains.deleteConfirm")} + /> + + + + + + {t("projectSettings.domains.name")} + + updateName(e.target.value)} + value={domain.name} + /> + + + + {t("projectSettings.domains.description")} + + updateDescription(e.target.value)} + value={domain.description} + /> + + + {t("projectSettings.domains.questions")} + {domain.questions.map((q, i) => ( + updateQuestion(i, e.target.value)} + sx={{ display: "block" }} + value={q} + /> + ))} + } + onClick={() => addQuestion()} + /> + + {isDomainChanged() ? ( + + ) : null} + + + + ); +} + +interface AddDomainDialogProps { + lang: string; + onSubmit: (domain?: SemanticDomainFull) => boolean; + open: boolean; +} + +/** Dialog component for adding a new custom domain to the current project. */ +export function AddDomainDialog(props: AddDomainDialogProps): ReactElement { + const [addingDom, setAddingDom] = useState(false); + const [name, setName] = useState(""); + const [parent, setParent] = useState(); + + const { t } = useTranslation(); + + const addParent = (domain?: SemanticDomain): void => { + setAddingDom(false); + setParent(domain); + }; + + const cancel = (): void => { + props.onSubmit(); + setName(""); + setParent(undefined); + }; + + const submit = (): void => { + if (!name.trim()) { + toast.error(t("projectSettings.domains.addError.name")); + return; + } + + // If we allow custom subdomains with id not ending in "0", + // we'll need to change this to check against the list of custom domains. + if (parent?.id[parent?.id.length - 1] === "0") { + toast.error(t("projectSettings.domains.addError.customParent")); + return; + } + + const id = parent ? `${parent.id}.0` : "0"; + const domain = newSemanticDomain(id, name.trim(), props.lang); + domain.parentId = parent?.id ?? ""; + if (!props.onSubmit(domain)) { + toast.error(t("projectSettings.domains.addError.takenParent")); + } else { + cancel(); + } + }; + + return ( + + + + {t("projectSettings.domains.add")} + + submit()} + > + t.palette.success.main }} /> + + cancel()} + > + t.palette.error.main }} /> + + + + + + + + + {t("projectSettings.domains.parent")} + + {parent ? ( + setParent(undefined)} + /> + ) : ( + setAddingDom(true)} + > + + + )} + + setAddingDom(false)} + returnControlToCaller={addParent} + /> + + + + + {t("projectSettings.domains.name")} + + setName(e.target.value)} + value={name} + /> + + + + + ); +} diff --git a/src/components/ProjectSettings/ProjectLanguages.tsx b/src/components/ProjectSettings/ProjectLanguages.tsx index 47db8ead10..fff0870d58 100644 --- a/src/components/ProjectSettings/ProjectLanguages.tsx +++ b/src/components/ProjectSettings/ProjectLanguages.tsx @@ -30,14 +30,16 @@ import theme from "types/theme"; import { newWritingSystem, semDomWritingSystems } from "types/writingSystem"; import { getAnalysisLangsFromWords } from "utilities/wordUtilities"; -const addAnalysisLangButtonId = "analysis-language-new"; -const addAnalysisLangCleanButtonId = "analysis-language-new-clear"; -const addAnalysisLangConfirmButtonId = "analysis-language-new-confirm"; -export const editVernacularNameButtonId = "vernacular-language-edit"; -export const editVernacularNameFieldId = "vernacular-language-name"; -export const editVernacularNameSaveButtonId = "vernacular-language-save"; -const getProjAnalysisLangsButtonId = "analysis-language-get"; -const semDomLangSelectId = "semantic-domains-language"; +export enum ProjectLanguagesId { + ButtonAddAnalysisLang = "analysis-language-new", + ButtonAddAnalysisLangClear = "analysis-language-new-clear", + ButtonAddAnalysisLangConfirm = "analysis-language-new-confirm", + ButtonEditVernacularName = "vernacular-language-edit", + ButtonEditVernacularNameSave = "vernacular-language-save", + ButtonGetProjAnalysisLangs = "analysis-language-get", + FieldEditVernacularName = "vernacular-language-name", + SelectSemDomLang = "semantic-domains-language", +} export default function ProjectLanguages( props: ProjectSettingProps @@ -136,21 +138,6 @@ export default function ProjectLanguages( setLangsInProj(langCodes.join(", ")); }; - const setSemDomWritingSystem = async (lang: string): Promise => { - const semDomWritingSystem = - semDomWritingSystems.find((ws) => ws.bcp47 === lang) ?? - newWritingSystem(); - await props - .setProject({ ...props.project, semDomWritingSystem }) - .then(() => resetState()) - .catch((err) => { - console.error(err); - toast.error( - t("projectSettings.language.updateSemDomWritingSystemFailed") - ); - }); - }; - const resetState = (): void => { setAdd(false); setLangsInProj(""); @@ -186,13 +173,13 @@ export default function ProjectLanguages( icon={} textId="projectSettings.language.addAnalysisLanguage" onClick={() => setAdd(true)} - buttonId={addAnalysisLangButtonId} + buttonId={ProjectLanguagesId.ButtonAddAnalysisLang} /> } textId="projectSettings.language.getGlossLanguages" onClick={() => getActiveAnalysisLangs()} - buttonId={getProjAnalysisLangsButtonId} + buttonId={ProjectLanguagesId.ButtonGetProjAnalysisLangs} /> {langsInProj} @@ -227,7 +214,7 @@ export default function ProjectLanguages( addAnalysisWritingSystem()} - id={addAnalysisLangConfirmButtonId} + id={ProjectLanguagesId.ButtonAddAnalysisLangConfirm} size="large" > @@ -236,7 +223,7 @@ export default function ProjectLanguages( resetState()} - id={addAnalysisLangCleanButtonId} + id={ProjectLanguagesId.ButtonAddAnalysisLangClear} size="large" > @@ -245,34 +232,6 @@ export default function ProjectLanguages( ); - const semDomLangSelect = (): ReactElement => ( - - ); - const vernacularLanguageDisplay = (): ReactElement => ( setChangeVernName(true)} - buttonId={editVernacularNameButtonId} + buttonId={ProjectLanguagesId.ButtonEditVernacularName} /> ) } @@ -297,7 +256,7 @@ export default function ProjectLanguages( setNewVernName(e.target.value)} onBlur={() => { @@ -311,7 +270,7 @@ export default function ProjectLanguages(