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;
+ }
}
}