#421 geo tag suggestion + typo fixes in editors

This commit is contained in:
Hamid Reza Mohammadi 2026-09-25 14:37:51 +03:30
parent aaacfe0442
commit fc241748e7
6 changed files with 151 additions and 6 deletions

View File

@ -121,6 +121,9 @@
// GanjoorPoemGeoDateTagCorrection's shape so it can be sent as-is
var geoDateTagSuggestions = @Html.Raw(Model.MyLastEditGeoDateTagsJson);
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
// new-location suggestion that's actually already catalogued - see _confirmNewLocationSuggestion
var allGeoLocations = @Html.Raw(Model.AllLocationsJson);
$(document).ready(function () {
renderPendingGeoDateTags();
});
@ -197,6 +200,63 @@
geoDateTagMap.setView([lat, lng], 12);
}
// great-circle distance in km - just for catching obviously-duplicate locations, not
// geodesy-grade precision
function _geoDistanceKm(lat1, lng1, lat2, lng2) {
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// For a brand-new location suggestion, checks it against the full existing-location catalog
// (allGeoLocations) before it's staged. Two cases:
// - near-identical coordinates to an existing location: almost certainly the same real place,
// typed in again instead of picked from the dropdown - blocked outright, no point asking.
// - same name as an existing location but meaningfully different coordinates: could be a real
// second place that happens to share a name, or could be a coordinate mistake - asks the
// user to explicitly confirm rather than guessing either way.
// Returns true if the tag should be added as-is.
async function _confirmNewLocationSuggestion(tag) {
if (!tag || tag.locationId != null || !tag.suggestedLocationName) {
return true; // an existing/selected location, or no location at all - nothing to check
}
var NEAR_DUPLICATE_KM = 5; // close enough that it's almost certainly the same real place
var trimmedName = tag.suggestedLocationName.trim();
for (var i = 0; i < allGeoLocations.length; i++) {
var existing = allGeoLocations[i];
var distanceKm = _geoDistanceKm(tag.suggestedLatitude, tag.suggestedLongitude, existing.latitude, existing.longitude);
if (distanceKm <= NEAR_DUPLICATE_KM) {
upToast('این مکان با نام «' + existing.name + '» و مختصات بسیار نزدیک از قبل ثبت شده است. لطفاً به‌جای پیشنهاد مکان جدید، همان را از فهرست «مکان» انتخاب کنید.', 'error');
return false;
}
}
var sameNameOtherPlaces = allGeoLocations.filter(function (existing) {
return existing.name && existing.name.trim().toLowerCase() === trimmedName.toLowerCase();
});
if (sameNameOtherPlaces.length > 0) {
var distances = sameNameOtherPlaces.map(function (existing) {
return Math.round(_geoDistanceKm(tag.suggestedLatitude, tag.suggestedLongitude, existing.latitude, existing.longitude));
});
var ok = await upConfirm(
'مکانی با نام «' + trimmedName + '» از قبل با مختصات دیگری در فهرست ثبت شده است (فاصله حدود ' + distances.join('، ') + ' کیلومتر). ' +
'آیا مطمئنید این مکان دیگری با همین نام است و اشتباهی در مختصات پیش نیامده؟',
{ okText: 'بله، مکان دیگری است', cancelText: 'انصراف' }
);
if (!ok) {
return false;
}
}
return true;
}
function renderPendingGeoDateTags() {
var container = document.getElementById('pending-geotag-suggestions');
if (geoDateTagSuggestions.length == 0) {
@ -297,10 +357,13 @@
}
}
function addGeoDateTagSuggestion() {
async function addGeoDateTagSuggestion() {
var tag = readGeoDateTagFromForm(true);
if (tag == null) return;
var proceed = await _confirmNewLocationSuggestion(tag);
if (!proceed) return;
geoDateTagSuggestions.push(tag);
resetGeoDateTagForm();
@ -308,7 +371,7 @@
upToast('به فهرست پیشنهادها افزوده شد. برای ثبت نهایی، دکمهٔ «ذخیره» را بزنید.');
}
function savePoemCorrections(id, verseCount) {
async function savePoemCorrections(id, verseCount) {
var verseOrderText = [];
var verseOrderMarkedForDelete = [];
var versePositions = [];
@ -370,6 +433,12 @@
// button is only needed to stage more than one tag before saving
var unstagedGeoDateTag = readGeoDateTagFromForm(false);
if (unstagedGeoDateTag != null) {
var proceedWithUnstaged = await _confirmNewLocationSuggestion(unstagedGeoDateTag);
if (!proceedWithUnstaged) {
// let them fix or clear the "add a new tag" form instead of silently dropping
// it or saving everything else without it
return;
}
geoDateTagSuggestions.push(unstagedGeoDateTag);
resetGeoDateTagForm();
renderPendingGeoDateTags();
@ -1441,12 +1510,12 @@ else
</div>
<div class="up-field">
<label>حروف قافیهٔ فعلی</label>
<label>حروف قافیه و ردیفِ فعلی</label>
<p id="rhyme-letters-old" class="up-field-static">@Model.RhymeLetters</p>
</div>
<div class="up-field">
<label for="rhyme-letters"><span class="up-field-icon" aria-hidden="true">🔤</span> حروف قافیهٔ ویراستهٔ من (بدون فاصله و حرکات)</label>
<label for="rhyme-letters"><span class="up-field-icon" aria-hidden="true">🔤</span> حروف قافیه و ردیفِ ویراستهٔ من (بدون فاصله و حرکات)</label>
<div class="up-btn-group up-mt-0">
@if (Model.MyLastEdit != null && !string.IsNullOrEmpty(Model.MyLastEdit.RhymeLetters))
{
@ -1458,6 +1527,11 @@ else
}
<a role="button" onclick="computePoemRhyme(@Model.PageInformation.Id, '#rhyme-letters')" class="up-btn up-btn--ghost">🧮 محاسبه</a>
</div>
<p class="up-hint">
این فیلد قافیه و ردیف را با هم و بدون فاصله می‌خواهد، نه فقط قافیه. اگر بعد از حروف قافیه کلمه یا کلماتی عیناً در همهٔ ابیات تکرار شده باشند (ردیف)، آن‌ها هم باید در انتها بیایند.
برای نمونه در بیت «... اگر تو <b>یله کنی</b>» / «... اگر تو <b>گله کنی</b>» چون «کنی» عیناً در هر دو مصرع تکرار شده و ردیف شعر است، حروف قافیه و ردیف باید «<b>لهکنی</b>» نوشته شود؛ نه «له» (با نادیده گرفتن ردیف) و نه «ه کنی» (با فاصلهٔ اشتباه).
همچنین از حرکات (فتحه، ضمه، کسره) یا هر نشانهٔ اضافهٔ دیگر در این فیلد استفاده نشود.
</p>
</div>
<div id="secondr-btn">

View File

@ -119,6 +119,19 @@ namespace GanjooRazor.Areas.User.Pages
/// </summary>
public List<GanjoorGeoLocation> Locations { get; set; }
/// <summary>
/// camelCase JSON of Locations (id/name/latitude/longitude only), used client-side to warn
/// about a new-location suggestion that's actually already in the catalog (same/near
/// coordinates) or shares a name with a different, already-catalogued place. Pre-serialized
/// here rather than inline in the .cshtml script block for the same reason as
/// MyLastEditGeoDateTagsJson - avoids embedding a C# object initializer inside a JS statement.
/// </summary>
public string AllLocationsJson =>
JsonConvert.SerializeObject(
(Locations ?? new List<GanjoorGeoLocation>()).Select(l => new { l.Id, l.Name, l.Latitude, l.Longitude }),
new JsonSerializerSettings { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() }
);
/// <summary>
/// poem geo date tags

View File

@ -237,12 +237,12 @@ else
<h2 class="up-card__title">قافیه</h2>
<div class="up-field">
<label><span class="up-field-icon" aria-hidden="true">🔤</span> حروف قافیهٔ فعلی</label>
<label><span class="up-field-icon" aria-hidden="true">🔤</span> حروف قافیه و ردیفِ فعلی</label>
<p id="rhyme-letters-old" class="up-field-static">@Model.PoemSection.RhymeLetters</p>
</div>
<div class="up-field">
<label for="rhyme-letters"><span class="up-field-icon" aria-hidden="true">🔤</span> حروف قافیهٔ پیشنهادی من (بدون فاصله و حرکات)</label>
<label for="rhyme-letters"><span class="up-field-icon" aria-hidden="true">🔤</span> حروف قافیه و ردیفِ پیشنهادی من (بدون فاصله و حرکات)</label>
<div class="up-btn-group up-mt-0">
@if (Model.MyLastEdit != null && !string.IsNullOrEmpty(Model.MyLastEdit.RhymeLetters))
{
@ -254,6 +254,11 @@ else
}
<a role="button" onclick="computeSectionRhyme(@Model.PoemSection.Id, '#rhyme-letters')" class="up-btn up-btn--ghost">🧮 محاسبه</a>
</div>
<p class="up-hint">
این فیلد قافیه و ردیف را با هم و بدون فاصله می‌خواهد، نه فقط قافیه. اگر بعد از حروف قافیه کلمه یا کلماتی عیناً در همهٔ ابیات تکرار شده باشند (ردیف)، آن‌ها هم باید در انتها بیایند.
برای نمونه در بیت «... اگر تو <b>یله کنی</b>» / «... اگر تو <b>گله کنی</b>» چون «کنی» عیناً در هر دو مصرع تکرار شده و ردیف شعر است، حروف قافیه و ردیف باید «<b>لهکنی</b>» نوشته شود؛ نه «له» (با نادیده گرفتن ردیف) و نه «ه کنی» (با فاصلهٔ اشتباه).
همچنین از حرکات (فتحه، ضمه، کسره) یا هر نشانهٔ اضافهٔ دیگر در این فیلد استفاده نشود.
</p>
</div>
</div>

View File

@ -21394,6 +21394,14 @@
<param name="id"></param>
<returns></returns>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorService._GeoDistanceKm(System.Double,System.Double,System.Double,System.Double)">
<summary>
great-circle distance between two points, in kilometers - used only for catching
obviously-duplicate new-location suggestions (someone retyping a place that's already
in the catalog under slightly different coordinates), so this doesn't need to be
geodesy-grade precise, just good enough at the few-kilometers scale
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorService.AddPoemGeoDateTagAsync(RMuseum.Models.Ganjoor.PoemGeoDateTag)">
<summary>
add poem geo tag

View File

@ -15,6 +15,23 @@ namespace RMuseum.Services.Implementation
public partial class GanjoorService : IGanjoorService
{
/// <summary>
/// great-circle distance between two points, in kilometers - used only for catching
/// obviously-duplicate new-location suggestions (someone retyping a place that's already
/// in the catalog under slightly different coordinates), so this doesn't need to be
/// geodesy-grade precise, just good enough at the few-kilometers scale
/// </summary>
private double _GeoDistanceKm(double lat1, double lng1, double lat2, double lng2)
{
const double earthRadiusKm = 6371.0;
double dLat = (lat2 - lat1) * Math.PI / 180.0;
double dLng = (lng2 - lng1) * Math.PI / 180.0;
double a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(lat1 * Math.PI / 180.0) * Math.Cos(lat2 * Math.PI / 180.0) *
Math.Sin(dLng / 2) * Math.Sin(dLng / 2);
return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
}
private int? _PrepareLunarDateTotalNumber(PoemGeoDateTag tag)
{
if (tag.LunarYear == null)

View File

@ -2107,6 +2107,11 @@ namespace RMuseum.Services.Implementation
// GetPoemById above always loads this
var approvedGeoDateTags = poem.GeoDateTags ?? Array.Empty<PoemGeoDateTag>();
// lazily loaded only if a tag actually suggests a brand new location - most
// submissions either pick an existing location or only carry a date, so this
// extra query is skipped entirely in the common case
GanjoorGeoLocation[] allLocations = null;
foreach (var geoDateTag in correction.GeoDateTags)
{
if (!geoDateTag.MarkForDelete)
@ -2119,6 +2124,29 @@ namespace RMuseum.Services.Implementation
return new RServiceResult<GanjoorPoemCorrectionViewModel>(null, "برچسب جغرافیایی/تاریخی باید حداقل شامل مکان یا تاریخ باشد.");
}
if (geoDateTag.LocationId == null && !string.IsNullOrWhiteSpace(geoDateTag.SuggestedLocationName)
&& geoDateTag.SuggestedLatitude != null && geoDateTag.SuggestedLongitude != null)
{
// catches "this is already a catalogued place" regardless of which couplet/poem
// it was previously tagged on - a hard reject, since coordinates a few kilometers
// apart are effectively certainly the same real place, not a judgment call
const double nearDuplicateKm = 5.0;
if (allLocations == null)
{
allLocations = await _context.GanjoorGeoLocations.AsNoTracking().ToArrayAsync();
}
var nearExistingLocation = allLocations.FirstOrDefault(l =>
_GeoDistanceKm((double)geoDateTag.SuggestedLatitude, (double)geoDateTag.SuggestedLongitude, l.Latitude, l.Longitude) <= nearDuplicateKm);
if (nearExistingLocation != null)
{
return new RServiceResult<GanjoorPoemCorrectionViewModel>(null,
$"مکانی با نام «{nearExistingLocation.Name}» با مختصات بسیار نزدیک از قبل در فهرست مکان‌ها ثبت شده است. لطفاً به‌جای پیشنهاد مکان جدید، همان را از فهرست «مکان» انتخاب کنید.");
}
// a same-name-but-far-away match is deliberately NOT rejected here - it can
// legitimately be a different real place sharing a name, and the editor's
// client-side check already asks the user to confirm that before it gets here
}
// PoemGeoDateTag.CoupletIndex uses 0 for "whole poem", matching a null CoupletIndex here
int effectiveCoupletIndex = geoDateTag.CoupletIndex ?? 0;
var approvedForCouplet = approvedGeoDateTags.Where(t => t.CoupletIndex == effectiveCoupletIndex);