people tags #387

This commit is contained in:
Hamid Reza Mohammadi 2026-10-04 15:22:44 +03:30
parent 94859046a3
commit a5f26d1381
3 changed files with 432 additions and 8 deletions

View File

@ -11952,6 +11952,15 @@
use one of the other types instead whenever an actual relationship is known
</summary>
</member>
<member name="F:RMuseum.Models.Ganjoor.PersonAffiliationType.Killer">
<summary>
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
</summary>
</member>
<member name="F:RMuseum.Models.Ganjoor.PersonAffiliationType.Other">
<summary>
doesn't fit any of the above - rely on Note for what the tie actually is
@ -22206,6 +22215,64 @@
<param name="rootId"></param>
<returns></returns>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService._FindOtherFamilyTreeCaptionHolderInComponentAsync(System.Int32)">
<summary>
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.
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService._GetAncestorEdgesAsync(System.Nullable{System.Int32})">
<summary>
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)
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService._WouldCreateAncestryCycleAsync(System.Int32,System.Int32,System.Nullable{System.Int32})">
<summary>
true if adding a directed ancestor-type edge ancestorId -&gt; 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.
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService._CountParentsAsync(System.Int32,System.Nullable{System.Int32})">
<summary>
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
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService._GetConflictingRelationAsync(System.Int32,System.Int32,RMuseum.Models.Ganjoor.PersonRelationType,System.Nullable{System.Int32})">
<summary>
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)
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService._ValidateFamilyRelationAsync(System.Int32,System.Int32,RMuseum.Models.Ganjoor.PersonRelationType,System.Nullable{System.Int32})">
<summary>
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).
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService.SuggestPersonEditAsync(RMuseum.Models.Ganjoor.GanjoorPersonEditSuggestion)">
<summary>
submit a suggested edit to an already-approved person's own fields
@ -22935,6 +23002,17 @@
</summary>
<param name="json">SuggestedPersonGraphJson value - see its doc comment for the expected shape</param>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorService._WouldCreateAncestryCycleInGraphAsync(System.Int32,System.Int32,System.Collections.Generic.List{System.ValueTuple{System.Int32,System.Int32}})">
<summary>
true if adding a directed ancestor-type edge ancestorId -&gt; 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.
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorService.ModeratePoemSectionCorrection(System.Guid,RMuseum.Models.Ganjoor.ViewModels.GanjoorPoemSectionCorrectionViewModel)">
<summary>
moderate poem section correction

View File

@ -280,6 +280,203 @@ namespace RMuseum.Services.Implementation
}
}
/// <summary>
/// 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.
/// </summary>
private async Task<GanjoorRelatedPerson> _FindOtherFamilyTreeCaptionHolderInComponentAsync(int personId)
{
var allRelations = await _context.GanjoorPersonRelations.ToListAsync();
var edgesByPersonId = new Dictionary<int, List<GanjoorPersonRelation>>();
void IndexEdge(int pid, GanjoorPersonRelation edge)
{
if (!edgesByPersonId.TryGetValue(pid, out var list))
{
list = new List<GanjoorPersonRelation>();
edgesByPersonId[pid] = list;
}
list.Add(edge);
}
foreach (var edge in allRelations)
{
IndexEdge(edge.Person1Id, edge);
IndexEdge(edge.Person2Id, edge);
}
var visited = new HashSet<int>() { personId };
var queue = new Queue<int>();
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();
}
/// <summary>
/// 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)
/// </summary>
private async Task<List<GanjoorPersonRelation>> _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();
}
/// <summary>
/// true if adding a directed ancestor-type edge ancestorId -&gt; 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.
/// </summary>
private async Task<bool> _WouldCreateAncestryCycleAsync(int ancestorId, int descendantId, int? excludeRelationId)
{
if (ancestorId == descendantId)
return true;
var edges = await _GetAncestorEdgesAsync(excludeRelationId);
var childrenOf = new Dictionary<int, List<int>>();
foreach (var edge in edges)
{
if (!childrenOf.TryGetValue(edge.Person1Id, out var list))
{
list = new List<int>();
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<int>() { descendantId };
var queue = new Queue<int>();
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;
}
/// <summary>
/// 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
/// </summary>
private async Task<int> _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();
}
/// <summary>
/// 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)
/// </summary>
private async Task<GanjoorPersonRelation> _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();
}
/// <summary>
/// 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).
/// </summary>
private async Task<string> _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;
}
/// <summary>
/// submit a suggested edit to an already-approved person's own fields
/// </summary>
@ -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<GanjoorPersonEditSuggestion>(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<GanjoorPersonRelationEditSuggestion>(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<GanjoorPersonRelationEditSuggestion>(null, validationError);
}
}
suggestion.Id = 0;
suggestion.ExistingRelation = null;
suggestion.ExistingAffiliation = null;
@ -852,6 +1070,15 @@ namespace RMuseum.Services.Implementation
switch (suggestion.Action)
{
case PersonRelationSuggestionAction.Add:
{
// 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<GanjoorPersonRelationEditSuggestion>(null, validationError);
}
_context.GanjoorPersonRelations.Add(new GanjoorPersonRelation()
{
Person1Id = suggestion.Person1Id,
@ -861,6 +1088,7 @@ namespace RMuseum.Services.Implementation
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<GanjoorPersonRelationEditSuggestion>(null, "نسبت مورد نظر دیگر وجود ندارد.");
}
var validationError = await _ValidateFamilyRelationAsync(suggestion.Person1Id, suggestion.Person2Id, suggestion.SuggestedRelationType, existing.Id);
if (validationError != null)
{
return new RServiceResult<GanjoorPersonRelationEditSuggestion>(null, validationError);
}
existing.RelationType = suggestion.SuggestedRelationType;
existing.DegreeHint = suggestion.SuggestedDegreeHint;
existing.Note = suggestion.SuggestedNote;

View File

@ -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<int, string>(0, "یکی از روابط پیشنهادی به شخصیتی خارج از این پیشنهاد اشاره می‌کند.");
}
if (person1Id == person2Id)
{
return new Tuple<int, string>(0, "دو طرف یک رابطه نمی‌توانند یک شخص باشند.");
}
if (string.Equals(relation.Kind, "affiliation", StringComparison.OrdinalIgnoreCase))
{
if (!Enum.TryParse<PersonAffiliationType>(relation.AffiliationType, out var affiliationType))
@ -972,6 +986,40 @@ namespace RMuseum.Services.Implementation
{
return new Tuple<int, string>(0, $"نوع رابطهٔ خویشاوندی «{relation.RelationType}» نامعتبر است.");
}
if (relationType == PersonRelationType.Parent || relationType == PersonRelationType.Ancestor)
{
if (await _WouldCreateAncestryCycleInGraphAsync(person1Id, person2Id, newlyAddedAncestorEdges))
{
return new Tuple<int, string>(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<int, string>(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<int, string>(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<int, string>(localKeyToPersonId[graph.Person.LocalKey], null);
}
/// <summary>
/// true if adding a directed ancestor-type edge ancestorId -&gt; 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.
/// </summary>
private async Task<bool> _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<int, List<int>>();
void AddEdge(int parent, int child)
{
if (!childrenOf.TryGetValue(parent, out var list))
{
list = new List<int>();
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<int>() { descendantId };
var queue = new Queue<int>();
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;
}
}
}