From e64ca0b2203c88d23c3aa684d85a4cce1c485afb Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Fri, 2 Feb 2024 09:35:39 -0500 Subject: [PATCH] [MergeDups] When word/sense is protected, give reasons (#2888) --- .vscode/settings.json | 6 +- Backend.Tests/Helper/LiftHelperTests.cs | 265 ++++++++++++++++++ Backend/Helper/LiftHelper.cs | 191 ++++++++++++- Backend/Models/ProtectReason.cs | 79 ++++++ Backend/Models/Sense.cs | 14 +- Backend/Models/Word.cs | 13 + Backend/Services/LiftService.cs | 2 + public/locales/en/translation.json | 35 ++- src/api/.openapi-generator/FILES | 2 + src/api/models/index.ts | 2 + src/api/models/protect-reason.ts | 41 +++ src/api/models/reason-type.ts | 47 ++++ src/api/models/sense.ts | 7 + src/api/models/word.ts | 7 + .../MultilineTooltipTitle/index.tsx | 7 + src/components/Pronunciations/AudioPlayer.tsx | 10 +- .../MergeDupsStep/MergeDragDrop/DropWord.tsx | 72 ++++- .../MergeDupsStep/SenseCardContent.tsx | 87 +++++- 18 files changed, 860 insertions(+), 27 deletions(-) create mode 100644 Backend.Tests/Helper/LiftHelperTests.cs create mode 100644 Backend/Models/ProtectReason.cs create mode 100644 src/api/models/protect-reason.ts create mode 100644 src/api/models/reason-type.ts create mode 100644 src/components/MultilineTooltipTitle/index.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 4c8b3939cc..73188d834c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -78,8 +78,10 @@ "sched", "signup", "sillsdev", - "Sldr", - "Subdir", + "sldr", + "subdir", + "subsense", + "subsenses", "subtag", "subtags", "targetdir", diff --git a/Backend.Tests/Helper/LiftHelperTests.cs b/Backend.Tests/Helper/LiftHelperTests.cs new file mode 100644 index 0000000000..f56a66a11e --- /dev/null +++ b/Backend.Tests/Helper/LiftHelperTests.cs @@ -0,0 +1,265 @@ +using System.Linq; +using static BackendFramework.Helper.LiftHelper; +using BackendFramework.Models; +using NUnit.Framework; +using SIL.Lift.Parsing; +using BackendFramework.Helper; + +namespace Backend.Tests.Helper +{ + public class LiftHelperTests + { + [Test] + public void EntryUnprotected() + { + var entry = new LiftEntry + { + CitationForm = new("key", "content"), + DateCreated = new(), + DateDeleted = new(), + DateModified = new(), + Guid = new(), + Id = "id", + LexicalForm = new("key", "content"), + Order = 1, + }; + // A single note with empty type is allowed. + entry.Notes.Add(new("", new("key", "content"))); + entry.Pronunciations.Add(new()); + entry.Pronunciations.Add(new()); + entry.Senses.Add(new()); + entry.Senses.Add(new()); + // The only entry trait not protected is morph type "stem". + entry.Traits.Add(new() { Name = TraitNames.MorphType, Value = "stem" }); + entry.Traits.Add(new() { Name = TraitNames.MorphType, Value = "stem" }); + entry.Traits.First().Annotations.Add(new() { Name = "name", Value = "value" }); + + Assert.That(IsProtected(entry), Is.False); + Assert.That(GetProtectedReasons(entry), Is.Empty); + } + + [Test] + public void EntryAnnotationsProtected() + { + var entry = new LiftEntry(); + entry.Annotations.Add(new()); + entry.Annotations.Add(new()); + Assert.That(IsProtected(entry), Is.True); + var reasons = GetProtectedReasons(entry); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Annotations)); + } + + [Test] + public void EntryEtymologiesProtected() + { + var entry = new LiftEntry(); + entry.Etymologies.Add(new()); + entry.Etymologies.Add(new()); + Assert.That(IsProtected(entry), Is.True); + var reasons = GetProtectedReasons(entry); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Etymologies)); + } + + [Test] + public void EntryFieldProtected() + { + var entry = new LiftEntry(); + entry.Fields.Add(new()); + entry.Fields.Add(new()); + Assert.That(IsProtected(entry), Is.True); + var reasons = GetProtectedReasons(entry); + Assert.That(reasons, Has.Count.EqualTo(2)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Field)); + Assert.That(reasons.Last().Type, Is.EqualTo(ReasonType.Field)); + } + + [Test] + public void EntryNoteTypeProtected() + { + var entry = new LiftEntry(); + entry.Notes.Add(new()); + entry.Notes.First().Type = "non-empty"; + Assert.That(IsProtected(entry), Is.True); + var reasons = GetProtectedReasons(entry); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.Last().Type, Is.EqualTo(ReasonType.NoteWithType)); + } + + [Test] + public void EntryNotesProtected() + { + var entry = new LiftEntry(); + entry.Notes.Add(new()); + entry.Notes.Add(new()); + Assert.That(IsProtected(entry), Is.True); + var reasons = GetProtectedReasons(entry); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.Last().Type, Is.EqualTo(ReasonType.Notes)); + } + + [Test] + public void EntryRelationsProtected() + { + var entry = new LiftEntry(); + entry.Relations.Add(new()); + entry.Relations.Add(new()); + Assert.That(IsProtected(entry), Is.True); + var reasons = GetProtectedReasons(entry); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.Last().Type, Is.EqualTo(ReasonType.Relations)); + } + + [Test] + public void EntryVariantsProtected() + { + var entry = new LiftEntry(); + entry.Variants.Add(new()); + entry.Variants.Add(new()); + Assert.That(IsProtected(entry), Is.True); + var reasons = GetProtectedReasons(entry); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.Last().Type, Is.EqualTo(ReasonType.Variants)); + } + + [Test] + public void SenseUnprotected() + { + var sense = new LiftSense + { + DateCreated = new(), + DateModified = new(), + Definition = new("key1", "contentA"), + Gloss = new("key1", "contentB"), + GramInfo = new() { Value = "value" }, + Guid = new(), + Id = "id", + Order = 1, + }; + sense.Definition.Add("key2", "contentA"); + sense.Gloss.Add("key3", "contentC"); + // The only sense trait not protected is semantic domain. + sense.Traits.Add(new() { Name = TraitNames.SemanticDomain, Value = "1" }); + sense.Traits.Add(new() { Name = TraitNames.SemanticDomainDdp4, Value = "1.1" }); + sense.Traits.First().Annotations.Add(new() { Name = "name", Value = "value" }); + + Assert.That(IsProtected(sense), Is.False); + Assert.That(GetProtectedReasons(sense), Is.Empty); + } + + [Test] + public void SenseAnnotationsProtected() + { + var sense = new LiftSense(); + sense.Annotations.Add(new()); + sense.Annotations.Add(new()); + Assert.That(IsProtected(sense), Is.True); + var reasons = GetProtectedReasons(sense); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Annotations)); + } + + [Test] + public void SenseExamplesProtected() + { + var sense = new LiftSense(); + sense.Examples.Add(new()); + sense.Examples.Add(new()); + Assert.That(IsProtected(sense), Is.True); + var reasons = GetProtectedReasons(sense); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Examples)); + } + + [Test] + public void SenseFieldsProtected() + { + var sense = new LiftSense(); + sense.Fields.Add(new()); + sense.Fields.Add(new()); + Assert.That(IsProtected(sense), Is.True); + var reasons = GetProtectedReasons(sense); + Assert.That(reasons, Has.Count.EqualTo(2)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Field)); + Assert.That(reasons.Last().Type, Is.EqualTo(ReasonType.Field)); + } + + [Test] + public void SenseGramInfoTraitProtected() + { + var sense = new LiftSense() + { + GramInfo = new() + }; + sense.GramInfo.Traits.Add(new()); + sense.GramInfo.Traits.Add(new()); + Assert.That(IsProtected(sense), Is.True); + var reasons = GetProtectedReasons(sense); + Assert.That(reasons, Has.Count.EqualTo(2)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.GramInfoTrait)); + Assert.That(reasons.Last().Type, Is.EqualTo(ReasonType.GramInfoTrait)); + } + + [Test] + public void SenseIllustrationsProtected() + { + var sense = new LiftSense(); + sense.Illustrations.Add(new()); + sense.Illustrations.Add(new()); + Assert.That(IsProtected(sense), Is.True); + var reasons = GetProtectedReasons(sense); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Illustrations)); + } + + [Test] + public void SenseNotesProtected() + { + var sense = new LiftSense(); + sense.Notes.Add(new()); + sense.Notes.Add(new()); + Assert.That(IsProtected(sense), Is.True); + var reasons = GetProtectedReasons(sense); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Notes)); + } + + [Test] + public void SenseRelationsProtected() + { + var sense = new LiftSense(); + sense.Relations.Add(new()); + sense.Relations.Add(new()); + Assert.That(IsProtected(sense), Is.True); + var reasons = GetProtectedReasons(sense); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Relations)); + } + + [Test] + public void SenseReversalsProtected() + { + var sense = new LiftSense(); + sense.Reversals.Add(new()); + sense.Reversals.Add(new()); + Assert.That(IsProtected(sense), Is.True); + var reasons = GetProtectedReasons(sense); + Assert.That(reasons, Has.Count.EqualTo(2)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Reversals)); + Assert.That(reasons.Last().Type, Is.EqualTo(ReasonType.Reversals)); + } + + [Test] + public void SenseSubsensesProtected() + { + var sense = new LiftSense(); + sense.Subsenses.Add(new()); + sense.Subsenses.Add(new()); + Assert.That(IsProtected(sense), Is.True); + var reasons = GetProtectedReasons(sense); + Assert.That(reasons, Has.Count.EqualTo(1)); + Assert.That(reasons.First().Type, Is.EqualTo(ReasonType.Subsenses)); + } + } +} diff --git a/Backend/Helper/LiftHelper.cs b/Backend/Helper/LiftHelper.cs index 06c8788bf8..621dbae536 100644 --- a/Backend/Helper/LiftHelper.cs +++ b/Backend/Helper/LiftHelper.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.Serialization; +using BackendFramework.Models; using SIL.Lift.Parsing; namespace BackendFramework.Helper @@ -16,6 +18,25 @@ protected InvalidLiftFileException(SerializationInfo info, StreamingContext cont } + public static class TraitNames + { + public const string AnthroCode = "anthrocode"; + public const string DialectLabels = "dialectlabels"; + public const string DomainType = "domaintype"; + public const string DoNotPublishIn = "donotpublishin"; + public const string DoNotUseForParsing = "donotuseforparsing"; + public const string EntryType = "entrytype"; + public const string ExcludeAsHeadWord = "excludeasheadword"; + public const string MinorEntryCondition = "minorentrycondition"; + public const string MorphType = "morphtype"; + public const string PublishIn = "publishin"; + public const string SemanticDomain = "semanticdomain"; + public const string SemanticDomainDdp4 = "semanticdomainddp4"; + public const string SenseType = "sensetype"; + public const string Status = "status"; + public const string UsageType = "usagetype"; + } + public static class LiftHelper { public static string GetLiftRootFromExtractedZip(string dirPath) @@ -57,28 +78,178 @@ public static string GetLiftRootFromExtractedZip(string dirPath) return extractedLiftRootPath; } - /// - /// Determine if a has any data not handled by The Combine. - /// + /// Determine if a has any data not handled by The Combine. public static bool IsProtected(LiftEntry entry) { return entry.Annotations.Count > 0 || entry.Etymologies.Count > 0 || entry.Fields.Count > 0 || (entry.Notes.Count == 1 && !string.IsNullOrEmpty(entry.Notes.First().Type)) || - entry.Notes.Count > 1 || entry.Pronunciations.Count > 0 || entry.Relations.Count > 0 || - entry.Traits.Any(t => !t.Value.Equals("stem", StringComparison.OrdinalIgnoreCase)) || + entry.Notes.Count > 1 || entry.Relations.Count > 0 || + entry.Traits.Any(t => !t.Value.Equals("stem", StringComparison.OrdinalIgnoreCase) || + !t.Name.Replace("-", "").Equals(TraitNames.MorphType, StringComparison.OrdinalIgnoreCase)) || entry.Variants.Count > 0; } - /// - /// Determine if a has any data not handled by The Combine. - /// + /// Determine what data is not handled by The Combine. + public static List GetProtectedReasons(LiftEntry entry) + { + var reasons = new List(); + if (entry.Annotations.Count > 0) + { + reasons.Add(new() { Type = ReasonType.Annotations, Count = entry.Annotations.Count }); + } + if (entry.Etymologies.Count > 0) + { + reasons.Add(new() { Type = ReasonType.Etymologies, Count = entry.Etymologies.Count }); + } + entry.Fields.ForEach(f => + { + reasons.Add(new() { Type = ReasonType.Field, Value = f.Type }); + }); + if (entry.Notes.Count == 1 && !string.IsNullOrEmpty(entry.Notes.First().Type)) + { + reasons.Add(new() { Type = ReasonType.NoteWithType, Value = entry.Notes.First().Type }); + } + if (entry.Notes.Count > 1) + { + reasons.Add(new() { Type = ReasonType.Notes, Count = entry.Notes.Count }); + } + if (entry.Relations.Count > 0) + { + reasons.Add(new() { Type = ReasonType.Relations, Count = entry.Relations.Count }); + } + entry.Traits.ForEach(t => + { + // FieldWorks > Src/LexText/LexTextControls/LiftMerger.cs > ProcessEntryTraits() + // FieldWorks > Src/LexText/LexTextControls/LiftExporter.cs > RangeNames + switch (t.Name.Replace("-", "").ToLowerInvariant()) + { + case TraitNames.DialectLabels: + reasons.Add(new() { Type = ReasonType.TraitDialectLabels, Value = t.Value }); + break; + case TraitNames.DoNotPublishIn: + reasons.Add(new() { Type = ReasonType.TraitDoNotPublishIn, Value = t.Value }); + break; + case TraitNames.DoNotUseForParsing: + reasons.Add(new() { Type = ReasonType.TraitDoNotUseForParsing, Value = t.Value }); + break; + case TraitNames.EntryType: + reasons.Add(new() { Type = ReasonType.TraitEntryType, Value = t.Value }); + break; + case TraitNames.ExcludeAsHeadWord: + reasons.Add(new() { Type = ReasonType.TraitExcludeAsHeadword }); + break; + case TraitNames.MinorEntryCondition: + reasons.Add(new() { Type = ReasonType.TraitMinorEntryCondition, Value = t.Value }); + break; + case TraitNames.MorphType: + if (!t.Value.Equals("stem", StringComparison.OrdinalIgnoreCase)) + { + reasons.Add(new() { Type = ReasonType.TraitMorphType, Value = t.Value }); + } + break; + case TraitNames.PublishIn: + reasons.Add(new() { Type = ReasonType.TraitPublishIn, Value = t.Value }); + break; + default: + reasons.Add(new() { Type = ReasonType.Trait, Value = $"{t.Name} \"{t.Value}\"" }); + break; + } + }); + if (entry.Variants.Count > 0) + { + reasons.Add(new() { Type = ReasonType.Variants, Count = entry.Variants.Count }); + } + + return reasons; + } + + /// Determine if a has any data not handled by The Combine. public static bool IsProtected(LiftSense sense) { - return sense.Examples.Count > 0 || sense.Fields.Count > 0 || + return sense.Annotations.Count > 0 || sense.Examples.Count > 0 || sense.Fields.Count > 0 || sense.GramInfo is not null && sense.GramInfo.Traits.Count > 0 || sense.Illustrations.Count > 0 || sense.Notes.Count > 0 || sense.Relations.Count > 0 || sense.Reversals.Count > 0 || sense.Subsenses.Count > 0 || - (sense.Traits.Any(t => !t.Name.StartsWith("semantic-domain", StringComparison.OrdinalIgnoreCase))); + sense.Traits.Any( + t => !t.Name.Replace("-", "").StartsWith("semanticdomain", StringComparison.OrdinalIgnoreCase)); + } + + /// Determine what data is not handled by The Combine. + public static List GetProtectedReasons(LiftSense sense) + { + var reasons = new List(); + if (sense.Annotations.Count > 0) + { + reasons.Add(new() { Type = ReasonType.Annotations, Count = sense.Annotations.Count }); + } + if (sense.Examples.Count > 0) + { + reasons.Add(new() { Type = ReasonType.Examples, Count = sense.Examples.Count }); + } + sense.Fields.ForEach(f => + { + reasons.Add(new() { Type = ReasonType.Field, Value = f.Type }); + }); + sense.GramInfo?.Traits.ForEach(t => + { + reasons.Add(new() { Type = ReasonType.GramInfoTrait, Value = t.Name }); + }); + if (sense.Illustrations.Count > 0) + { + reasons.Add(new() { Type = ReasonType.Illustrations, Count = sense.Illustrations.Count }); + } + if (sense.Notes.Count > 0) + { + reasons.Add(new() { Type = ReasonType.Notes, Count = sense.Notes.Count }); + } + if (sense.Relations.Count > 0) + { + reasons.Add(new() { Type = ReasonType.Relations, Count = sense.Relations.Count }); + } + sense.Reversals.ForEach(r => + { + reasons.Add(new() { Type = ReasonType.Reversals, Value = r.Type }); + }); + if (sense.Subsenses.Count > 0) + { + reasons.Add(new() { Type = ReasonType.Subsenses, Count = sense.Subsenses.Count }); + } + sense.Traits.ForEach(t => + { + // FieldWorks > Src/LexText/LexTextControls/LiftMerger.cs > ProcessSenseTraits() + // FieldWorks > Src/LexText/LexTextControls/LiftExporter.cs > RangeNames + switch (t.Name.Replace("-", "").ToLowerInvariant()) + { + case TraitNames.AnthroCode: + reasons.Add(new() { Type = ReasonType.TraitAnthroCode, Value = t.Value }); + break; + case TraitNames.DomainType: + reasons.Add(new() { Type = ReasonType.TraitDomainType, Value = t.Value }); + break; + case TraitNames.DoNotPublishIn: + reasons.Add(new() { Type = ReasonType.TraitDoNotPublishIn, Value = t.Value }); + break; + case TraitNames.PublishIn: + reasons.Add(new() { Type = ReasonType.TraitPublishIn, Value = t.Value }); + break; + case TraitNames.SemanticDomain: + case TraitNames.SemanticDomainDdp4: + break; + case TraitNames.SenseType: + reasons.Add(new() { Type = ReasonType.TraitSenseType, Value = t.Value }); + break; + case TraitNames.Status: + reasons.Add(new() { Type = ReasonType.TraitStatus, Value = t.Value }); + break; + case TraitNames.UsageType: + reasons.Add(new() { Type = ReasonType.TraitUsageType, Value = t.Value }); + break; + default: + reasons.Add(new() { Type = ReasonType.Trait, Value = $"{t.Name} ({t.Value})" }); + break; + } + }); + return reasons; } } } diff --git a/Backend/Models/ProtectReason.cs b/Backend/Models/ProtectReason.cs new file mode 100644 index 0000000000..8af151efbf --- /dev/null +++ b/Backend/Models/ProtectReason.cs @@ -0,0 +1,79 @@ +using System; +using System.ComponentModel.DataAnnotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BackendFramework.Models +{ + public enum ReasonType + { + Annotations, + Etymologies, + Examples, + Field, + GramInfoTrait, + Illustrations, + NoteWithType, + Notes, + Relations, + Reversals, + Subsenses, + Trait, + TraitAnthroCode, + TraitDialectLabels, + TraitDomainType, + TraitDoNotPublishIn, + TraitDoNotUseForParsing, + TraitEntryType, + TraitExcludeAsHeadword, + TraitMinorEntryCondition, + TraitMorphType, + TraitPublishIn, + TraitSenseType, + TraitStatus, + TraitUsageType, + Variants, + }; + + public class ProtectReason + { + [Required] + [BsonElement("type")] + [BsonRepresentation(BsonType.String)] + public ReasonType Type { get; set; } + + [BsonElement("count")] + public int? Count { get; set; } + + [BsonElement("value")] + public string? Value { get; set; } + + public ProtectReason Clone() + { + return new ProtectReason + { + Type = Type, + Count = Count, + Value = Value, + }; + } + + public override bool Equals(object? obj) + { + if (obj is not ProtectReason other || GetType() != obj.GetType()) + { + return false; + } + + return + Type == other.Type && Count == other.Count && + ((Value is null && other.Value is null) || + (Value is not null && Value.Equals(other.Value, StringComparison.Ordinal))); + } + + public override int GetHashCode() + { + return HashCode.Combine(Type, Count, Value); + } + } +} diff --git a/Backend/Models/Sense.cs b/Backend/Models/Sense.cs index e5d7a6e8f1..9f18baad47 100644 --- a/Backend/Models/Sense.cs +++ b/Backend/Models/Sense.cs @@ -37,6 +37,9 @@ public class Sense [BsonElement("Glosses")] public List Glosses { get; set; } + [BsonElement("protectReasons")] + public List ProtectReasons { get; set; } + [Required] [BsonElement("SemanticDomains")] public List SemanticDomains { get; set; } @@ -49,6 +52,7 @@ public Sense() GrammaticalInfo = new GrammaticalInfo(); Definitions = new List(); Glosses = new List(); + ProtectReasons = new List(); SemanticDomains = new List(); } @@ -61,6 +65,7 @@ public Sense Clone() GrammaticalInfo = GrammaticalInfo.Clone(), Definitions = new List(), Glosses = new List(), + ProtectReasons = new List(), SemanticDomains = new List(), }; @@ -72,6 +77,10 @@ public Sense Clone() { clone.Glosses.Add(gloss.Clone()); } + foreach (var reason in ProtectReasons) + { + clone.ProtectReasons.Add(reason.Clone()); + } foreach (var sd in SemanticDomains) { clone.SemanticDomains.Add(sd.Clone()); @@ -95,13 +104,16 @@ public override bool Equals(object? obj) other.Definitions.All(Definitions.Contains) && other.Glosses.Count == Glosses.Count && other.Glosses.All(Glosses.Contains) && + other.ProtectReasons.Count == ProtectReasons.Count && + other.ProtectReasons.All(ProtectReasons.Contains) && other.SemanticDomains.Count == SemanticDomains.Count && other.SemanticDomains.All(SemanticDomains.Contains); } public override int GetHashCode() { - return HashCode.Combine(Guid, Accessibility, GrammaticalInfo, Definitions, Glosses, SemanticDomains); + return HashCode.Combine( + Guid, Accessibility, GrammaticalInfo, Definitions, Glosses, ProtectReasons, SemanticDomains); } public bool IsEmpty() diff --git a/Backend/Models/Word.cs b/Backend/Models/Word.cs index 97cd1838ab..2a5e03e917 100644 --- a/Backend/Models/Word.cs +++ b/Backend/Models/Word.cs @@ -54,6 +54,9 @@ public class Word [BsonRepresentation(BsonType.String)] public Status Accessibility { get; set; } + [BsonElement("protectReasons")] + public List ProtectReasons { get; set; } + [Required] [BsonElement("history")] public List History { get; set; } @@ -94,6 +97,7 @@ public Word() Audio = new List(); EditedBy = new List(); History = new List(); + ProtectReasons = new List(); Senses = new List(); Note = new Note(); Flag = new Flag(); @@ -115,6 +119,7 @@ public Word Clone() Audio = new List(), EditedBy = new List(), History = new List(), + ProtectReasons = new List(), Senses = new List(), Note = Note.Clone(), Flag = Flag.Clone(), @@ -132,6 +137,10 @@ public Word Clone() { clone.History.Add(id); } + foreach (var reason in ProtectReasons) + { + clone.ProtectReasons.Add(reason.Clone()); + } foreach (var sense in Senses) { clone.Senses.Add(sense.Clone()); @@ -151,6 +160,9 @@ public bool ContentEquals(Word other) other.Audio.Count == Audio.Count && other.Audio.All(Audio.Contains) && + other.ProtectReasons.Count == ProtectReasons.Count && + other.ProtectReasons.All(ProtectReasons.Contains) && + other.Senses.Count == Senses.Count && other.Senses.All(Senses.Contains) && @@ -189,6 +201,7 @@ public override int GetHashCode() hash.Add(Created); hash.Add(Modified); hash.Add(Accessibility); + hash.Add(ProtectReasons); hash.Add(History); hash.Add(EditedBy); hash.Add(OtherField); diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index 6075a40ae8..9acb4a412d 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -742,6 +742,7 @@ public void FinishEntry(LiftEntry entry) if (LiftHelper.IsProtected(entry)) { newWord.Accessibility = Status.Protected; + newWord.ProtectReasons = LiftHelper.GetProtectedReasons(entry); } // Add Note if one exists. @@ -796,6 +797,7 @@ public void FinishEntry(LiftEntry entry) if (LiftHelper.IsProtected(sense)) { newSense.Accessibility = Status.Protected; + newSense.ProtectReasons = LiftHelper.GetProtectedReasons(sense); } // Add definitions diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 5c4f5803e5..a10f5a2708 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -411,8 +411,39 @@ "noDups": "Nothing to merge.", "delete": "Delete sense", "deleteDialog": "Delete this sense?", - "protectedSense": "This sense was imported with data that The Combine doesn't handle, so it cannot be deleted or dropped into another sense. You may still move this sense to another word or drop other senses into this one to merge them.", - "protectedWord": "This word was imported with data that The Combine doesn't handle, so to prevent deletion, its final sense cannot be removed." + "protectedSense": "This sense was imported with data that The Combine doesn't handle.", + "protectedSenseInfo": "This sense cannot be deleted or dropped into another sense. You may still move it to another word or drop other senses into this one to merge them.", + "protectedWord": "This word was imported with data that The Combine doesn't handle.", + "protectedWordInfo": "To prevent deletion, the final sense of this word cannot be removed.", + "protectedData": "Protected data: {{ val }}" + }, + "protectReason": { + "annotations": "annotations", + "etymologies": "etymologies", + "examples": "examples", + "field": "\"{{ val }}\" field", + "gramInfoTrait": "grammatical info \"{{ val }}\"", + "illustrations": "illustrations", + "noteWithType": "note with type \"{{ val }}\"", + "notesSense": "notes", + "notesWord": "more than 1 note", + "relations": "relations", + "reversal": "\"{{ val }}\" reversal", + "subsenses": "subsenses", + "traitAnthroCode": "anthro code \"{{ val }}\"", + "traitDialectLabels": "dialect labels \"{{ val }}\"", + "traitDomainType": "domain type \"{{ val }}\"", + "traitDoNotPublishIn": "do not publish in \"{{ val }}\"", + "traitDoNotUseForParsing": "do not use for parsing \"{{ val }}\"", + "traitEntryType": "entry type \"{{ val }}\"", + "traitExcludeAsHeadword": "exclude as headword", + "traitMinorEntryCondition": "minor entry condition \"{{ val }}\"", + "traitMorphType": "morph type \"{{ val }}\" (rather than default \"stem\")", + "traitPublishIn": "publish in \"{{ val }}\"", + "traitSenseType": "sense type \"{{ val }}\"", + "traitStatus": "status \"{{ val }}\"", + "traitUsageType": "usage type \"{{ val }}\"", + "variants": "variants" }, "completed": { "number": "Number of merges completed: " diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index 58f54943ea..873eb5b3b5 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -48,6 +48,8 @@ models/permission.ts models/project-role.ts models/project.ts models/pronunciation.ts +models/protect-reason.ts +models/reason-type.ts models/role.ts models/semantic-domain-count.ts models/semantic-domain-full.ts diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 33539e6fa0..5b140bd273 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -24,6 +24,8 @@ export * from "./permission"; export * from "./project"; export * from "./project-role"; export * from "./pronunciation"; +export * from "./protect-reason"; +export * from "./reason-type"; export * from "./role"; export * from "./semantic-domain"; export * from "./semantic-domain-count"; diff --git a/src/api/models/protect-reason.ts b/src/api/models/protect-reason.ts new file mode 100644 index 0000000000..be298a97d3 --- /dev/null +++ b/src/api/models/protect-reason.ts @@ -0,0 +1,41 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { ReasonType } from "./reason-type"; + +/** + * + * @export + * @interface ProtectReason + */ +export interface ProtectReason { + /** + * + * @type {ReasonType} + * @memberof ProtectReason + */ + type: ReasonType; + /** + * + * @type {number} + * @memberof ProtectReason + */ + count?: number | null; + /** + * + * @type {string} + * @memberof ProtectReason + */ + value?: string | null; +} diff --git a/src/api/models/reason-type.ts b/src/api/models/reason-type.ts new file mode 100644 index 0000000000..f482af4661 --- /dev/null +++ b/src/api/models/reason-type.ts @@ -0,0 +1,47 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @enum {string} + */ +export enum ReasonType { + Annotations = "Annotations", + Etymologies = "Etymologies", + Examples = "Examples", + Field = "Field", + GramInfoTrait = "GramInfoTrait", + Illustrations = "Illustrations", + NoteWithType = "NoteWithType", + Notes = "Notes", + Relations = "Relations", + Reversals = "Reversals", + Subsenses = "Subsenses", + Trait = "Trait", + TraitAnthroCode = "TraitAnthroCode", + TraitDialectLabels = "TraitDialectLabels", + TraitDomainType = "TraitDomainType", + TraitDoNotPublishIn = "TraitDoNotPublishIn", + TraitDoNotUseForParsing = "TraitDoNotUseForParsing", + TraitEntryType = "TraitEntryType", + TraitExcludeAsHeadword = "TraitExcludeAsHeadword", + TraitMinorEntryCondition = "TraitMinorEntryCondition", + TraitMorphType = "TraitMorphType", + TraitPublishIn = "TraitPublishIn", + TraitSenseType = "TraitSenseType", + TraitStatus = "TraitStatus", + TraitUsageType = "TraitUsageType", + Variants = "Variants", +} diff --git a/src/api/models/sense.ts b/src/api/models/sense.ts index d0f585a447..622cd09cf5 100644 --- a/src/api/models/sense.ts +++ b/src/api/models/sense.ts @@ -15,6 +15,7 @@ import { Definition } from "./definition"; import { Gloss } from "./gloss"; import { GrammaticalInfo } from "./grammatical-info"; +import { ProtectReason } from "./protect-reason"; import { SemanticDomain } from "./semantic-domain"; import { Status } from "./status"; @@ -42,6 +43,12 @@ export interface Sense { * @memberof Sense */ grammaticalInfo: GrammaticalInfo; + /** + * + * @type {Array} + * @memberof Sense + */ + protectReasons?: Array | null; /** * * @type {Array} diff --git a/src/api/models/word.ts b/src/api/models/word.ts index 94b4e41c1c..9baf004482 100644 --- a/src/api/models/word.ts +++ b/src/api/models/word.ts @@ -15,6 +15,7 @@ import { Flag } from "./flag"; import { Note } from "./note"; import { Pronunciation } from "./pronunciation"; +import { ProtectReason } from "./protect-reason"; import { Sense } from "./sense"; import { Status } from "./status"; @@ -78,6 +79,12 @@ export interface Word { * @memberof Word */ accessibility: Status; + /** + * + * @type {Array} + * @memberof Word + */ + protectReasons?: Array | null; /** * * @type {Array} diff --git a/src/components/MultilineTooltipTitle/index.tsx b/src/components/MultilineTooltipTitle/index.tsx new file mode 100644 index 0000000000..c4ebf98e88 --- /dev/null +++ b/src/components/MultilineTooltipTitle/index.tsx @@ -0,0 +1,7 @@ +import { ReactElement } from "react"; + +export default function MultilineTooltipTitle(props: { + lines: string[]; +}): ReactElement { + return
{props.lines.join("\n")}
; +} diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index e917890e80..533bb2bb71 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -22,6 +22,7 @@ import { Pronunciation, Speaker } from "api/models"; import { getSpeaker } from "backend"; import { SpeakerMenuList } from "components/AppBar/SpeakerMenu"; import { ButtonConfirmation } from "components/Dialogs"; +import MultilineTooltipTitle from "components/MultilineTooltipTitle"; import { playing, resetPronunciations, @@ -165,13 +166,12 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { ); } - const multilineTooltipText = (lines: string[]): ReactElement => ( -
{lines.join("\n")}
- ); - return ( <> - + } + placement="top" + > state.mergeDuplicateGoal.data ); + const { t } = useTranslation(); + const dispatchFlagWord = (flag: Flag): void => { dispatch(flagWord({ wordId: props.wordId, flag })); }; @@ -148,6 +151,71 @@ export function DropWordCardHeader(
); + const reasonText = (reason: ProtectReason): string => { + // Backend/Helper/LiftHelper.cs > GetProtectedReasons(LiftEntry entry) + switch (reason.type) { + case ReasonType.Annotations: + return t("mergeDups.protectReason.annotations"); + case ReasonType.Etymologies: + return t("mergeDups.protectReason.etymologies"); + case ReasonType.Field: + return t("mergeDups.protectReason.field", { val: reason.value }); + case ReasonType.NoteWithType: + return t("mergeDups.protectReason.noteWithType", { val: reason.value }); + case ReasonType.Notes: + return t("mergeDups.protectReason.notesWord"); + case ReasonType.Relations: + return t("mergeDups.protectReason.relations"); + case ReasonType.Trait: + return reason.value ?? "(unknown trait)"; + case ReasonType.TraitDialectLabels: + return t("mergeDups.protectReason.traitDialectLabels", { + val: reason.value, + }); + case ReasonType.TraitDoNotPublishIn: + return t("mergeDups.protectReason.traitDoNotPublishIn", { + val: reason.value, + }); + case ReasonType.TraitDoNotUseForParsing: + return t("mergeDups.protectReason.traitDoNotUseForParsing", { + val: reason.value, + }); + case ReasonType.TraitEntryType: + return t("mergeDups.protectReason.traitEntryType", { + val: reason.value, + }); + case ReasonType.TraitExcludeAsHeadword: + return t("mergeDups.protectReason.traitExcludeAsHeadword"); + case ReasonType.TraitMinorEntryCondition: + return t("mergeDups.protectReason.traitMinorEntryCondition", { + val: reason.value, + }); + case ReasonType.TraitMorphType: + return t("mergeDups.protectReason.traitMorphType", { + val: reason.value, + }); + case ReasonType.TraitPublishIn: + return t("mergeDups.protectReason.traitPublishIn", { + val: reason.value, + }); + case ReasonType.Variants: + return t("mergeDups.protectReason.variants"); + default: + throw new Error(); + } + }; + + const tooltipTexts = [t("mergeDups.helpText.protectedWord")]; + const reasons = words[props.wordId]?.protectReasons; + if (reasons?.length) { + tooltipTexts.push( + t("mergeDups.helpText.protectedData", { + val: reasons.map(reasonText).join("; "), + }) + ); + } + tooltipTexts.push(t("mergeDups.helpText.protectedWordInfo")); + const headerAction = treeWord ? ( <> {props.protectedWithOneChild && ( @@ -156,7 +224,7 @@ export function DropWordCardHeader( icon={} side="top" size="small" - textId="mergeDups.helpText.protectedWord" + text={} /> )} @@ -27,12 +37,79 @@ export default function SenseCardContent( ) ), ]; - const protectedWarning = - !props.sidebar && props.senses[0].accessibility === Status.Protected; const gramInfo = props.senses .map((s) => s.grammaticalInfo) .find((g) => g.catGroup !== GramCatGroup.Unspecified); + const reasonText = (reason: ProtectReason): string => { + // Backend/Helper/LiftHelper.cs > GetProtectedReasons(LiftSense sense) + switch (reason.type) { + case ReasonType.Annotations: + return t("mergeDups.protectReason.annotations"); + case ReasonType.Examples: + return t("mergeDups.protectReason.examples"); + case ReasonType.Field: + return t("mergeDups.protectReason.field", { val: reason.value }); + case ReasonType.GramInfoTrait: + return t("mergeDups.protectReason.gramInfoTrait", { + val: reason.value, + }); + case ReasonType.Illustrations: + return t("mergeDups.protectReason.illustrations"); + case ReasonType.Notes: + return t("mergeDups.protectReason.notesSense"); + case ReasonType.Relations: + return t("mergeDups.protectReason.relations"); + case ReasonType.Reversals: + return t("mergeDups.protectReason.reversal", { val: reason.value }); + case ReasonType.Subsenses: + return t("mergeDups.protectReason.subsenses"); + case ReasonType.Trait: + return reason.value ?? "(unknown trait)"; + case ReasonType.TraitAnthroCode: + return t("mergeDups.protectReason.traitAnthroCode", { + val: reason.value, + }); + case ReasonType.TraitDomainType: + return t("mergeDups.protectReason.traitDomainType", { + val: reason.value, + }); + case ReasonType.TraitDoNotPublishIn: + return t("mergeDups.protectReason.traitDoNotPublishIn", { + val: reason.value, + }); + case ReasonType.TraitPublishIn: + return t("mergeDups.protectReason.traitPublishIn", { + val: reason.value, + }); + case ReasonType.TraitSenseType: + return t("mergeDups.protectReason.traitSenseType", { + val: reason.value, + }); + case ReasonType.TraitStatus: + return t("mergeDups.protectReason.traitStatus", { val: reason.value }); + case ReasonType.TraitUsageType: + return t("mergeDups.protectReason.traitUsageType", { + val: reason.value, + }); + default: + throw new Error(); + } + }; + + const protectedWarning = + !props.sidebar && props.senses[0].accessibility === Status.Protected; + const tooltipTexts = [t("mergeDups.helpText.protectedSense")]; + const reasons = props.senses[0]?.protectReasons; + if (reasons?.length) { + tooltipTexts.push( + t("mergeDups.helpText.protectedData", { + val: reasons.map(reasonText).join("; "), + }) + ); + } + tooltipTexts.push(t("mergeDups.helpText.protectedSenseInfo")); + return ( {/* Icon for part of speech (if any). */} @@ -52,7 +129,7 @@ export default function SenseCardContent( icon={} side="top" size="small" - textId="mergeDups.helpText.protectedSense" + text={} buttonId={`sense-${props.senses[0].guid}-protected`} /> )}