people tags #387

This commit is contained in:
Hamid Reza Mohammadi 2026-09-28 15:06:14 +03:30
parent 8110e09a07
commit fa8544b62d
9 changed files with 281 additions and 83 deletions

View File

@ -941,11 +941,11 @@ else
} }
@if (geoTag.PersonId != null) @if (geoTag.PersonId != null)
{ {
<text> - فرد: @(geoTag.Person != null ? geoTag.Person.Name : $"فرد #{geoTag.PersonId}")</text> <text> - شخصیت: @(geoTag.Person != null ? geoTag.Person.Name : $"شخصیت #{geoTag.PersonId}")</text>
} }
else if (personGraph?.Person != null) else if (personGraph?.Person != null)
{ {
<text> - فرد جدید: @personGraph.Person.Name</text> <text> - شخصیت جدید: @personGraph.Person.Name</text>
if (personGraph.Relations != null && personGraph.Relations.Count > 0) if (personGraph.Relations != null && personGraph.Relations.Count > 0)
{ {
<text> (@personGraph.Relations.Count.ToPersianNumbers() رابطهٔ همراه)</text> <text> (@personGraph.Relations.Count.ToPersianNumbers() رابطهٔ همراه)</text>
@ -1031,17 +1031,17 @@ else
<tr> <tr>
<td colspan="2"> <td colspan="2">
<p class="up-hint"> <p class="up-hint">
اگر کاربر فرد را اشتباه تشخیص داده (مثلاً فردی که از قبل در فهرست است را به‌عنوان فرد جدید پیشنهاد کرده)، به‌جای رد کردن می‌توانید آن را به فرد موجود متصل کنید؛ اگر کاربر شخصیت را اشتباه تشخیص داده (مثلاً شخصیتی که از قبل در فهرست است را به‌عنوان شخصیت جدید پیشنهاد کرده)، به‌جای رد کردن می‌توانید آن را به شخصیت موجود متصل کنید؛
برای فرد تازه فقط نام قابل اصلاح است - اصلاح روابط/تاریخ‌ها نیازمند رد کردن و ارسال دوبارهٔ پیشنهاد توسط کاربر است: برای شخصیت تازه فقط نام قابل اصلاح است - اصلاح روابط/تاریخ‌ها نیازمند رد کردن و ارسال دوبارهٔ پیشنهاد توسط کاربر است:
</p> </p>
<label for="geotag@(geoIndex)_person_search">اتصال به فرد موجود در فهرست (در صورت وجود اشتباه در تشخیص فرد):</label> <label for="geotag@(geoIndex)_person_search">اتصال به شخصیت موجود در فهرست (در صورت وجود اشتباه در تشخیص شخصیت):</label>
<div class="up-autocomplete-wrap"> <div class="up-autocomplete-wrap">
<input type="text" id="geotag@(geoIndex)_person_search" autocomplete="off" placeholder="نام فرد را تایپ کنید..." <input type="text" id="geotag@(geoIndex)_person_search" autocomplete="off" placeholder="نام شخصیت را تایپ کنید..."
value="@(geoTag.PersonId != null ? (Model.People ?? new List<GanjoorRelatedPerson>()).Where(p => p.Id == geoTag.PersonId).Select(p => p.Name).FirstOrDefault() : null)" /> value="@(geoTag.PersonId != null ? (Model.People ?? new List<GanjoorRelatedPerson>()).Where(p => p.Id == geoTag.PersonId).Select(p => p.Name).FirstOrDefault() : null)" />
<input type="hidden" id="geotag@(geoIndex)_person_select" name="geotag@(geoIndex)_person_select" value="@(geoTag.PersonId ?? 0)" /> <input type="hidden" id="geotag@(geoIndex)_person_select" name="geotag@(geoIndex)_person_select" value="@(geoTag.PersonId ?? 0)" />
<div id="geotag@(geoIndex)_person_results" class="up-autocomplete-results" style="display:none;"></div> <div id="geotag@(geoIndex)_person_results" class="up-autocomplete-results" style="display:none;"></div>
</div> </div>
<p class="up-hint">برای اتصال به یک فرد موجود، نام او را تایپ کنید و از فهرست انتخاب کنید؛ برای رها کردن اتصال و پیشنهاد به‌عنوان فرد جدید، این فیلد را خالی کنید.</p> <p class="up-hint">برای اتصال به یک شخصیت موجود، نام او را تایپ کنید و از فهرست انتخاب کنید؛ برای رها کردن اتصال و پیشنهاد به‌عنوان شخصیت جدید، این فیلد را خالی کنید.</p>
<script> <script>
setupPersonAutocomplete( setupPersonAutocomplete(
document.getElementById('geotag@(geoIndex)_person_search'), document.getElementById('geotag@(geoIndex)_person_search'),
@ -1056,7 +1056,7 @@ else
{ {
<div id="geotag@(geoIndex)_new_person_fields"> <div id="geotag@(geoIndex)_new_person_fields">
<br> <br>
<label for="geotag@(geoIndex)_person_name">نام اصلاح‌شدهٔ فرد:</label> <label for="geotag@(geoIndex)_person_name">نام اصلاح‌شدهٔ شخصیت:</label>
<input type="text" id="geotag@(geoIndex)_person_name" name="geotag@(geoIndex)_person_name" size="30" /> <input type="text" id="geotag@(geoIndex)_person_name" name="geotag@(geoIndex)_person_name" size="30" />
</div> </div>
} }

View File

@ -511,7 +511,7 @@ namespace GanjooRazor.Areas.Admin.Pages
} }
catch (Exception) catch (Exception)
{ {
return new BadRequestObjectResult("برچسب فرد پیشنهادی قابل تفسیر نیست."); return new BadRequestObjectResult("برچسب شخصیت پیشنهادی قابل تفسیر نیست.");
} }
} }
} }

View File

