diff --git a/RMuseum/RMuseum.xml b/RMuseum/RMuseum.xml index 2eddeb0a..498a5c90 100644 --- a/RMuseum/RMuseum.xml +++ b/RMuseum/RMuseum.xml @@ -11952,6 +11952,15 @@ use one of the other types instead whenever an actual relationship is known + + + Person1 killed Person2 (کشتن) - directional. Deliberately separate from Rival: an act of + killing is usually a single event (e.g. in battle, by treachery, in a duel), and doesn't by + itself mean the two were ongoing enemies/rivals beforehand - they could have been allies, + kin-by-marriage, or strangers. Use Rival instead (additionally, if applicable) when there + was a standing enmity, and Note for how/why the killing happened + + doesn't fit any of the above - rely on Note for what the tie actually is @@ -22206,6 +22215,64 @@ + + + finds another person already carrying a non-null FamilyTreeCaption within the same + connected kinship component as personId (if any). Captions are resolved per requested + person, not stored on the tree itself, so nothing stops two different people in one + connected component from each getting their own caption - which would silently produce + two separate "family tree" list entries (GetFamilyTreeRootsAsync) that both open to the + exact same graph. This is used to catch that at approval time rather than let it happen + silently; it only ever flags an OTHER person, so re-saving/editing the caption a person + already uniquely holds in their own tree is never blocked by it. + + + + + loads every directed ancestor-type kinship edge (RelationType Parent or Ancestor, + Person1 = ancestor, Person2 = descendant) currently in the live graph, optionally + excluding one relation row by id (used so a Modify suggestion can be checked against + every OTHER edge without tripping on the very row it's about to replace) + + + + + true if adding a directed ancestor-type edge ancestorId -> descendantId (ancestorId + becomes a parent/ancestor of descendantId) would create a cycle in the kinship graph - + i.e. descendantId is already (directly or transitively) an ancestor of ancestorId, or + they're literally the same person. Without this, nothing stops e.g. approving "A is + parent of B" and later "B is parent of A", which familytree.js's unguarded recursive + layout() would then infinite-loop on when rendering that tree. + + + + + counts this child's distinct existing Parent-type edges (biological parents), optionally + excluding one relation row by id - used to cap a person at two recorded parents, since + familytree.js's buildLayout only ever attaches the first two it sorts to the front and + silently drops any further ones with no error + + + + + returns an existing kinship edge between this unordered pair (if any), other than + excludeRelationId, whose RelationType differs from proposedType - used to stop a pair + from simultaneously carrying two contradictory family relations (e.g. Parent AND Spouse, + or Parent AND Sibling, between the very same two people) + + + + + runs every family-relation data-integrity check (self-reference is checked separately by + the caller for Add) that applies to adding/changing a kinship edge of relationType between + person1Id and person2Id: ancestry cycles, the two-parents cap, and contradictory relation + types already existing between the same pair. Returns a Persian error message, or null if + the edge is fine to create/apply. Shared by SuggestPersonRelationEditAsync (so a + contradictory suggestion is rejected up front) and ModeratePersonRelationEditSuggestionAsync + (so it's still caught even if another suggestion was approved in the meantime, or the + submission-time check is ever bypassed). + + submit a suggested edit to an already-approved person's own fields @@ -22935,6 +23002,17 @@ SuggestedPersonGraphJson value - see its doc comment for the expected shape + + + true if adding a directed ancestor-type edge ancestorId -> descendantId (ancestorId + becomes a parent/ancestor of descendantId) would create a cycle, counting both the + already-live kinship graph AND any ancestor-type edges added earlier in the same + in-progress _MaterializePersonGraphAsync batch (extraEdges) - mirrors + GanjoorRelatedPersonService's own _WouldCreateAncestryCycleAsync, duplicated here since + this runs in a different service/DbContext and also has to account for not-yet-saved, + same-batch edges that the other copy never needs to. + + moderate poem section correction diff --git a/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs b/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs index e6d15a0a..5dc1461b 100644 --- a/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs +++ b/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs @@ -280,6 +280,203 @@ namespace RMuseum.Services.Implementation } } + /// + /// finds another person already carrying a non-null FamilyTreeCaption within the same + /// connected kinship component as personId (if any). Captions are resolved per requested + /// person, not stored on the tree itself, so nothing stops two different people in one + /// connected component from each getting their own caption - which would silently produce + /// two separate "family tree" list entries (GetFamilyTreeRootsAsync) that both open to the + /// exact same graph. This is used to catch that at approval time rather than let it happen + /// silently; it only ever flags an OTHER person, so re-saving/editing the caption a person + /// already uniquely holds in their own tree is never blocked by it. + /// + private async Task _FindOtherFamilyTreeCaptionHolderInComponentAsync(int personId) + { + var allRelations = await _context.GanjoorPersonRelations.ToListAsync(); + var edgesByPersonId = new Dictionary>(); + void IndexEdge(int pid, GanjoorPersonRelation edge) + { + if (!edgesByPersonId.TryGetValue(pid, out var list)) + { + list = new List(); + edgesByPersonId[pid] = list; + } + list.Add(edge); + } + foreach (var edge in allRelations) + { + IndexEdge(edge.Person1Id, edge); + IndexEdge(edge.Person2Id, edge); + } + + var visited = new HashSet() { personId }; + var queue = new Queue(); + queue.Enqueue(personId); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!edgesByPersonId.TryGetValue(current, out var touchingEdges)) + continue; + foreach (var edge in touchingEdges) + { + var otherPersonId = edge.Person1Id == current ? edge.Person2Id : edge.Person1Id; + if (visited.Add(otherPersonId)) + { + queue.Enqueue(otherPersonId); + } + } + } + visited.Remove(personId); + + if (visited.Count == 0) + return null; + + return await _context.GanjoorRelatedPersons + .Where(p => visited.Contains(p.Id) && !string.IsNullOrEmpty(p.FamilyTreeCaption)) + .FirstOrDefaultAsync(); + } + + /// + /// loads every directed ancestor-type kinship edge (RelationType Parent or Ancestor, + /// Person1 = ancestor, Person2 = descendant) currently in the live graph, optionally + /// excluding one relation row by id (used so a Modify suggestion can be checked against + /// every OTHER edge without tripping on the very row it's about to replace) + /// + private async Task> _GetAncestorEdgesAsync(int? excludeRelationId) + { + var query = _context.GanjoorPersonRelations + .Where(r => r.RelationType == PersonRelationType.Parent || r.RelationType == PersonRelationType.Ancestor); + if (excludeRelationId != null) + { + query = query.Where(r => r.Id != excludeRelationId.Value); + } + return await query.ToListAsync(); + } + + /// + /// true if adding a directed ancestor-type edge ancestorId -> descendantId (ancestorId + /// becomes a parent/ancestor of descendantId) would create a cycle in the kinship graph - + /// i.e. descendantId is already (directly or transitively) an ancestor of ancestorId, or + /// they're literally the same person. Without this, nothing stops e.g. approving "A is + /// parent of B" and later "B is parent of A", which familytree.js's unguarded recursive + /// layout() would then infinite-loop on when rendering that tree. + /// + private async Task _WouldCreateAncestryCycleAsync(int ancestorId, int descendantId, int? excludeRelationId) + { + if (ancestorId == descendantId) + return true; + + var edges = await _GetAncestorEdgesAsync(excludeRelationId); + var childrenOf = new Dictionary>(); + foreach (var edge in edges) + { + if (!childrenOf.TryGetValue(edge.Person1Id, out var list)) + { + list = new List(); + childrenOf[edge.Person1Id] = list; + } + list.Add(edge.Person2Id); + } + + // walk forward from descendantId: if it can already reach ancestorId through existing + // edges, descendantId is already an ancestor of ancestorId, so the new edge would close a loop + var visited = new HashSet() { descendantId }; + var queue = new Queue(); + queue.Enqueue(descendantId); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current == ancestorId) + return true; + if (!childrenOf.TryGetValue(current, out var children)) + continue; + foreach (var child in children) + { + if (visited.Add(child)) + { + queue.Enqueue(child); + } + } + } + return false; + } + + /// + /// counts this child's distinct existing Parent-type edges (biological parents), optionally + /// excluding one relation row by id - used to cap a person at two recorded parents, since + /// familytree.js's buildLayout only ever attaches the first two it sorts to the front and + /// silently drops any further ones with no error + /// + private async Task _CountParentsAsync(int childId, int? excludeRelationId) + { + var query = _context.GanjoorPersonRelations + .Where(r => r.RelationType == PersonRelationType.Parent && r.Person2Id == childId); + if (excludeRelationId != null) + { + query = query.Where(r => r.Id != excludeRelationId.Value); + } + return await query.Select(r => r.Person1Id).Distinct().CountAsync(); + } + + /// + /// returns an existing kinship edge between this unordered pair (if any), other than + /// excludeRelationId, whose RelationType differs from proposedType - used to stop a pair + /// from simultaneously carrying two contradictory family relations (e.g. Parent AND Spouse, + /// or Parent AND Sibling, between the very same two people) + /// + private async Task _GetConflictingRelationAsync(int person1Id, int person2Id, PersonRelationType proposedType, int? excludeRelationId) + { + var query = _context.GanjoorPersonRelations + .Include(r => r.Person1) + .Include(r => r.Person2) + .Where(r => + ((r.Person1Id == person1Id && r.Person2Id == person2Id) || (r.Person1Id == person2Id && r.Person2Id == person1Id)) + && r.RelationType != proposedType); + if (excludeRelationId != null) + { + query = query.Where(r => r.Id != excludeRelationId.Value); + } + return await query.FirstOrDefaultAsync(); + } + + /// + /// runs every family-relation data-integrity check (self-reference is checked separately by + /// the caller for Add) that applies to adding/changing a kinship edge of relationType between + /// person1Id and person2Id: ancestry cycles, the two-parents cap, and contradictory relation + /// types already existing between the same pair. Returns a Persian error message, or null if + /// the edge is fine to create/apply. Shared by SuggestPersonRelationEditAsync (so a + /// contradictory suggestion is rejected up front) and ModeratePersonRelationEditSuggestionAsync + /// (so it's still caught even if another suggestion was approved in the meantime, or the + /// submission-time check is ever bypassed). + /// + private async Task _ValidateFamilyRelationAsync(int person1Id, int person2Id, PersonRelationType relationType, int? excludeRelationId) + { + if (relationType == PersonRelationType.Parent || relationType == PersonRelationType.Ancestor) + { + if (await _WouldCreateAncestryCycleAsync(person1Id, person2Id, excludeRelationId)) + { + return "این نسبت باعث ایجاد حلقهٔ تناقض‌آمیز در شجره‌نامه می‌شود (مثلاً فردی نیای خود شناخته می‌شود). لطفاً نسبت‌های موجود بین این دو نفر و نیاکان/نوادگان آن‌ها را بررسی کنید."; + } + } + + if (relationType == PersonRelationType.Parent) + { + var existingParentsCount = await _CountParentsAsync(person2Id, excludeRelationId); + if (existingParentsCount >= 2) + { + return "این نامبرده هم‌اکنون دو پدر/مادر ثبت‌شده دارد. برای افزودن سومی، نخست یکی از نسبت‌های پدر/مادری موجود را ویرایش یا حذف کنید."; + } + } + + var conflictingRelation = await _GetConflictingRelationAsync(person1Id, person2Id, relationType, excludeRelationId); + if (conflictingRelation != null) + { + return $"هم‌اکنون نسبت خویشاوندی دیگری بین «{conflictingRelation.Person1?.Name}» و «{conflictingRelation.Person2?.Name}» ثبت شده که با نوع جدید پیشنهادی در تناقض است. لطفاً نخست آن را ویرایش یا حذف کنید."; + } + + return null; + } + /// /// submit a suggested edit to an already-approved person's own fields /// @@ -411,6 +608,16 @@ namespace RMuseum.Services.Implementation } else { + if (!string.IsNullOrWhiteSpace(suggestion.SuggestedFamilyTreeCaption)) + { + var otherCaptionHolder = await _FindOtherFamilyTreeCaptionHolderInComponentAsync(person.Id); + if (otherCaptionHolder != null) + { + return new RServiceResult(null, + $"شخصیت «{otherCaptionHolder.Name}» هم‌اکنون در همین خوشهٔ خویشاوندی (همان شجره‌نامه) عنوان تبارنامهٔ «{otherCaptionHolder.FamilyTreeCaption}» را دارد. تأیید این پیشنهاد باعث می‌شود یک شجره‌نامهٔ واحد دو عنوان/مدخل جداگانه در فهرست شجره‌نامه‌ها پیدا کند. نخست عنوان «{otherCaptionHolder.Name}» را حذف یا ویرایش کنید، یا این پیشنهاد را رد کنید."); + } + } + person.Name = suggestion.SuggestedName; person.Description = suggestion.SuggestedDescription; person.WikiUrl = suggestion.SuggestedWikiUrl; @@ -679,6 +886,17 @@ namespace RMuseum.Services.Implementation return new RServiceResult(null, "یکی از دو طرف نسبت پیدا نشد."); } + if (suggestion.Kind == PersonRelationSuggestionKind.Family && + (suggestion.Action == PersonRelationSuggestionAction.Add || suggestion.Action == PersonRelationSuggestionAction.Modify)) + { + var excludeRelationId = suggestion.Action == PersonRelationSuggestionAction.Modify ? suggestion.ExistingRelationId : null; + var validationError = await _ValidateFamilyRelationAsync(suggestion.Person1Id, suggestion.Person2Id, suggestion.SuggestedRelationType, excludeRelationId); + if (validationError != null) + { + return new RServiceResult(null, validationError); + } + } + suggestion.Id = 0; suggestion.ExistingRelation = null; suggestion.ExistingAffiliation = null; @@ -852,15 +1070,25 @@ namespace RMuseum.Services.Implementation switch (suggestion.Action) { case PersonRelationSuggestionAction.Add: - _context.GanjoorPersonRelations.Add(new GanjoorPersonRelation() { - Person1Id = suggestion.Person1Id, - Person2Id = suggestion.Person2Id, - RelationType = suggestion.SuggestedRelationType, - DegreeHint = suggestion.SuggestedDegreeHint, - Note = suggestion.SuggestedNote, - }); - break; + // re-validated here (not just at submission time in + // SuggestPersonRelationEditAsync) in case another suggestion + // touching the same people/relations was approved in between + var validationError = await _ValidateFamilyRelationAsync(suggestion.Person1Id, suggestion.Person2Id, suggestion.SuggestedRelationType, null); + if (validationError != null) + { + return new RServiceResult(null, validationError); + } + _context.GanjoorPersonRelations.Add(new GanjoorPersonRelation() + { + Person1Id = suggestion.Person1Id, + Person2Id = suggestion.Person2Id, + RelationType = suggestion.SuggestedRelationType, + DegreeHint = suggestion.SuggestedDegreeHint, + Note = suggestion.SuggestedNote, + }); + break; + } case PersonRelationSuggestionAction.Modify: { var existing = await _context.GanjoorPersonRelations.Where(r => r.Id == suggestion.ExistingRelationId.Value).SingleOrDefaultAsync(); @@ -868,6 +1096,11 @@ namespace RMuseum.Services.Implementation { return new RServiceResult(null, "نسبت مورد نظر دیگر وجود ندارد."); } + var validationError = await _ValidateFamilyRelationAsync(suggestion.Person1Id, suggestion.Person2Id, suggestion.SuggestedRelationType, existing.Id); + if (validationError != null) + { + return new RServiceResult(null, validationError); + } existing.RelationType = suggestion.SuggestedRelationType; existing.DegreeHint = suggestion.SuggestedDegreeHint; existing.Note = suggestion.SuggestedNote; diff --git a/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-ModeratePoemCorrection.cs b/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-ModeratePoemCorrection.cs index abaaee35..4f8bdf21 100644 --- a/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-ModeratePoemCorrection.cs +++ b/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-ModeratePoemCorrection.cs @@ -944,6 +944,15 @@ namespace RMuseum.Services.Implementation // second pass: now that every node has a real id, create the edges between them if (graph.Relations != null) { + // ancestor-type (Parent/Ancestor) edges added earlier in THIS SAME batch, kept + // separately because they're not queryable from _context until the final + // SaveChangesAsync below - a single suggested-person-graph payload can easily + // describe more than one generation at once (e.g. a new person plus their new + // parent plus that parent's own already-known parent), so the cycle/parent-cap + // checks below need to see these in-flight edges too, not just what's already live + var newlyAddedAncestorEdges = new List<(int AncestorId, int DescendantId)>(); + var newlyAddedRelations = new List<(int Person1Id, int Person2Id, PersonRelationType RelationType)>(); + foreach (var relation in graph.Relations) { if (!localKeyToPersonId.TryGetValue(relation.Person1 ?? "", out int person1Id) || @@ -952,6 +961,11 @@ namespace RMuseum.Services.Implementation return new Tuple(0, "یکی از روابط پیشنهادی به شخصیتی خارج از این پیشنهاد اشاره می‌کند."); } + if (person1Id == person2Id) + { + return new Tuple(0, "دو طرف یک رابطه نمی‌توانند یک شخص باشند."); + } + if (string.Equals(relation.Kind, "affiliation", StringComparison.OrdinalIgnoreCase)) { if (!Enum.TryParse(relation.AffiliationType, out var affiliationType)) @@ -972,6 +986,40 @@ namespace RMuseum.Services.Implementation { return new Tuple(0, $"نوع رابطهٔ خویشاوندی «{relation.RelationType}» نامعتبر است."); } + + if (relationType == PersonRelationType.Parent || relationType == PersonRelationType.Ancestor) + { + if (await _WouldCreateAncestryCycleInGraphAsync(person1Id, person2Id, newlyAddedAncestorEdges)) + { + return new Tuple(0, "این رابطه باعث ایجاد حلقهٔ تناقض‌آمیز در شجره‌نامه می‌شود (مثلاً فردی نیای خود شناخته می‌شود)."); + } + } + + if (relationType == PersonRelationType.Parent) + { + var existingParentsCount = await _context.GanjoorPersonRelations + .Where(r => r.RelationType == PersonRelationType.Parent && r.Person2Id == person2Id) + .Select(r => r.Person1Id).Distinct().CountAsync(); + var newParentsCount = newlyAddedAncestorEdges.Count(e => e.DescendantId == person2Id && e.AncestorId != person1Id); + if (existingParentsCount + newParentsCount >= 2) + { + return new Tuple(0, "این نامبرده هم‌اکنون دو پدر/مادر دارد؛ نمی‌توان سومی را از طریق این برچسب افزود."); + } + } + + var conflictingExisting = await _context.GanjoorPersonRelations + .Where(r => + ((r.Person1Id == person1Id && r.Person2Id == person2Id) || (r.Person1Id == person2Id && r.Person2Id == person1Id)) + && r.RelationType != relationType) + .AnyAsync(); + var conflictingNew = newlyAddedRelations.Any(r => + ((r.Person1Id == person1Id && r.Person2Id == person2Id) || (r.Person1Id == person2Id && r.Person2Id == person1Id)) + && r.RelationType != relationType); + if (conflictingExisting || conflictingNew) + { + return new Tuple(0, "نسبت دیگری با نوع متفاوت بین همین دو نفر از قبل (یا در همین پیشنهاد) ثبت شده که با این مغایرت دارد."); + } + _context.GanjoorPersonRelations.Add(new GanjoorPersonRelation() { Person1Id = person1Id, @@ -980,6 +1028,12 @@ namespace RMuseum.Services.Implementation DegreeHint = relation.DegreeHint, Note = relation.Note, }); + + if (relationType == PersonRelationType.Parent || relationType == PersonRelationType.Ancestor) + { + newlyAddedAncestorEdges.Add((person1Id, person2Id)); + } + newlyAddedRelations.Add((person1Id, person2Id, relationType)); } } await _context.SaveChangesAsync(); @@ -987,5 +1041,64 @@ namespace RMuseum.Services.Implementation return new Tuple(localKeyToPersonId[graph.Person.LocalKey], null); } + + /// + /// true if adding a directed ancestor-type edge ancestorId -> descendantId (ancestorId + /// becomes a parent/ancestor of descendantId) would create a cycle, counting both the + /// already-live kinship graph AND any ancestor-type edges added earlier in the same + /// in-progress _MaterializePersonGraphAsync batch (extraEdges) - mirrors + /// GanjoorRelatedPersonService's own _WouldCreateAncestryCycleAsync, duplicated here since + /// this runs in a different service/DbContext and also has to account for not-yet-saved, + /// same-batch edges that the other copy never needs to. + /// + private async Task _WouldCreateAncestryCycleInGraphAsync(int ancestorId, int descendantId, List<(int AncestorId, int DescendantId)> extraEdges) + { + if (ancestorId == descendantId) + return true; + + var edges = await _context.GanjoorPersonRelations + .Where(r => r.RelationType == PersonRelationType.Parent || r.RelationType == PersonRelationType.Ancestor) + .Select(r => new { r.Person1Id, r.Person2Id }) + .ToListAsync(); + + var childrenOf = new Dictionary>(); + void AddEdge(int parent, int child) + { + if (!childrenOf.TryGetValue(parent, out var list)) + { + list = new List(); + childrenOf[parent] = list; + } + list.Add(child); + } + foreach (var edge in edges) + { + AddEdge(edge.Person1Id, edge.Person2Id); + } + foreach (var edge in extraEdges) + { + AddEdge(edge.AncestorId, edge.DescendantId); + } + + var visited = new HashSet() { descendantId }; + var queue = new Queue(); + queue.Enqueue(descendantId); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current == ancestorId) + return true; + if (!childrenOf.TryGetValue(current, out var children)) + continue; + foreach (var child in children) + { + if (visited.Add(child)) + { + queue.Enqueue(child); + } + } + } + return false; + } } }