From 5003f92f006b94067b5ec6137215b52fa6fb3149 Mon Sep 17 00:00:00 2001 From: Hamid Reza Mohammadi Date: Mon, 28 Sep 2026 08:54:42 +0330 Subject: [PATCH] people tags #387 --- .../ViewModels/PersonGraphSuggestion.cs | 100 ++++++++++++ .../GanjoorService-ModeratePoemCorrection.cs | 145 +++++++++++++++++- 2 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 RMuseum/Models/Ganjoor/ViewModels/PersonGraphSuggestion.cs diff --git a/RMuseum/Models/Ganjoor/ViewModels/PersonGraphSuggestion.cs b/RMuseum/Models/Ganjoor/ViewModels/PersonGraphSuggestion.cs new file mode 100644 index 00000000..446ff309 --- /dev/null +++ b/RMuseum/Models/Ganjoor/ViewModels/PersonGraphSuggestion.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; + +namespace RMuseum.Models.Ganjoor.ViewModels +{ + /// + /// deserialized shape of GanjoorPoemGeoDateTagCorrection.SuggestedPersonGraphJson - a brand new + /// person (optionally linked to other people, new or existing) suggested in one go alongside a + /// poem's geo/date/person tag. See that field's doc comment for the full JSON shape. + /// + public class PersonGraphSuggestion + { + /// + /// the node that ends up assigned to the tag's PersonId once approved + /// + public PersonGraphNode Person { get; set; } + + /// + /// other people this submission also introduces or links to, referenced from Relations by + /// their LocalKey + /// + public List RelatedPeople { get; set; } + + /// + /// kinship and/or non-family edges between any of Person/RelatedPeople's local keys + /// + public List Relations { get; set; } + } + + /// + /// one person node in a PersonGraphSuggestion - either a link to an existing, already approved + /// GanjoorRelatedPerson (ExistingPersonId set, the rest ignored) or a brand new person to create + /// (ExistingPersonId null, the rest describing them) + /// + public class PersonGraphNode + { + /// + /// key used only to resolve Relations entries within this same submission - never stored + /// beyond approval time + /// + public string LocalKey { get; set; } + + /// + /// set to link to an existing, already approved person instead of creating a new one - when + /// set, every other field on this node is ignored + /// + public int? ExistingPersonId { get; set; } + + public string Name { get; set; } + public string Description { get; set; } + public string WikiUrl { get; set; } + public int BirthYearInLHijri { get; set; } + public int DeathYearInLHijri { get; set; } + public bool ValidBirthDate { get; set; } + public bool ValidDeathDate { get; set; } + public int? BirthLocationId { get; set; } + public int? DeathLocationId { get; set; } + + /// + /// see GanjoorRelatedPerson.FamilyTreeCaption - only meaningful for whichever node is meant + /// to be a tree's named root + /// + public string FamilyTreeCaption { get; set; } + } + + /// + /// one edge in a PersonGraphSuggestion, between two nodes referenced by their LocalKey (Person1/ + /// Person2 - matching PersonGraphNode.LocalKey, not a real database id yet). Kind picks which + /// enum RelationType/AffiliationType is parsed against and which live table the edge is + /// materialized into on approval. + /// + public class PersonGraphRelationEntry + { + /// + /// "family" (default, materialized as GanjoorPersonRelation - RelationType against + /// PersonRelationType) or "affiliation" (materialized as GanjoorPersonAffiliation - + /// AffiliationType against PersonAffiliationType) + /// + public string Kind { get; set; } + + public string Person1 { get; set; } + public string Person2 { get; set; } + + /// + /// parsed against PersonRelationType when Kind is "family" + /// + public string RelationType { get; set; } + + /// + /// parsed against PersonAffiliationType when Kind is "affiliation" + /// + public string AffiliationType { get; set; } + + /// + /// only meaningful for a family relation of type Ancestor - see GanjoorPersonRelation.DegreeHint + /// + public int? DegreeHint { get; set; } + + public string Note { get; set; } + } +} diff --git a/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-ModeratePoemCorrection.cs b/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-ModeratePoemCorrection.cs index cb00cd07..5a3b362c 100644 --- a/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-ModeratePoemCorrection.cs +++ b/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-ModeratePoemCorrection.cs @@ -719,6 +719,20 @@ namespace RMuseum.Services.Implementation approvedLocationId = newLocation.Id; } + int? approvedPersonId = dbGeoDateTag.PersonId; + if (approvedPersonId == null && !string.IsNullOrWhiteSpace(dbGeoDateTag.SuggestedPersonGraphJson)) + { + // a brand new person (and possibly their relatives/relations, some of which may + // also be brand new) - resolve/create everything the JSON describes and use + // whichever person node it designates as the one actually being tagged here + var personGraphResult = await _MaterializePersonGraphAsync(dbGeoDateTag.SuggestedPersonGraphJson); + if (!string.IsNullOrEmpty(personGraphResult.Item2)) + { + return new RServiceResult(null, personGraphResult.Item2); + } + approvedPersonId = personGraphResult.Item1; + } + var newTag = new PoemGeoDateTag() { PoemId = dbCorrection.PoemId, @@ -728,7 +742,7 @@ namespace RMuseum.Services.Implementation LunarYear = dbGeoDateTag.LunarYear, LunarMonth = dbGeoDateTag.LunarMonth, LunarDay = dbGeoDateTag.LunarDay, - PersonId = dbGeoDateTag.PersonId, + PersonId = approvedPersonId, IgnoreInCategory = dbGeoDateTag.IgnoreInCategory, VerifiedDate = false, MachineGenerated = false, @@ -822,5 +836,134 @@ namespace RMuseum.Services.Implementation return new RServiceResult(null, exp.ToString()); } } + + /// + /// materializes a GanjoorPoemGeoDateTagCorrection.SuggestedPersonGraphJson payload: creates + /// any brand new person nodes it describes (or reuses an existing one when a node names + /// ExistingPersonId), then creates the kinship (GanjoorPersonRelation) and/or non-family + /// (GanjoorPersonAffiliation) edges between them. Returns the resolved id of the "person" + /// node (the one that ends up assigned to the tag's PersonId), or an error message. + /// + /// SuggestedPersonGraphJson value - see its doc comment for the expected shape + private async Task> _MaterializePersonGraphAsync(string json) + { + PersonGraphSuggestion graph; + try + { + graph = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + } + catch (Exception exp) + { + return new Tuple(0, $"برچسب فرد پیشنهادی قابل تفسیر نیست: {exp.Message}"); + } + + if (graph?.Person == null || string.IsNullOrWhiteSpace(graph.Person.LocalKey)) + { + return new Tuple(0, "برچسب فرد پیشنهادی ناقص است."); + } + + // first pass: resolve/create every node (the tagged person plus anyone else referenced), + // building a localKey -> real database id map for the relations pass below. A node + // referencing another not-yet-existing node has no real id to point at until this pass + // finishes, which is exactly why this travels as one JSON blob instead of separate rows. + var allNodes = new List() { graph.Person }; + if (graph.RelatedPeople != null) + { + allNodes.AddRange(graph.RelatedPeople); + } + + var localKeyToPersonId = new Dictionary(); + foreach (var node in allNodes) + { + if (string.IsNullOrWhiteSpace(node.LocalKey)) + { + return new Tuple(0, "یکی از افراد برچسب پیشنهادی بدون کلید محلی است."); + } + if (localKeyToPersonId.ContainsKey(node.LocalKey)) + { + continue; // same local key reused (e.g. Person also listed in RelatedPeople) - already resolved + } + + if (node.ExistingPersonId != null) + { + var existingPerson = await _context.GanjoorRelatedPersons.Where(p => p.Id == node.ExistingPersonId).AnyAsync(); + if (!existingPerson) + { + return new Tuple(0, $"فرد موجود با کد {node.ExistingPersonId} پیدا نشد."); + } + localKeyToPersonId[node.LocalKey] = node.ExistingPersonId.Value; + continue; + } + + if (string.IsNullOrWhiteSpace(node.Name)) + { + return new Tuple(0, "نام یکی از افراد پیشنهادی وارد نشده است."); + } + + var newPerson = new GanjoorRelatedPerson() + { + Name = node.Name.Trim(), + Description = node.Description, + WikiUrl = node.WikiUrl, + BirthYearInLHijri = node.BirthYearInLHijri, + DeathYearInLHijri = node.DeathYearInLHijri, + ValidBirthDate = node.ValidBirthDate, + ValidDeathDate = node.ValidDeathDate, + BirthLocationId = node.BirthLocationId, + DeathLocationId = node.DeathLocationId, + FamilyTreeCaption = string.IsNullOrWhiteSpace(node.FamilyTreeCaption) ? null : node.FamilyTreeCaption.Trim(), + MachineGenerated = false, + }; + _context.GanjoorRelatedPersons.Add(newPerson); + await _context.SaveChangesAsync(); // need its Id below, other nodes/relations may reference it + localKeyToPersonId[node.LocalKey] = newPerson.Id; + } + + // second pass: now that every node has a real id, create the edges between them + if (graph.Relations != null) + { + foreach (var relation in graph.Relations) + { + if (!localKeyToPersonId.TryGetValue(relation.Person1 ?? "", out int person1Id) || + !localKeyToPersonId.TryGetValue(relation.Person2 ?? "", out int person2Id)) + { + return new Tuple(0, "یکی از روابط پیشنهادی به فردی خارج از این پیشنهاد اشاره می‌کند."); + } + + if (string.Equals(relation.Kind, "affiliation", StringComparison.OrdinalIgnoreCase)) + { + if (!Enum.TryParse(relation.AffiliationType, out var affiliationType)) + { + return new Tuple(0, $"نوع رابطهٔ غیرخویشاوندی «{relation.AffiliationType}» نامعتبر است."); + } + _context.GanjoorPersonAffiliations.Add(new GanjoorPersonAffiliation() + { + Person1Id = person1Id, + Person2Id = person2Id, + AffiliationType = affiliationType, + Note = relation.Note, + }); + } + else + { + if (!Enum.TryParse(relation.RelationType, out var relationType)) + { + return new Tuple(0, $"نوع رابطهٔ خویشاوندی «{relation.RelationType}» نامعتبر است."); + } + _context.GanjoorPersonRelations.Add(new GanjoorPersonRelation() + { + Person1Id = person1Id, + Person2Id = person2Id, + RelationType = relationType, + DegreeHint = relation.DegreeHint, + Note = relation.Note, + }); + } + } + await _context.SaveChangesAsync(); + } + + return new Tuple(localKeyToPersonId[graph.Person.LocalKey], null); + } } }