diff --git a/GanjooRazor/Areas/User/Pages/Editor.cshtml b/GanjooRazor/Areas/User/Pages/Editor.cshtml index f3b87bfd..78a0816a 100644 --- a/GanjooRazor/Areas/User/Pages/Editor.cshtml +++ b/GanjooRazor/Areas/User/Pages/Editor.cshtml @@ -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
- +

@Model.RhymeLetters

- +
@if (Model.MyLastEdit != null && !string.IsNullOrEmpty(Model.MyLastEdit.RhymeLetters)) { @@ -1458,6 +1527,11 @@ else } 🧮 محاسبه
+

+ این فیلد قافیه و ردیف را با هم و بدون فاصله می‌خواهد، نه فقط قافیه. اگر بعد از حروف قافیه کلمه یا کلماتی عیناً در همهٔ ابیات تکرار شده باشند (ردیف)، آن‌ها هم باید در انتها بیایند. + برای نمونه در بیت «... اگر تو یله کنی» / «... اگر تو گله کنی» چون «کنی» عیناً در هر دو مصرع تکرار شده و ردیف شعر است، حروف قافیه و ردیف باید «لهکنی» نوشته شود؛ نه «له» (با نادیده گرفتن ردیف) و نه «ه کنی» (با فاصلهٔ اشتباه). + همچنین از حرکات (فتحه، ضمه، کسره) یا هر نشانهٔ اضافهٔ دیگر در این فیلد استفاده نشود. +

diff --git a/GanjooRazor/Areas/User/Pages/Editor.cshtml.cs b/GanjooRazor/Areas/User/Pages/Editor.cshtml.cs index 07d4c576..c83d2d2c 100644 --- a/GanjooRazor/Areas/User/Pages/Editor.cshtml.cs +++ b/GanjooRazor/Areas/User/Pages/Editor.cshtml.cs @@ -119,6 +119,19 @@ namespace GanjooRazor.Areas.User.Pages /// public List Locations { get; set; } + /// + /// 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. + /// + public string AllLocationsJson => + JsonConvert.SerializeObject( + (Locations ?? new List()).Select(l => new { l.Id, l.Name, l.Latitude, l.Longitude }), + new JsonSerializerSettings { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() } + ); + /// /// poem geo date tags diff --git a/GanjooRazor/Areas/User/Pages/Section.cshtml b/GanjooRazor/Areas/User/Pages/Section.cshtml index a4833686..24d8f2f5 100644 --- a/GanjooRazor/Areas/User/Pages/Section.cshtml +++ b/GanjooRazor/Areas/User/Pages/Section.cshtml @@ -237,12 +237,12 @@ else

قافیه

- +

@Model.PoemSection.RhymeLetters

- +
@if (Model.MyLastEdit != null && !string.IsNullOrEmpty(Model.MyLastEdit.RhymeLetters)) { @@ -254,6 +254,11 @@ else } 🧮 محاسبه
+

+ این فیلد قافیه و ردیف را با هم و بدون فاصله می‌خواهد، نه فقط قافیه. اگر بعد از حروف قافیه کلمه یا کلماتی عیناً در همهٔ ابیات تکرار شده باشند (ردیف)، آن‌ها هم باید در انتها بیایند. + برای نمونه در بیت «... اگر تو یله کنی» / «... اگر تو گله کنی» چون «کنی» عیناً در هر دو مصرع تکرار شده و ردیف شعر است، حروف قافیه و ردیف باید «لهکنی» نوشته شود؛ نه «له» (با نادیده گرفتن ردیف) و نه «ه کنی» (با فاصلهٔ اشتباه). + همچنین از حرکات (فتحه، ضمه، کسره) یا هر نشانهٔ اضافهٔ دیگر در این فیلد استفاده نشود. +

diff --git a/RMuseum/RMuseum.xml b/RMuseum/RMuseum.xml index e255bfe6..167762c1 100644 --- a/RMuseum/RMuseum.xml +++ b/RMuseum/RMuseum.xml @@ -21394,6 +21394,14 @@ + + + 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 + + add poem geo tag diff --git a/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-PoemGeoDateTag.cs b/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-PoemGeoDateTag.cs index b66e3e67..c3481a9d 100644 --- a/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-PoemGeoDateTag.cs +++ b/RMuseum/Services/Implementation/GanjoorService-Partials/GanjoorService-PoemGeoDateTag.cs @@ -15,6 +15,23 @@ namespace RMuseum.Services.Implementation public partial class GanjoorService : IGanjoorService { + /// + /// 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 + /// + 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) diff --git a/RMuseum/Services/Implementation/GanjoorService.cs b/RMuseum/Services/Implementation/GanjoorService.cs index b83ab0ff..5ebf182a 100644 --- a/RMuseum/Services/Implementation/GanjoorService.cs +++ b/RMuseum/Services/Implementation/GanjoorService.cs @@ -2107,6 +2107,11 @@ namespace RMuseum.Services.Implementation // GetPoemById above always loads this var approvedGeoDateTags = poem.GeoDateTags ?? Array.Empty(); + // 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(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(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);