@ -116,9 +116,17 @@
// ---------- geo/date tag suggestions ---------- // ---------- geo/date tag AND person (شخصیت) tag suggestions ----------
// staged client-side until the main save; each item matches // Both kinds are staged client-side (until the main save) in this ONE combined array, and both
// GanjoorPoemGeoDateTagCorrection's shape so it can be sent as-is // are sent to the server as one flat GanjoorPoemGeoDateTagCorrection[] (pcs.geoDateTags) - that
// part of the data model is shared, on purpose (see the doc comment on
// GanjoorPoemGeoDateTagCorrection.SuggestedPersonGraphJson). But a geo/date tag and a person tag
// mean unrelated things to a reader ("this couplet refers to this place/time" vs "this couplet
// refers to this person"), so the UI never mixes them in one row: every staged/loaded item here
// carries a client-only "_uiKind" field ('geo' or 'person', added by _getSuggestionUiKind for
// anything that doesn't already have it - e.g. a draft loaded from the server) that the two
// render functions (renderPendingGeoDateTags / renderPendingPersonTags) filter on, and the two
// "add a tag" cards each read/write only their own half of the form.
var geoDateTagSuggestions = @Html.Raw(Model.MyLastEditGeoDateTagsJson); var geoDateTagSuggestions = @Html.Raw(Model.MyLastEditGeoDateTagsJson);
var geoDateTagCoupletTexts = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(Model.Couplets.ToDictionary(c => c.Item1.ToString(), c => c.Item2))); var geoDateTagCoupletTexts = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(Model.Couplets.ToDictionary(c => c.Item1.ToString(), c => c.Item2)));
// full existing-location catalog ({id, name, latitude, longitude}[]), used to warn about a // full existing-location catalog ({id, name, latitude, longitude}[]), used to warn about a
@ -127,8 +135,28 @@
// full existing-people catalog ({id, name, birthYearInLHijri, deathYearInLHijri}[]), used both // full existing-people catalog ({id, name, birthYearInLHijri, deathYearInLHijri}[]), used both
// to pick an existing person for a tag and as the "other side" of a new person's relatives // to pick an existing person for a tag and as the "other side" of a new person's relatives
var allGeoPeople = @Html.Raw(Model.AllPeopleJson); var allGeoPeople = @Html.Raw(Model.AllPeopleJson);
// {id, personId}[] for this poem's already-approved geo/date/person tags - used only to work out
// the _uiKind of a "delete this existing tag" suggestion loaded from a saved-but-unsubmitted
// draft, which otherwise carries nothing but the id being deleted
var allExistingGeoDateTags = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(
(Model.PoemGeoDateTags ?? Array.Empty<RMuseum.Models.Ganjoor.PoemGeoDateTag>()).Select(t => new { id = t.Id, personId = t.PersonId })));
// Works out whether a staged/loaded suggestion is a geo/date tag or a person (شخصیت) tag, for the
// two render functions to filter on. New items always set _uiKind themselves when pushed; this
// is only needed for items loaded from Model.MyLastEditGeoDateTagsJson (an unsubmitted draft
// from a previous visit), which predate the split and never carry it.
function _getSuggestionUiKind(s) {
if (s._uiKind) return s._uiKind;
if (s.markForDelete) {
var existing = allExistingGeoDateTags.filter(function (t) { return t.id === s.existingTagId; })[0];
return (existing && existing.personId) ? 'person' : 'geo';
}
return (s.personId != null || s.suggestedPersonGraphJson) ? 'person' : 'geo';
}
$(document).ready(function () { $(document).ready(function () {
renderPendingGeoDateTags(); renderPendingGeoDateTags();
renderPendingPersonTags();
setupLocationAutocomplete( setupLocationAutocomplete(
document.getElementById('geotag-location-search'), document.getElementById('geotag-location-search'),
document.getElementById('geotag-location-select'), document.getElementById('geotag-location-select'),
@ -269,16 +297,16 @@
'<div class="up-field">' + '<div class="up-field">' +
'<label>جهت رابطه</label>' + '<label>جهت رابطه</label>' +
'<select id="person-relative-direction-' + idx + '">' + '<select id="person-relative-direction-' + idx + '">' +
'<option value="newFirst">این فرد جدید، طرف اول رابطه است (مثلاً پدر/وزیر طرف دوم)</option>' + '<option value="newFirst">این شخصیت جدید، طرف اول رابطه است (مثلاً پدر/وزیر طرف دوم)</option>' +
'<option value="otherFirst">فرد دیگر، طرف اول رابطه است (مثلاً پدر/وزیر این فرد جدید)</option>' + '<option value="otherFirst">شخصیت دیگر، طرف اول رابطه است (مثلاً پدر/وزیر این شخصیت جدید)</option>' +
'</select>' + '</select>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="up-field-row">' + '<div class="up-field-row">' +
'<div class="up-field">' + '<div class="up-field">' +
'<label>فرد دیگر (باید از قبل در فهرست موجود باشد)</label>' + '<label>شخصیت دیگر (باید از قبل در فهرست موجود باشد)</label>' +
'<div class="up-autocomplete-wrap">' + '<div class="up-autocomplete-wrap">' +
'<input type="text" id="person-relative-person-search-' + idx + '" autocomplete="off" placeholder="نام فرد را تایپ کنید..." />' + '<input type="text" id="person-relative-person-search-' + idx + '" autocomplete="off" placeholder="نام شخصیت را تایپ کنید..." />' +
'<input type="hidden" id="person-relative-person-id-' + idx + '" />' + '<input type="hidden" id="person-relative-person-id-' + idx + '" />' +
'<div id="person-relative-person-results-' + idx + '" class="up-autocomplete-results" style="display:none;"></div>' + '<div id="person-relative-person-results-' + idx + '" class="up-autocomplete-results" style="display:none;"></div>' +
'</div>' + '</div>' +
@ -298,7 +326,7 @@
allGeoPeople, allGeoPeople,
null, null,
function (typedText) { function (typedText) {
upToast('این فرد در فهرست نیست. طرف دیگر یک رابطه باید از قبل تعریف‌شده باشد؛ برای دو فرد تازه، ابتدا این فرم را برای یکی از آن‌ها به‌صورت جدا تکمیل و ثبت کنید.', 'error'); upToast('این شخصیت در فهرست نیست. طرف دیگر یک رابطه باید از قبل تعریف‌شده باشد؛ برای دو شخصیت تازه، ابتدا این فرم را برای یکی از آن‌ها به‌صورت جدا تکمیل و ثبت کنید.', 'error');
} }
); );
} }
@ -456,7 +484,7 @@
function buildPersonGraphJson() { function buildPersonGraphJson() {
var name = ($('#geotag-new-person-name').val() || '').trim(); var name = ($('#geotag-new-person-name').val() || '').trim();
if (name == '') { if (name == '') {
upToast('لطفاً نام فرد جدید را وارد کنید.', 'error'); upToast('لطفاً نام شخصیت جدید را وارد کنید.', 'error');
return null; return null;
} }
@ -487,7 +515,7 @@
var idx = rows[r].id.replace('person-relative-row-', ''); var idx = rows[r].id.replace('person-relative-row-', '');
var otherPersonId = $('#person-relative-person-id-' + idx).val(); var otherPersonId = $('#person-relative-person-id-' + idx).val();
if (!otherPersonId) { if (!otherPersonId) {
upToast('برای یکی از روابط افزوده‌شده، فرد دیگر انتخاب نشده است.', 'error'); upToast('برای یکی از روابط افزوده‌شده، شخصیت دیگر انتخاب نشده است.', 'error');
return null; return null;
} }
otherPersonId = parseInt(otherPersonId); otherPersonId = parseInt(otherPersonId);
@ -532,30 +560,61 @@
function renderPendingGeoDateTags() { function renderPendingGeoDateTags() {
var container = document.getElementById('pending-geotag-suggestions'); var container = document.getElementById('pending-geotag-suggestions');
if (geoDateTagSuggestions.length == 0) { var rows = [];
for (var i = 0; i < geoDateTagSuggestions.length; i++) {
if (_getSuggestionUiKind(geoDateTagSuggestions[i]) === 'geo') rows.push(i);
}
if (rows.length == 0) {
container.innerHTML = ''; container.innerHTML = '';
return; return;
} }
var html = '<table><tr><th>وضعیت</th><th>بیت</th><th>مکان</th><th>فرد</th><th>سال</th><th>ماه</th><th>روز</th><th>#</th></tr>'; var html = '<table><tr><th>وضعیت</th><th>بیت</th><th>مکان</th><th>سال</th><th>ماه</th><th>روز</th><th>#</th></tr>';
for (var i = 0; i < geoDateTagSuggestions.length; i++) { for (var r = 0; r < rows.length; r++) {
var i = rows[r];
var s = geoDateTagSuggestions[i]; var s = geoDateTagSuggestions[i];
var status = s.markForDelete ? '🗑️ درخواست حذف' : '➕ پیشنهاد جدید'; var status = s.markForDelete ? '🗑️ درخواست حذف' : '➕ پیشنهاد جدید';
var coupletText = (s.coupletIndex != null && geoDateTagCoupletTexts[s.coupletIndex]) ? geoDateTagCoupletTexts[s.coupletIndex] : '— کل شعر —'; var coupletText = (s.coupletIndex != null && geoDateTagCoupletTexts[s.coupletIndex]) ? geoDateTagCoupletTexts[s.coupletIndex] : '— کل شعر —';
var locationText = s.markForDelete ? '' : (s.locationId ? ((allGeoLocations.filter(function (l) { return l.id === s.locationId; })[0] || {}).name || ('مکان #' + s.locationId)) : (s.suggestedLocationName || '')); var locationText = s.markForDelete ? '' : (s.locationId ? ((allGeoLocations.filter(function (l) { return l.id === s.locationId; })[0] || {}).name || ('مکان #' + s.locationId)) : (s.suggestedLocationName || ''));
html += '<tr><td>' + status + '</td><td>' + coupletText + '</td><td>' + locationText + '</td><td>' + (s.lunarYear || '') + '</td><td>' + (s.lunarMonth || '') + '</td><td>' + (s.lunarDay || '') + '</td>' +
'<td><a role="button" class="up-btn up-btn--sm up-btn--ghost" onclick="removeGeoDateTagSuggestion(' + i + ')">✕ حذف از فهرست</a></td></tr>';
}
html += '</table>';
container.innerHTML = html;
}
// same purpose/shape as renderPendingGeoDateTags above, but for the person(شخصیت) tag section -
// kept as a fully separate function (rather than one function with a "kind" switch inside) so
// each half of the split UI is easy to read/change on its own
function renderPendingPersonTags() {
var container = document.getElementById('pending-persontag-suggestions');
var rows = [];
for (var i = 0; i < geoDateTagSuggestions.length; i++) {
if (_getSuggestionUiKind(geoDateTagSuggestions[i]) === 'person') rows.push(i);
}
if (rows.length == 0) {
container.innerHTML = '';
return;
}
var html = '<table><tr><th>وضعیت</th><th>بیت</th><th>شخصیت</th><th>#</th></tr>';
for (var r = 0; r < rows.length; r++) {
var i = rows[r];
var s = geoDateTagSuggestions[i];
var status = s.markForDelete ? '🗑️ درخواست حذف' : '➕ پیشنهاد جدید';
var coupletText = (s.coupletIndex != null && geoDateTagCoupletTexts[s.coupletIndex]) ? geoDateTagCoupletTexts[s.coupletIndex] : '— کل شعر —';
var personText = ''; var personText = '';
if (!s.markForDelete) { if (!s.markForDelete) {
if (s.personId) { if (s.personId) {
personText = (allGeoPeople.filter(function (p) { return p.id === s.personId; })[0] || {}).name || ('فرد #' + s.personId); personText = (allGeoPeople.filter(function (p) { return p.id === s.personId; })[0] || {}).name || ('شخصیت #' + s.personId);
} else if (s.suggestedPersonGraphJson) { } else if (s.suggestedPersonGraphJson) {
try { try {
var parsedGraph = JSON.parse(s.suggestedPersonGraphJson); var parsedGraph = JSON.parse(s.suggestedPersonGraphJson);
personText = 'فرد جدید: ' + (parsedGraph.person ? parsedGraph.person.name : ''); personText = 'شخصیت جدید: ' + (parsedGraph.person ? parsedGraph.person.name : '');
} catch (e) { } catch (e) {
personText = 'فرد جدید'; personText = 'شخصیت جدید';
} }
} }
} }
html += '<tr><td>' + status + '</td><td>' + coupletText + '</td><td>' + locationText + '</td><td>' + personText + '</td><td>' + (s.lunarYear || '') + '</td><td>' + (s.lunarMonth || '') + '</td><td>' + (s.lunarDay || '') + '</td>' + html += '<tr><td>' + status + '</td><td>' + coupletText + '</td><td>' + personText + '</td>' +
'<td><a role="button" class="up-btn up-btn--sm up-btn--ghost" onclick="removeGeoDateTagSuggestion(' + i + ')">✕ حذف از فهرست</a></td></tr>'; '<td><a role="button" class="up-btn up-btn--sm up-btn--ghost" onclick="removeGeoDateTagSuggestion(' + i + ')">✕ حذف از فهرست</a></td></tr>';
} }
html += '</table>'; html += '</table>';
@ -572,25 +631,49 @@
} }
} }
function scrollToPendingPersonTags() {
var container = document.getElementById('pending-persontag-suggestions');
if (container && container.innerHTML.trim() != '') {
container.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// shared by both sections - the two pending suggestions live in one combined array (see the
// comment above geoDateTagSuggestions), so removing by index and re-rendering both tables is
// simpler and just as cheap as tracking which table a given index belongs to
function removeGeoDateTagSuggestion(index) { function removeGeoDateTagSuggestion(index) {
geoDateTagSuggestions.splice(index, 1); geoDateTagSuggestions.splice(index, 1);
renderPendingGeoDateTags(); renderPendingGeoDateTags();
renderPendingPersonTags();
} }
function requestDeleteExistingGeoDateTag(existingTagId, el) { function requestDeleteExistingGeoDateTag(existingTagId, el) {
geoDateTagSuggestions.push({ geoDateTagSuggestions.push({
markForDelete: true, markForDelete: true,
existingTagId: existingTagId existingTagId: existingTagId,
_uiKind: 'geo'
}); });
$(el).closest('tr').hide(); $(el).closest('tr').hide();
renderPendingGeoDateTags(); renderPendingGeoDateTags();
scrollToPendingGeoDateTags(); scrollToPendingGeoDateTags();
} }
// Reads the "add a new tag" form. Returns a tag object if it has // person-tag counterpart of requestDeleteExistingGeoDateTag above
// meaningful content, or null if the form is empty (with an optional function requestDeleteExistingPersonTag(existingTagId, el) {
// error toast for the case where the user explicitly asked to add an geoDateTagSuggestions.push({
// empty entry vs. silently having nothing to pick up on save). markForDelete: true,
existingTagId: existingTagId,
_uiKind: 'person'
});
$(el).closest('tr').hide();
renderPendingPersonTags();
scrollToPendingPersonTags();
}
// Reads the "add a new geo/date tag" form (location + date only - see readPersonTagFromForm
// below for the separate شخصیت form). Returns a tag object if it has meaningful content, or
// null if the form is empty (with an optional error toast for the case where the user
// explicitly asked to add an empty entry vs. silently having nothing to pick up on save).
function readGeoDateTagFromForm(showErrorIfEmpty) { function readGeoDateTagFromForm(showErrorIfEmpty) {
var coupletIndex = $('#geotag-couplet-select').val(); var coupletIndex = $('#geotag-couplet-select').val();
var locationId = $('#geotag-location-select').val(); var locationId = $('#geotag-location-select').val();
@ -600,16 +683,13 @@
var year = $('#geotag-year').val(); var year = $('#geotag-year').val();
var month = $('#geotag-month').val(); var month = $('#geotag-month').val();
var day = $('#geotag-day').val(); var day = $('#geotag-day').val();
var personId = $('#geotag-person-select').val();
var newPersonName = $('#geotag-new-person-name').val();
var hasLocation = (locationId && locationId != '') || (newLocationName && newLocationLat && newLocationLng); var hasLocation = (locationId && locationId != '') || (newLocationName && newLocationLat && newLocationLng);
var hasDate = year && year != ''; var hasDate = year && year != '';
var hasPerson = (personId && personId != '') || (newPersonName && newPersonName.trim() != '');
if (!hasLocation && !hasDate && !hasPerson) { if (!hasLocation && !hasDate) {
if (showErrorIfEmpty) { if (showErrorIfEmpty) {
upToast('لطفاً حداقل یک مکان، تاریخ یا فرد وارد کنید.', 'error'); upToast('لطفاً حداقل یک مکان یا تاریخ وارد کنید.', 'error');
} }
return null; return null;
} }
@ -625,6 +705,41 @@
} }
} }
return {
markForDelete: false,
coupletIndex: (coupletIndex && coupletIndex != '') ? parseInt(coupletIndex) : null,
locationId: (locationId && locationId != '') ? parseInt(locationId) : null,
suggestedLocationName: (!locationId || locationId == '') ? (newLocationName || null) : null,
suggestedLatitude: (!locationId || locationId == '') && newLocationLat ? parseFloat(newLocationLat) : null,
suggestedLongitude: (!locationId || locationId == '') && newLocationLng ? parseFloat(newLocationLng) : null,
lunarYear: year ? parseInt(year) : null,
lunarMonth: month ? parseInt(month) : null,
lunarDay: day ? parseInt(day) : null,
personId: null,
suggestedPersonGraphJson: null,
ignoreInCategory: $('#geotag-ignore-in-category').is(':checked'),
suggestionNote: $('#geotag-note').val() || null,
_uiKind: 'geo'
};
}
// شخصیت-tag counterpart of readGeoDateTagFromForm above - deliberately never sets
// locationId/suggestedLocationName/lunarYear/etc., so a person tag can never end up mixed with
// a geo/date tag in one row (see the comment above geoDateTagSuggestions for why that matters)
function readPersonTagFromForm(showErrorIfEmpty) {
var coupletIndex = $('#persontag-couplet-select').val();
var personId = $('#geotag-person-select').val();
var newPersonName = $('#geotag-new-person-name').val();
var hasPerson = (personId && personId != '') || (newPersonName && newPersonName.trim() != '');
if (!hasPerson) {
if (showErrorIfEmpty) {
upToast('لطفاً یک شخصیت موجود را انتخاب کنید یا شخصیت جدیدی تعریف کنید.', 'error');
}
return null;
}
var suggestedPersonGraphJson = null; var suggestedPersonGraphJson = null;
if ((!personId || personId == '') && newPersonName && newPersonName.trim() != '') { if ((!personId || personId == '') && newPersonName && newPersonName.trim() != '') {
suggestedPersonGraphJson = buildPersonGraphJson(); suggestedPersonGraphJson = buildPersonGraphJson();
@ -636,17 +751,18 @@
return { return {
markForDelete: false, markForDelete: false,
coupletIndex: (coupletIndex && coupletIndex != '') ? parseInt(coupletIndex) : null, coupletIndex: (coupletIndex && coupletIndex != '') ? parseInt(coupletIndex) : null,
locationId: (locationId && locationId != '') ? parseInt(locationId) : null, locationId: null,
suggestedLocationName: (!locationId || locationId == '') ? (newLocationName || null) : null, suggestedLocationName: null,
suggestedLatitude: (!locationId || locationId == '') && newLocationLat ? parseFloat(newLocationLat) : null, suggestedLatitude: null,
suggestedLongitude: (!locationId || locationId == '') && newLocationLng ? parseFloat(newLocationLng) : null, suggestedLongitude: null,
lunarYear: year ? parseInt(year) : null, lunarYear: null,
lunarMonth: month ? parseInt(month) : null, lunarMonth: null,
lunarDay: day ? parseInt(day) : null, lunarDay: null,
personId: (personId && personId != '') ? parseInt(personId) : null, personId: (personId && personId != '') ? parseInt(personId) : null,
suggestedPersonGraphJson: suggestedPersonGraphJson, suggestedPersonGraphJson: suggestedPersonGraphJson,
ignoreInCategory: $('#geotag-ignore-in-category').is(':checked'), ignoreInCategory: false, // category-map aggregation is a geo/date-tag concept only
suggestionNote: $('#geotag-note').val() || null suggestionNote: $('#persontag-note').val() || null,
_uiKind: 'person'
}; };
} }
@ -661,6 +777,17 @@
$('#geotag-year').val(''); $('#geotag-year').val('');
$('#geotag-month').val(''); $('#geotag-month').val('');
$('#geotag-day').val(''); $('#geotag-day').val('');
$('#geotag-ignore-in-category').prop('checked', false);
$('#geotag-note').val('');
if (geoDateTagMarker != null && geoDateTagMap != null) {
geoDateTagMap.removeLayer(geoDateTagMarker);
geoDateTagMarker = null;
}
}
// شخصیت-tag counterpart of resetGeoDateTagForm above
function resetPersonTagForm() {
$('#persontag-couplet-select').val('');
$('#geotag-person-select').val(''); $('#geotag-person-select').val('');
$('#geotag-person-search').val(''); $('#geotag-person-search').val('');
$('#geotag-person-results').hide().empty(); $('#geotag-person-results').hide().empty();
@ -682,12 +809,7 @@
if (document.getElementById('new-person-widget').style.display !== 'none') { if (document.getElementById('new-person-widget').style.display !== 'none') {
toggleNewPersonWidget(); toggleNewPersonWidget();
} }
$('#geotag-ignore-in-category').prop('checked', false); $('#persontag-note').val('');
$('#geotag-note').val('');
if (geoDateTagMarker != null && geoDateTagMap != null) {
geoDateTagMap.removeLayer(geoDateTagMarker);
geoDateTagMarker = null;
}
} }
async function addGeoDateTagSuggestion() { async function addGeoDateTagSuggestion() {
@ -705,6 +827,20 @@
upToast('به فهرست پیشنهادها افزوده شد. برای ثبت نهایی، دکمهٔ «ذخیره» را بزنید.'); upToast('به فهرست پیشنهادها افزوده شد. برای ثبت نهایی، دکمهٔ «ذخیره» را بزنید.');
} }
// شخصیت-tag counterpart of addGeoDateTagSuggestion above - no location-duplicate confirmation
// needed here, that's a geo-tag-only concern
function addPersonTagSuggestion() {
var tag = readPersonTagFromForm(true);
if (tag == null) return;
geoDateTagSuggestions.push(tag);
resetPersonTagForm();
renderPendingPersonTags();
scrollToPendingPersonTags();
upToast('به فهرست پیشنهادها افزوده شد. برای ثبت نهایی، دکمهٔ «ذخیره» را بزنید.');
}
async function savePoemCorrections(id, verseCount) { async function savePoemCorrections(id, verseCount) {
var verseOrderText = []; var verseOrderText = [];
var verseOrderMarkedForDelete = []; var verseOrderMarkedForDelete = [];
@ -762,9 +898,9 @@
section_format = null; section_format = null;
} }
// pick up anything still sitting in the "add a new tag" form that the // pick up anything still sitting in either "add a new tag" form (geo/date, and شخصیت) that
// user didn't explicitly click "add to list" for - the explicit add // the user didn't explicitly click "add to list" for - the explicit add button is only
// button is only needed to stage more than one tag before saving // needed to stage more than one tag of the same kind before saving
var unstagedGeoDateTag = readGeoDateTagFromForm(false); var unstagedGeoDateTag = readGeoDateTagFromForm(false);
if (unstagedGeoDateTag != null) { if (unstagedGeoDateTag != null) {
var proceedWithUnstaged = await _confirmNewLocationSuggestion(unstagedGeoDateTag); var proceedWithUnstaged = await _confirmNewLocationSuggestion(unstagedGeoDateTag);
@ -779,6 +915,14 @@
scrollToPendingGeoDateTags(); scrollToPendingGeoDateTags();
} }
var unstagedPersonTag = readPersonTagFromForm(false);
if (unstagedPersonTag != null) {
geoDateTagSuggestions.push(unstagedPersonTag);
resetPersonTagForm();
renderPendingPersonTags();
scrollToPendingPersonTags();
}
if (verseOrderText.length == 0 && verseOrderSummaries.length == 0 && geoDateTagSuggestions.length == 0) { if (verseOrderText.length == 0 && verseOrderSummaries.length == 0 && geoDateTagSuggestions.length == 0) {
if (rhythm_new == null && rhythm_new2 == null && rhyme_letters == null && section_format == null) { if (rhythm_new == null && rhythm_new2 == null && rhyme_letters == null && section_format == null) {
upToast('شما هیچ تغییری در اطلاعات نداده‌اید!', 'error'); upToast('شما هیچ تغییری در اطلاعات نداده‌اید!', 'error');
@ -1541,6 +1685,7 @@ else
} }
<a role="button" href="#summaries" class="up-btn up-btn--sm up-btn--ghost">💬 بخش معنی</a> <a role="button" href="#summaries" class="up-btn up-btn--sm up-btn--ghost">💬 بخش معنی</a>
<a role="button" href="#geo-date-section" class="up-btn up-btn--sm up-btn--ghost">🗺️ بخش زمان و مکان</a> <a role="button" href="#geo-date-section" class="up-btn up-btn--sm up-btn--ghost">🗺️ بخش زمان و مکان</a>
<a role="button" href="#person-tag-section" class="up-btn up-btn--sm up-btn--ghost">🧑‍🎓 بخش شخصیت‌ها</a>
<a role="button" href="#footer" class="up-btn up-btn--sm up-btn--ghost">⬇ پایین صفحه</a> <a role="button" href="#footer" class="up-btn up-btn--sm up-btn--ghost">⬇ پایین صفحه</a>
</div> </div>
@ -2227,7 +2372,7 @@ else
<h2 class="up-card__title">🗺️ برچسب‌های زمان و مکان (پیشنهادی)</h2> <h2 class="up-card__title">🗺️ برچسب‌های زمان و مکان (پیشنهادی)</h2>
<p class="up-hint">می‌توانید پیشنهاد دهید که این شعر به مکان یا تاریخ خاصی اشاره دارد. پیشنهادهای شما پس از بررسی ناظران اعمال خواهد شد. با دکمهٔ «ذخیره» بالا همراه با بقیهٔ تغییرات ثبت می‌شود.</p> <p class="up-hint">می‌توانید پیشنهاد دهید که این شعر به مکان یا تاریخ خاصی اشاره دارد. پیشنهادهای شما پس از بررسی ناظران اعمال خواهد شد. با دکمهٔ «ذخیره» بالا همراه با بقیهٔ تغییرات ثبت می‌شود.</p>
@if (Model.PoemGeoDateTags != null && Model.PoemGeoDateTags.Any()) @if (Model.PoemGeoDateTags != null && Model.PoemGeoDateTags.Any(t => t.PersonId == null))
{ {
<table> <table>
<tr> <tr>
@ -2238,7 +2383,8 @@ else
<th>روز</th> <th>روز</th>
<th>#</th> <th>#</th>
</tr> </tr>
@foreach (var tag in Model.PoemGeoDateTags) @* شخصیت tags are shown in their own section below, not mixed in with geo/date tags here - see #person-tag-section *@
@foreach (var tag in Model.PoemGeoDateTags.Where(t => t.PersonId == null))
{ {
<tr id="existing-geotag-@tag.Id"> <tr id="existing-geotag-@tag.Id">
<td>@(Model.Couplets.Where(c => c.Item1 == tag.CoupletIndex).Select(c => c.Item2).FirstOrDefault() ?? "— کل شعر —")</td> <td>@(Model.Couplets.Where(c => c.Item1 == tag.CoupletIndex).Select(c => c.Item2).FirstOrDefault() ?? "— کل شعر —")</td>
@ -2390,15 +2536,72 @@ else
</div> </div>
</div> </div>
<div class="up-check-row">
<input type="checkbox" id="geotag-ignore-in-category" />
<label for="geotag-ignore-in-category">در نقشهٔ کلی مجموعه نادیده گرفته شود (مثلاً مکانی که فقط برای مقایسه ذکر شده)</label>
</div>
<div class="up-field"> <div class="up-field">
<label for="geotag-person-search">فرد</label> <label for="geotag-note">دلیل این برچسب (اختیاری، به‌ویژه برای اشاره‌های غیرمستقیم مثل ابجد یا تلمیح به رویدادی شناخته‌شده)</label>
<textarea id="geotag-note" rows="2"></textarea>
</div>
<a role="button" class="up-btn up-btn--ghost" onclick="addGeoDateTagSuggestion()">➕ افزودن به فهرست پیشنهادها</a>
</div>
@* شخصیت (person) tags use the same GanjoorPoemGeoDateTagCorrection structure as the geo/date
tags above (see the JS comment above geoDateTagSuggestions), but a couplet referring to a
place/time and a couplet referring to a person are unrelated claims, so this is its own card
with its own "existing tags" table, its own pending list, and its own add button - a
suggestion coming out of this card never carries a location or a date. *@
<div class="up-card" id="person-tag-section">
<h2 class="up-card__title">🧑‍🎓 برچسب‌های شخصیت (پیشنهادی)</h2>
<p class="up-hint">می‌توانید پیشنهاد دهید که این شعر به یک شخصیت (تاریخی یا داستانی) اشاره دارد. پیشنهادهای شما پس از بررسی ناظران اعمال خواهد شد. با دکمهٔ «ذخیره» بالا همراه با بقیهٔ تغییرات ثبت می‌شود.</p>
@if (Model.PoemGeoDateTags != null && Model.PoemGeoDateTags.Any(t => t.PersonId != null))
{
<table>
<tr>
<th>بیت</th>
<th>شخصیت</th>
<th>#</th>
</tr>
@foreach (var tag in Model.PoemGeoDateTags.Where(t => t.PersonId != null))
{
<tr id="existing-geotag-@tag.Id">
<td>@(Model.Couplets.Where(c => c.Item1 == tag.CoupletIndex).Select(c => c.Item2).FirstOrDefault() ?? "— کل شعر —")</td>
<td>@(tag.Person != null ? tag.Person.Name : $"شخصیت #{tag.PersonId}")</td>
<td>
<a role="button" class="up-btn up-btn--sm up-btn--ghost" onclick="requestDeleteExistingPersonTag(@tag.Id, this)">🗑️ درخواست حذف</a>
</td>
</tr>
}
</table>
}
<div id="pending-persontag-suggestions"></div>
<div class="up-field">
<label for="persontag-couplet-select">بیت</label>
<select id="persontag-couplet-select">
<option value="">— کل شعر —</option>
@foreach (var couplet in Model.Couplets)
{
<option value="@couplet.Item1">@($"{(couplet.Item1 + 1).ToPersianNumbers()} - {couplet.Item2}")</option>
}
</select>
<p class="up-hint">اگر این برچسب به یک بیت مشخص اشاره دارد، آن را انتخاب کنید تا دیگران بتوانند شاهد آن را در متن پیدا کنند.</p>
</div>
<div class="up-field">
<label for="geotag-person-search">شخصیت</label>
<div class="up-autocomplete-wrap"> <div class="up-autocomplete-wrap">
<input type="text" id="geotag-person-search" autocomplete="off" placeholder="نام فرد را تایپ کنید..." /> <input type="text" id="geotag-person-search" autocomplete="off" placeholder="نام شخصیت را تایپ کنید..." />
<input type="hidden" id="geotag-person-select" /> <input type="hidden" id="geotag-person-select" />
<div id="geotag-person-results" class="up-autocomplete-results" style="display:none;"></div> <div id="geotag-person-results" class="up-autocomplete-results" style="display:none;"></div>
</div> </div>
<p class="up-hint">شروع به تایپ کنید تا فهرست افراد بر اساس نام فیلتر شود؛ اگر فرد مورد نظر پیدا نشد، گزینهٔ تعریف او به‌عنوان فرد جدید نمایش داده می‌شود.</p> <p class="up-hint">شروع به تایپ کنید تا فهرست شخصیت‌ها بر اساس نام فیلتر شود؛ اگر شخصیت مورد نظر پیدا نشد، گزینهٔ تعریف او به‌عنوان شخصیت جدید نمایش داده می‌شود.</p>
<a role="button" class="up-btn up-btn--sm up-btn--ghost" onclick="toggleNewPersonWidget()">👤 فرد جدید پیشنهاد بده</a> <a role="button" class="up-btn up-btn--sm up-btn--ghost" onclick="toggleNewPersonWidget()">👤 شخصیت جدید پیشنهاد بده</a>
</div> </div>
<div id="new-person-widget" style="display:none;"> <div id="new-person-widget" style="display:none;">
@ -2445,29 +2648,24 @@ else
</div> </div>
</div> </div>
<div class="up-field"> <div class="up-field">
<label for="geotag-new-person-familytreecaption">عنوان شجره‌نامه (اختیاری، فقط اگر این فرد ریشهٔ یک خانواده/سلسله شناخته‌شده است - مثلاً «ساسانیان»)</label> <label for="geotag-new-person-familytreecaption">عنوان شجره‌نامه (اختیاری، فقط اگر این شخصیت ریشهٔ یک خانواده/سلسله شناخته‌شده است - مثلاً «ساسانیان»)</label>
<input type="text" id="geotag-new-person-familytreecaption" /> <input type="text" id="geotag-new-person-familytreecaption" />
</div> </div>
<p class="up-hint"> <p class="up-hint">
اگر خویشاوندی یا وابستگی این فرد با فردی دیگر را می‌دانید، می‌توانید همین‌جا اضافه کنید (فرد دیگر باید از قبل در فهرست موجود باشد؛ اگر خویشاوندی یا وابستگی این شخصیت با شخصیتی دیگر را می‌دانید، می‌توانید همین‌جا اضافه کنید (شخصیت دیگر باید از قبل در فهرست موجود باشد؛
برای فردی که او هم تازه است، ابتدا این فرم را برای او هم به‌صورت جدا تکمیل و ثبت کنید، سپس این رابطه را اضافه کنید). برای شخصیتی که او هم تازه است، ابتدا این فرم را برای او هم به‌صورت جدا تکمیل و ثبت کنید، سپس این رابطه را اضافه کنید).
</p> </p>
<div id="geotag-person-relatives-list"></div> <div id="geotag-person-relatives-list"></div>
<a role="button" class="up-btn up-btn--sm up-btn--ghost" onclick="addPersonRelativeRow()">➕ افزودن خویشاوند یا وابستگی</a> <a role="button" class="up-btn up-btn--sm up-btn--ghost" onclick="addPersonRelativeRow()">➕ افزودن خویشاوند یا وابستگی</a>
</div> </div>
<div class="up-check-row">
<input type="checkbox" id="geotag-ignore-in-category" />
<label for="geotag-ignore-in-category">در نقشهٔ کلی مجموعه نادیده گرفته شود (مثلاً مکانی که فقط برای مقایسه ذکر شده)</label>
</div>
<div class="up-field"> <div class="up-field">
<label for="geotag-note">دلیل این برچسب (اختیاری، به‌ویژه برای اشاره‌های غیرمستقیم مثل ابجد یا تلمیح به رویدادی شناخته‌شده)</label> <label for="persontag-note">دلیل این برچسب (اختیاری، به‌ویژه برای اشاره‌های غیرمستقیم به این شخصیت)</label>
<textarea id="geotag-note" rows="2"></textarea> <textarea id="persontag-note" rows="2"></textarea>
</div> </div>
<a role="button" class="up-btn up-btn--ghost" onclick="addGeoDateTagSuggestion()">➕ افزودن به فهرست پیشنهادها</a> <a role="button" class="up-btn up-btn--ghost" onclick="addPersonTagSuggestion()">➕ افزودن به فهرست پیشنهادها</a>
</div> </div>
<div class="up-card"> <div class="up-card">

View File

@ -100,7 +100,7 @@ else
{ {
<div class="spacer">&nbsp;</div> <div class="spacer">&nbsp;</div>
<div class="poem"> <div class="poem">
<h2>اشعار برچسب‌خورده با این فرد</h2> <h2>اشعار برچسب‌خورده با این شخصیت</h2>
<ul> <ul>
@foreach (var tag in Model.Poems) @foreach (var tag in Model.Poems)
{ {
@ -116,7 +116,7 @@ else
{ {
<div class="spacer">&nbsp;</div> <div class="spacer">&nbsp;</div>
<div class="poem"> <div class="poem">
<p><small>هنوز شعری با این فرد برچسب‌گذاری نشده است.</small></p> <p><small>هنوز شعری با این شخصیت برچسب‌گذاری نشده است.</small></p>
</div> </div>
} }

View File

@ -113,7 +113,7 @@ namespace GanjooRazor.Pages
Person = JsonConvert.DeserializeObject<GanjoorRelatedPerson>(await personResponse.Content.ReadAsStringAsync()); Person = JsonConvert.DeserializeObject<GanjoorRelatedPerson>(await personResponse.Content.ReadAsStringAsync());
if (Person == null) if (Person == null)
{ {
LastError = "فردی با این کد پیدا نشد."; LastError = "شخصیتی با این کد پیدا نشد.";
return Page(); return Page();
} }
@ -144,8 +144,8 @@ namespace GanjooRazor.Pages
bool subjectServes = a.SubjectIsPerson1 || IsSymmetricAffiliation(a.AffiliationType); bool subjectServes = a.SubjectIsPerson1 || IsSymmetricAffiliation(a.AffiliationType);
AffiliationRows.Add(new PersonAffiliationDisplayRow() AffiliationRows.Add(new PersonAffiliationDisplayRow()
{ {
SentenceBeforeOtherName = subjectServes ? $"این فرد {roleWord} " : "", SentenceBeforeOtherName = subjectServes ? $"این شخصیت {roleWord} " : "",
SentenceAfterOtherName = subjectServes ? " بود" : $" {roleWord} این فرد بود", SentenceAfterOtherName = subjectServes ? " بود" : $" {roleWord} این شخصیت بود",
OtherPersonId = a.OtherPersonId, OtherPersonId = a.OtherPersonId,
OtherPersonName = a.OtherPersonName, OtherPersonName = a.OtherPersonName,
Note = a.Note, Note = a.Note,

View File

@ -2640,7 +2640,7 @@ function setupPersonAutocomplete(searchInput, hiddenInput, resultsBox, people, o
if (matches.length == 0) { if (matches.length == 0) {
var emptyRow = document.createElement('div'); var emptyRow = document.createElement('div');
emptyRow.className = 'up-autocomplete-item up-autocomplete-item--empty'; emptyRow.className = 'up-autocomplete-item up-autocomplete-item--empty';
emptyRow.textContent = '➕ «' + typedText + '» در فهرست پیدا نشد؛ به‌عنوان فرد جدید تعریف شود'; emptyRow.textContent = '➕ «' + typedText + '» در فهرست پیدا نشد؛ به‌عنوان شخصیت جدید تعریف شود';
emptyRow.addEventListener('click', function () { emptyRow.addEventListener('click', function () {
hide(); hide();
if (onNotFound) onNotFound(typedText); if (onNotFound) onNotFound(typedText);

View File

@ -91,7 +91,7 @@ namespace RMuseum.Services.Implementation
var person = await _context.GanjoorRelatedPersons.Where(p => p.Id == id).SingleOrDefaultAsync(); var person = await _context.GanjoorRelatedPersons.Where(p => p.Id == id).SingleOrDefaultAsync();
if (person == null) if (person == null)
{ {
return new RServiceResult<GanjoorPersonRelationsViewModel>(null, "فرد پیدا نشد."); return new RServiceResult<GanjoorPersonRelationsViewModel>(null, "شخصیت پیدا نشد.");
} }
var relationRows = await _context.GanjoorPersonRelations var relationRows = await _context.GanjoorPersonRelations

View File

@ -854,12 +854,12 @@ namespace RMuseum.Services.Implementation
} }
catch (Exception exp) catch (Exception exp)
{ {
return new Tuple<int, string>(0, $"برچسب فرد پیشنهادی قابل تفسیر نیست: {exp.Message}"); return new Tuple<int, string>(0, $"برچسب شخصیت پیشنهادی قابل تفسیر نیست: {exp.Message}");
} }
if (graph?.Person == null || string.IsNullOrWhiteSpace(graph.Person.LocalKey)) if (graph?.Person == null || string.IsNullOrWhiteSpace(graph.Person.LocalKey))
{ {
return new Tuple<int, string>(0, "برچسب فرد پیشنهادی ناقص است."); return new Tuple<int, string>(0, "برچسب شخصیت پیشنهادی ناقص است.");
} }
// first pass: resolve/create every node (the tagged person plus anyone else referenced), // first pass: resolve/create every node (the tagged person plus anyone else referenced),
@ -889,7 +889,7 @@ namespace RMuseum.Services.Implementation
var existingPerson = await _context.GanjoorRelatedPersons.Where(p => p.Id == node.ExistingPersonId).AnyAsync(); var existingPerson = await _context.GanjoorRelatedPersons.Where(p => p.Id == node.ExistingPersonId).AnyAsync();
if (!existingPerson) if (!existingPerson)
{ {
return new Tuple<int, string>(0, $"فرد موجود با کد {node.ExistingPersonId} پیدا نشد."); return new Tuple<int, string>(0, $"شخصیت موجود با کد {node.ExistingPersonId} پیدا نشد.");
} }
localKeyToPersonId[node.LocalKey] = node.ExistingPersonId.Value; localKeyToPersonId[node.LocalKey] = node.ExistingPersonId.Value;
continue; continue;
@ -927,7 +927,7 @@ namespace RMuseum.Services.Implementation
if (!localKeyToPersonId.TryGetValue(relation.Person1 ?? "", out int person1Id) || if (!localKeyToPersonId.TryGetValue(relation.Person1 ?? "", out int person1Id) ||
!localKeyToPersonId.TryGetValue(relation.Person2 ?? "", out int person2Id)) !localKeyToPersonId.TryGetValue(relation.Person2 ?? "", out int person2Id))
{ {
return new Tuple<int, string>(0, "یکی از روابط پیشنهادی به فردی خارج از این پیشنهاد اشاره می‌کند."); return new Tuple<int, string>(0, "یکی از روابط پیشنهادی به شخصیتی خارج از این پیشنهاد اشاره می‌کند.");
} }
if (string.Equals(relation.Kind, "affiliation", StringComparison.OrdinalIgnoreCase)) if (string.Equals(relation.Kind, "affiliation", StringComparison.OrdinalIgnoreCase))

View File

@ -2132,7 +2132,7 @@ namespace RMuseum.Services.Implementation
bool hasPerson = geoDateTag.PersonId != null || !string.IsNullOrWhiteSpace(geoDateTag.SuggestedPersonGraphJson); bool hasPerson = geoDateTag.PersonId != null || !string.IsNullOrWhiteSpace(geoDateTag.SuggestedPersonGraphJson);
if (!hasLocation && !hasDate && !hasPerson) if (!hasLocation && !hasDate && !hasPerson)
{ {
return new RServiceResult<GanjoorPoemCorrectionViewModel>(null, "برچسب جغرافیایی/تاریخی/فردی باید حداقل شامل مکان، تاریخ یا فرد باشد."); return new RServiceResult<GanjoorPoemCorrectionViewModel>(null, "برچسب جغرافیایی/تاریخی/شخصیتی باید حداقل شامل مکان، تاریخ یا شخصیت باشد.");
} }
if (geoDateTag.LocationId == null && !string.IsNullOrWhiteSpace(geoDateTag.SuggestedLocationName) if (geoDateTag.LocationId == null && !string.IsNullOrWhiteSpace(geoDateTag.SuggestedLocationName)