Remove Tajik: TajikGanjoor site, API, services, export, transliteration

- delete TajikGanjoor project (removed from solution)
- drop Tajik endpoints/services/DTOs/entities; migration DiwanRemoveTajik drops their tables
- remove Tajik admin buttons/handlers and appsettings sections
- README is now Diwan's; upstream README kept in docs/GANJOOR-README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-04 23:37:39 +02:00
parent 6bbd650741
commit 30a8fd8542
353 changed files with 6725 additions and 79090 deletions

View File

@ -1,62 +0,0 @@
# دیوان · Diwan site
This is a fork of [GanjoorService](https://github.com/ganjoor/GanjoorService) (GPL-3.0), the software behind ganjoor.net, adapted to serve **classical Urdu poetry and prose** from [diwan-data](https://github.com/anas-rashid/diwan-data).
## Changes from upstream
- **Branding:** "گنجور" becomes "دیوان" throughout the site. Upstream is credited in the footer.
- **Urdu basics:** pages are `lang="ur"`. The home page, footer and century groups (Hijri centuries, e.g. "تیرہویں صدی ہجری") are in Urdu. Deeper pages, such as admin and account pages, are still Persian.
- **Fonts:** Noto Nastaliq Urdu by default, with a **نستعلیق / نسخ** switch (Noto Naskh Arabic) at the bottom left. The choice is remembered per browser.
- **Footer:** links to Ganjoor-only services (Hafez divination, music index, etc.) are removed. Links to the Wikisource source, the data and the code are added.
- **Linux/Docker:** `Dockerfile` + `docker-compose.yml` (SQL Server 2022, API, site, Caddy for HTTPS).
- **Config fixes so env vars work:** the four places that read `appsettings.json` directly now also read environment variables. The JWT issuer follows `RSecurityBackend:ApplicationName` instead of the hard-coded "Ganjoor". `deploy/entrypoint.sh` copies the settings that RSecurityBackend reads only from `appsettings.json` (connection string, secret, app name, admin email) into the file at container start.
- **Links:** `ganjoor.net` links to the site's own pages are now relative. Links to Ganjoor's other services (blog, audio, etc.) are left as they are.
- **Locale:** `ur-PK`.
## Deploy (Ubuntu/Debian x86-64, e.g. Vultr)
```sh
# 1. Docker
curl -fsSL https://get.docker.com | sh
# 2. Code + config
git clone https://github.com/anas-rashid/diwan.git && cd diwan
cp .env.example .env && nano .env # domains, passwords, admin email
# 3. DNS: point SITE_DOMAIN and API_DOMAIN (A records) at the server, then:
docker compose up -d --build # first build takes a few minutes
docker compose logs -f api # wait for "Application started"
```
SQL Server needs about 2 GB of RAM. Use a plan with at least 4 GB in total.
### Load the data
1. Open `https://SITE_DOMAIN/login` and sign in with `ADMIN_EMAIL` and the password **`Test!123`**. The first login creates the admin account with that fixed password (RSecurityBackend's default; upstream's guide is wrong about this). **Change it right away** in the user panel.
2. On the import page that opens (or **Admin → مالی و سایت → درون‌ریزی دادهٔ عمومی**), choose **Internet URL** and enter:
```
https://cdn.jsdelivr.net/gh/anas-rashid/diwan-data@main/
```
3. The import runs in the background (about 11k poems). Century groups are rebuilt automatically when it finishes.
Re-running the import adds new poems and leaves existing ones untouched, so it can be repeated after diwan-data's daily sync.
### Update
```sh
git pull && docker compose up -d --build
```
## Build locally (macOS/Linux)
```sh
cd RMuseum # its global.json pins SDK 10.0.302; newer SDKs fail on some upstream Razor views
dotnet build RMuseum.csproj -p:EnableWindowsTargeting=true
dotnet build ../GanjooRazor/GanjooRazor.csproj -p:EnableWindowsTargeting=true
```
Running it needs SQL Server, so use the Docker setup above. SQL Server's image is x86-64 only.
## License
GPL-3.0, same as upstream (see `LICENSE`). Data: see [diwan-data](https://github.com/anas-rashid/diwan-data).

View File

@ -228,14 +228,11 @@
<a role="button" onclick="defaultHtmlText()" class="up-btn up-btn--ghost up-btn--sm" title="چینش پیش‌فرض ابیات">چینش پیش‌فرض ابیات</a>
<a role="button" onclick="cleanCache(@Model.PageInformation.Id)" class="up-btn up-btn--ghost up-btn--sm" title="حذف کش صفحه">حذف کش صفحه</a>
<a role="button" onclick="rebuildSitemap()" class="up-btn up-btn--ghost up-btn--sm" title="بازسازی نقشهٔ سایت">بازسازی نقشهٔ سایت</a>
<a role="button" onclick="rebuildTajikSitemap()" class="up-btn up-btn--ghost up-btn--sm" title="بازسازی نقشهٔ سایت">بازسازی نقشهٔ سایت تاجیکی</a>
<a role="button" onclick="refillCoupletCounts()" class="up-btn up-btn--ghost up-btn--sm">بازشماری تعداد ابیات قطعات</a>
<a role="button" onclick="rebuildStats()" class="up-btn up-btn--ghost up-btn--sm" title="بازسازی صفحات آمار و اوزان عروضی">بازسازی صفحات آمار و اوزان عروضی</a>
<a role="button" onclick="rebuildRelatedPages()" class="up-btn up-btn--ghost up-btn--sm" title="بازسازی صفحات استقبالها">بازسازی صفحات استقبالها</a>
<a role="button" onclick="rebuildDigitalSourcesStats()" class="up-btn up-btn--ghost up-btn--sm" title="بازسازی آمار منابع دیجیتال">بازسازی آمار منابع دیجیتال</a>
<a role="button" onclick="regeneratePublicData()" class="up-btn up-btn--ghost up-btn--sm" title="بازسازی و ارسال دادهٔ عمومی دیوان">بازسازی دادهٔ عمومی</a>
<a role="button" onclick="regenerateTajikPublicData()" class="up-btn up-btn--ghost up-btn--sm" title="بازسازی و ارسال دادهٔ عمومی تاجیکی">بازسازی دادهٔ عمومی تاجیکی</a>
<a role="button" onclick="transilerate()" class="up-btn up-btn--ghost up-btn--sm" title="برگردان تاجیکی">برگردان تاجیکی</a>
</div>
@if (Model.PageInformation.PoetOrCat != null)

View File

@ -303,23 +303,6 @@ namespace GanjooRazor.Areas.Admin.Pages
return new OkObjectResult(false);
}
public async Task<IActionResult> OnPostRebuildTajikSitemapAsync()
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
{
HttpResponseMessage response = await secureClient.PostAsync($"{APIRoot.Url}/api/ganjoor/sitemap/tajik", null);
if (!response.IsSuccessStatusCode)
{
return BadRequest(JsonConvert.DeserializeObject<string>(await response.Content.ReadAsStringAsync()));
}
return new OkObjectResult(true);
}
}
return new OkObjectResult(false);
}
public async Task<IActionResult> OnPostRebuildStatsAsync()
{
@ -360,27 +343,6 @@ namespace GanjooRazor.Areas.Admin.Pages
return new OkObjectResult(false);
}
/// <summary>
/// (re)generate the separate Tajik (Cyrillic) public data export — same background-job
/// pattern as the main one, different repo
/// </summary>
public async Task<IActionResult> OnPostRegenerateTajikPublicDataAsync()
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
{
HttpResponseMessage response = await secureClient.PostAsync($"{APIRoot.Url}/api/ganjoor/tajik/publicdata/batchexport", null);
if (!response.IsSuccessStatusCode)
{
return BadRequest(JsonConvert.DeserializeObject<string>(await response.Content.ReadAsStringAsync()));
}
return new OkObjectResult(true);
}
}
return new OkObjectResult(false);
}
public async Task<IActionResult> OnPostRefillSectionsCoupletCountsAsync()
{
@ -436,23 +398,6 @@ namespace GanjooRazor.Areas.Admin.Pages
return new OkObjectResult(false);
}
public async Task<IActionResult> OnPostTransilerateAsync()
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
{
HttpResponseMessage response = await secureClient.PutAsync($"{APIRoot.Url}/api/ganjoor/transilerate", null);
if (!response.IsSuccessStatusCode)
{
return BadRequest(JsonConvert.DeserializeObject<string>(await response.Content.ReadAsStringAsync()));
}
return new OkObjectResult(true);
}
}
return new OkObjectResult(false);
}

View File

@ -158,25 +158,6 @@
});
}
async function rebuildTajikSitemap() {
var ok = await upConfirm('آیا از بازسازی نقشهٔ سایت تاجیکی اطمینان دارید؟');
if (!ok) return;
var url = '?handler=RebuildTajikSitemap';
$.ajax({
type: "POST",
url: url,
error: function () {
upToast('خطا رخ داد.', 'error');
},
success: function () {
upToast('فرایند بازسازی نقشهٔ سایت تاجیکی شروع شد.', 'success');
},
});
}
async function rebuildStats() {
@ -217,25 +198,6 @@
});
}
async function regenerateTajikPublicData() {
var ok = await upConfirm('آیا از بازسازی و ارسال دادهٔ عمومی تاجیکی (مخزن گیت جداگانه) اطمینان دارید؟');
if (!ok) return;
var url = '?handler=RegenerateTajikPublicData';
$.ajax({
type: "POST",
url: url,
error: function () {
upToast('خطا رخ داد.', 'error');
},
success: function () {
upToast('فرایند بازسازی دادهٔ عمومی تاجیکی در پس‌زمینه شروع شد.', 'success');
},
});
}
async function refillCoupletCounts() {
@ -297,25 +259,6 @@
async function transilerate() {
var ok = await upConfirm('آیا از شروع برگردان تاجیکی اطمینان دارید؟');
if (!ok) return;
var url = '?handler=Transilerate';
$.ajax({
type: "POST",
url: url,
error: function () {
upToast('خطا رخ داد.', 'error');
},
success: function () {
upToast('فرایند شروع شد.', 'success');
},
});
}
async function rebuildRebuildWordCounts(poetId) {
var onlyCurrentPoet = await upConfirm('فقط شاعر جاری؟', { danger: false });
if (!onlyCurrentPoet) {

View File

@ -1,14 +1,12 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.8.12009.203 stable
VisualStudioVersion = 18.8.12009.203
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RMuseum", "RMuseum\RMuseum.csproj", "{113EF3DE-E8A9-40EF-9DA0-8DFABB3CDD68}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GanjooRazor", "GanjooRazor\GanjooRazor.csproj", "{48F9CDFA-625F-4743-BF21-BD556F6F33E8}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TajikGanjoor", "TajikGanjoor\TajikGanjoor.csproj", "{1C51EB06-5190-4646-84C7-5EE712C86F57}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -23,10 +21,6 @@ Global
{48F9CDFA-625F-4743-BF21-BD556F6F33E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48F9CDFA-625F-4743-BF21-BD556F6F33E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48F9CDFA-625F-4743-BF21-BD556F6F33E8}.Release|Any CPU.Build.0 = Release|Any CPU
{1C51EB06-5190-4646-84C7-5EE712C86F57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1C51EB06-5190-4646-84C7-5EE712C86F57}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C51EB06-5190-4646-84C7-5EE712C86F57}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C51EB06-5190-4646-84C7-5EE712C86F57}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -1,38 +1,64 @@
> **This is Diwan (دیوان)**, a fork of GanjoorService that serves classical Urdu poetry and prose. See [DIWAN.md](DIWAN.md) for what changed and how to deploy it. The original README follows.
# دیوان · Diwan site
# GanjoorService
Ganjoor museum and ganjoor.net own backend (ASP.NET Core Web API) and frontend (Razor Pages) code
Diwan is a standalone project built on a fork of [GanjoorService](https://github.com/ganjoor/GanjoorService) (GPL-3.0), the software behind ganjoor.net, adapted to serve **classical Urdu poetry and prose** from [diwan-data](https://github.com/anas-rashid/diwan-data).
این کد وب سرویس [گنجینهٔ گنجور](https://museum.ganjoor.net) و [گنجور](https://ganjoor.net) و همچنین کد سایت گنجور است.
## Changes from upstream
[فهرست توابع در دسترس](https://api.ganjoor.net)
- **Tajik removed:** the TajikGanjoor site, Tajik API endpoints, services, export and transliteration are gone. Migration `DiwanRemoveTajik` drops their tables.
![https://api.ganjoor.net](https://user-images.githubusercontent.com/582212/91652208-14a63c00-eaaa-11ea-89c2-5acabdfda7de.png)
- **Branding:** "گنجور" becomes "دیوان" throughout the site. Upstream is credited in the footer.
- **Urdu basics:** pages are `lang="ur"`. The home page, footer and century groups (Hijri centuries, e.g. "تیرہویں صدی ہجری") are in Urdu. Deeper pages, such as admin and account pages, are still Persian.
- **Fonts:** Noto Nastaliq Urdu by default, with a **نستعلیق / نسخ** switch (Noto Naskh Arabic) at the bottom left. The choice is remembered per browser.
- **Footer:** links to Ganjoor-only services (Hafez divination, music index, etc.) are removed. Links to the Wikisource source, the data and the code are added.
- **Linux/Docker:** `Dockerfile` + `docker-compose.yml` (SQL Server 2022, API, site, Caddy for HTTPS).
- **Config fixes so env vars work:** the four places that read `appsettings.json` directly now also read environment variables. The JWT issuer follows `RSecurityBackend:ApplicationName` instead of the hard-coded "Ganjoor". `deploy/entrypoint.sh` copies the settings that RSecurityBackend reads only from `appsettings.json` (connection string, secret, app name, admin email) into the file at container start.
- **Links:** `ganjoor.net` links to the site's own pages are now relative. Links to Ganjoor's other services (blog, audio, etc.) are left as they are.
- **Locale:** `ur-PK`.
## Running it locally
## Deploy (Ubuntu/Debian x86-64, e.g. Vultr)
New to this codebase and want to run your own copy? See **[RUNNING_LOCALLY.md](RUNNING_LOCALLY.md)**
for a full step-by-step guide — cloning, database setup, and Visual Studio configuration.
```sh
# 1. Docker
curl -fsSL https://get.docker.com | sh
The one thing that guide covers in more depth but is worth knowing up front: **the production
database is never published**, since it contains private/user-linked data. What *is* published is
a git repository of the poetry content itself (poets, categories, poems — allowlisted, no user
data): **[github.com/ganjoor/ganjoor-data](https://github.com/ganjoor/ganjoor-data)**. A fresh
local install can pull real content from there via **Admin → مالی و سایت → درون‌ریزی دادهٔ عمومی**
(also reachable automatically the first time you run the site against an empty database) instead
of starting from nothing.
# 2. Code + config
git clone https://github.com/anas-rashid/diwan.git && cd diwan
cp .env.example .env && nano .env # domains, passwords, admin email
## Semantic search
# 3. DNS: point SITE_DOMAIN and API_DOMAIN (A records) at the server, then:
docker compose up -d --build # first build takes a few minutes
docker compose logs -f api # wait for "Application started"
```
The "find a poem about..." feature has its own setup guide, separate from the main one above —
it needs a downloaded ONNX model and published embeddings data on top of the usual database setup:
**[SEMANTIC_SEARCH_SETUP.md](SEMANTIC_SEARCH_SETUP.md)**. Also has a real troubleshooting section
covering the actual problems hit building this feature, not a generic checklist — worth reading
before assuming something new is broken.
SQL Server needs about 2 GB of RAM. Use a plan with at least 4 GB in total.
## Changelog
### Load the data
The user-facing changelog shown in the site's footer ("تازه‌های نرم‌افزار") is generated and
maintained from git history. See **[docs/CHANGELOG_PROCESS.md](docs/CHANGELOG_PROCESS.md)** for
the JSON format and the process for adding new entries.
1. Open `https://SITE_DOMAIN/login` and sign in with `ADMIN_EMAIL` and the password **`Test!123`**. The first login creates the admin account with that fixed password (RSecurityBackend's default; upstream's guide is wrong about this). **Change it right away** in the user panel.
2. On the import page that opens (or **Admin → مالی و سایت → درون‌ریزی دادهٔ عمومی**), choose **Internet URL** and enter:
```
https://cdn.jsdelivr.net/gh/anas-rashid/diwan-data@main/
```
3. The import runs in the background (about 11k poems). Century groups are rebuilt automatically when it finishes.
Re-running the import adds new poems and leaves existing ones untouched, so it can be repeated after diwan-data's daily sync.
### Update
```sh
git pull && docker compose up -d --build
```
## Build locally (macOS/Linux)
```sh
cd RMuseum # its global.json pins SDK 10.0.302; newer SDKs fail on some upstream Razor views
dotnet build RMuseum.csproj -p:EnableWindowsTargeting=true
dotnet build ../GanjooRazor/GanjooRazor.csproj -p:EnableWindowsTargeting=true
```
Running it needs SQL Server, so use the Docker setup above. SQL Server's image is x86-64 only.
## License
GPL-3.0, same as upstream (see `LICENSE`). Data: see [diwan-data](https://github.com/anas-rashid/diwan-data).

View File

@ -2500,32 +2500,6 @@ namespace RMuseum.Controllers
}
}
/// <summary>
/// start exporting the Tajik (Cyrillic) overlay data to its own, separate public
/// git-tracked JSON data set — only poets/categories/poems with an actual Tajik
/// translation are included
/// </summary>
/// <returns></returns>
[HttpPost("tajik/publicdata/batchexport")]
[Authorize(Policy = RMuseumSecurableItem.GanjoorEntityShortName + ":" + RMuseumSecurableItem.ImportOperationShortName)]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public IActionResult StartBatchExportTajikPublicGitData()
{
try
{
var res = _ganjoorService.StartBatchExportTajikPublicGitData();
if (!string.IsNullOrEmpty(res.ExceptionString))
return BadRequest(res.ExceptionString);
return Ok();
}
catch (Exception exp)
{
return BadRequest(exp.ToString());
}
}
/// <summary>
/// (re)build local Ganjoor content from a public data export tree (local folder or HTTP) —
/// intended for local development use, not for production servers
@ -2904,25 +2878,6 @@ namespace RMuseum.Controllers
return Ok();
}
/// <summary>
/// build tj.ganjoor.net site map
/// </summary>
/// <returns></returns>
[HttpPost("sitemap/tajik")]
[Authorize(Policy = RMuseumSecurableItem.GanjoorEntityShortName + ":" + SecurableItem.ModifyOperationShortName)]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public IActionResult StartBuildingTajikSitemap()
{
var res = _ganjoorService.StartBuildingTajikSitemap();
if (!string.IsNullOrEmpty(res.ExceptionString))
return BadRequest(res.ExceptionString);
return Ok();
}
/// <summary>
/// regenerate stats page
@ -4637,161 +4592,6 @@ namespace RMuseum.Controllers
}
/*
/// <summary>
/// import tajik from sqlite
/// </summary>
/// <param name="poetId"></param>
/// <returns></returns>
[HttpPost]
[Route("tajik/sqlite/import/{poetId}")]
[Authorize(Policy = RMuseumSecurableItem.GanjoorEntityShortName + ":" + RMuseumSecurableItem.ImportOperationShortName)]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(bool))]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
public async Task<IActionResult> TajikImportFromSqlite(int poetId = 0)
{
IFormFile file = Request.Form.Files[0];
RServiceResult<bool> res =
await _ganjoorService.TajikImportFromSqlite(poetId, file);
if (res.Result)
return Ok();
return BadRequest(res.ExceptionString);
}*/
[HttpPut]
[Route("tajik/sqlite/onetime/import")]
[Authorize(Policy = RMuseumSecurableItem.GanjoorEntityShortName + ":" + RMuseumSecurableItem.ImportOperationShortName)]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(bool))]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
public IActionResult TajikOneTimeImportFromSqlite()
{
RServiceResult<bool> res =
_ganjoorService.TajikImportFromSqlite(0, "C:\\Tools\\import\\output.s3db");
if (res.Result)
return Ok();
return BadRequest(res.ExceptionString);
}
/// <summary>
/// re-runs the Tajik poet/category HTML generators for every already-imported poet and
/// category page, overwriting their stored TajikHtmlText. Needed one-time after changing
/// either generator function (e.g. adding poem excerpts to the table of contents), since
/// the normal SQLite import skips pages that already exist and never refreshes them.
/// Runs as a background job - check the Admin area's LongRunningJobs page for progress.
/// </summary>
/// <returns></returns>
[HttpPut]
[Route("tajik/regenerate-html")]
[Authorize(Policy = RMuseumSecurableItem.GanjoorEntityShortName + ":" + RMuseumSecurableItem.ImportOperationShortName)]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(bool))]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
public IActionResult RegenerateTajikCatAndPoetHtmlTextAsync()
{
RServiceResult<bool> res =
_ganjoorService.RegenerateTajikCatAndPoetHtmlTextAsync();
if (res.Result)
return Ok();
return BadRequest(res.ExceptionString);
}
/// <summary>
/// recovery job: regenerates TajikHtmlText for Tajik poems whose stored HTML currently
/// contains a link (the signature left by the CatPage id corruption bug) - use after
/// RegenerateTajikCatAndPoetHtmlText (or anything else) may have corrupted poem pages.
/// Check the Admin area's LongRunningJobs page for progress.
/// </summary>
/// <returns></returns>
[HttpPut]
[Route("tajik/restore-poem-html")]
[Authorize(Policy = RMuseumSecurableItem.GanjoorEntityShortName + ":" + RMuseumSecurableItem.ImportOperationShortName)]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(bool))]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
public IActionResult RestoreTajikPoemHtmlTextAsync()
{
RServiceResult<bool> res =
_ganjoorService.RestoreTajikPoemHtmlTextAsync();
if (res.Result)
return Ok();
return BadRequest(res.ExceptionString);
}
/// <summary>
/// tajik poets
/// </summary>
/// <returns></returns>
[HttpGet]
[Route("tajik/poets")]
[AllowAnonymous]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(GanjoorTajikPoet[]))]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
public async Task<IActionResult> GetTajikPoetsAsync()
{
var cacheKey = $"ganjoor/tajik/poets";
if (!_memoryCache.TryGetValue(cacheKey, out GanjoorTajikPoet[] poets))
{
RServiceResult<GanjoorTajikPoet[]> res =
await _ganjoorService.GetTajikPoetsAsync();
if (!string.IsNullOrEmpty(res.ExceptionString))
return BadRequest(res.ExceptionString);
poets = res.Result;
if (AggressiveCacheEnabled)
_memoryCache.Set(cacheKey, poets, TimeSpan.FromHours(1));
}
return Ok(poets);
}
/// <summary>
/// tajik page by url
/// </summary>
/// <param name="url"></param>
/// <param name="catPoems"></param>
/// <returns></returns>
[HttpGet]
[Route("tajik/page")]
[AllowAnonymous]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(GanjoorPageCompleteViewModel))]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> GetTajikPageByUrlAsync(string url, bool catPoems = false)
{
RServiceResult<GanjoorPageCompleteViewModel> res =
await _ganjoorService.GetTajikPageByUrlAsync(url, catPoems);
if (!string.IsNullOrEmpty(res.ExceptionString))
return BadRequest(res.ExceptionString);
if (res.Result == null)
return NotFound();
return Ok(res.Result);
}
/// <summary>
/// search tajik poems
/// </summary>
/// <param name="paging"></param>
/// <param name="term"></param>
/// <param name="poetId"></param>
/// <param name="catId"></param>
/// <returns></returns>
[HttpGet]
[Route("tajik/search")]
[AllowAnonymous]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(IEnumerable<GanjoorPoemCompleteViewModel>))]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
public async Task<IActionResult> SearchTajikAsync([FromQuery] PagingParameterModel paging, string term, int poetId = 0, int catId = 0)
{
var pagedResult = await _ganjoorService.SearchTajikAsync(paging, term, poetId == 0 ? (int?)null : poetId, catId == 0 ? (int?)null : catId);
if (!string.IsNullOrEmpty(pagedResult.ExceptionString))
return BadRequest(pagedResult.ExceptionString);
// Paging Header
HttpContext.Response.Headers.Append("paging-headers", JsonConvert.SerializeObject(pagedResult.Result.PagingMeta));
return Ok(pagedResult.Result.Items);
}
/// <summary>
/// fill couplet summaries using open ai

View File

@ -743,31 +743,6 @@ namespace RMuseum.DbContext
/// </summary>
public DbSet<CategoryWordCountSummary> CategoryWordCountSummaries { get; set; }
/// <summary>
/// tajik poets
/// </summary>
public DbSet<GanjoorTajikPoet> TajikPoets { get; set; }
/// <summary>
/// tajik cats
/// </summary>
public DbSet<GanjoorTajikCat> TajikCats { get; set; }
/// <summary>
/// tajik poems
/// </summary>
public DbSet<GanjoorTajikPoem> TajikPoems { get; set; }
/// <summary>
/// tajik verse
/// </summary>
public DbSet<GanjoorTajikVerse> TajikVerses { get; set; }
/// <summary>
/// tajik pages
/// </summary>
public DbSet<GanjoorTajikPage> TajikPages { get; set; }
/// <summary>
/// ganjoor cat corrections
/// </summary>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,125 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RMuseum.Migrations
{
/// <inheritdoc />
public partial class DiwanRemoveTajik : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TajikPages");
migrationBuilder.DropTable(
name: "TajikPoems");
migrationBuilder.DropTable(
name: "TajikVerses");
migrationBuilder.DropTable(
name: "TajikCats");
migrationBuilder.DropTable(
name: "TajikPoets");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TajikPages",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
TajikHtmlText = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TajikPages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TajikPoets",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
BirthYearInLHijri = table.Column<int>(type: "int", nullable: false),
TajikDescription = table.Column<string>(type: "nvarchar(max)", nullable: true),
TajikNickname = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TajikPoets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TajikVerses",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
PoemId = table.Column<int>(type: "int", nullable: false),
TajikText = table.Column<string>(type: "nvarchar(max)", nullable: true),
VOrder = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TajikVerses", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TajikCats",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
PoetId = table.Column<int>(type: "int", nullable: false),
TajikDescription = table.Column<string>(type: "nvarchar(max)", nullable: true),
TajikTitle = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TajikCats", x => x.Id);
table.ForeignKey(
name: "FK_TajikCats_TajikPoets_PoetId",
column: x => x.PoetId,
principalTable: "TajikPoets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TajikPoems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
CatId = table.Column<int>(type: "int", nullable: false),
FullTitle = table.Column<string>(type: "nvarchar(max)", nullable: true),
FullUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
TajikPlainText = table.Column<string>(type: "nvarchar(max)", nullable: true),
TajikTitle = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TajikPoems", x => x.Id);
table.ForeignKey(
name: "FK_TajikPoems_TajikCats_CatId",
column: x => x.CatId,
principalTable: "TajikCats",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_TajikCats_PoetId",
table: "TajikCats",
column: "PoetId");
migrationBuilder.CreateIndex(
name: "IX_TajikPoems_CatId",
table: "TajikPoems",
column: "CatId");
}
}
}

View File

@ -2497,105 +2497,6 @@ namespace RMuseum.Migrations
b.ToTable("GanjoorSiteBanners");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorTajikCat", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<int>("PoetId")
.HasColumnType("int");
b.Property<string>("TajikDescription")
.HasColumnType("nvarchar(max)");
b.Property<string>("TajikTitle")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("PoetId");
b.ToTable("TajikCats");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorTajikPage", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("TajikHtmlText")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("TajikPages");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorTajikPoem", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<int>("CatId")
.HasColumnType("int");
b.Property<string>("FullTitle")
.HasColumnType("nvarchar(max)");
b.Property<string>("FullUrl")
.HasColumnType("nvarchar(max)");
b.Property<string>("TajikPlainText")
.HasColumnType("nvarchar(max)");
b.Property<string>("TajikTitle")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CatId");
b.ToTable("TajikPoems");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorTajikPoet", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<int>("BirthYearInLHijri")
.HasColumnType("int");
b.Property<string>("TajikDescription")
.HasColumnType("nvarchar(max)");
b.Property<string>("TajikNickname")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("TajikPoets");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorTajikVerse", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<int>("PoemId")
.HasColumnType("int");
b.Property<string>("TajikText")
.HasColumnType("nvarchar(max)");
b.Property<int>("VOrder")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("TajikVerses");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorUserBookmark", b =>
{
b.Property<Guid>("Id")
@ -5815,28 +5716,6 @@ namespace RMuseum.Migrations
b.Navigation("RImage");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorTajikCat", b =>
{
b.HasOne("RMuseum.Models.Ganjoor.GanjoorTajikPoet", "Poet")
.WithMany()
.HasForeignKey("PoetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Poet");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorTajikPoem", b =>
{
b.HasOne("RMuseum.Models.Ganjoor.GanjoorTajikCat", "Cat")
.WithMany()
.HasForeignKey("CatId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Cat");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorUserBookmark", b =>
{
b.HasOne("RMuseum.Models.Ganjoor.GanjoorPoem", "Poem")

View File

@ -1,36 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
namespace RMuseum.Models.Ganjoor
{
public class GanjoorTajikCat
{
/// <summary>
/// id
/// </summary>
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
public int Id { get; set; }
/// <summary>
/// tajik title
/// </summary>
public string TajikTitle { get; set; }
/// <summary>
/// additional description or note in Tajik
/// </summary>
public string TajikDescription { get; set; }
/// <summary>
/// poet_id
/// </summary>
public int PoetId { get; set; }
/// <summary>
/// poet
/// </summary>
public GanjoorTajikPoet Poet { get; set; }
}
}

View File

@ -1,20 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
namespace RMuseum.Models.Ganjoor
{
public class GanjoorTajikPage
{
/// <summary>
/// id
/// </summary>
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
public int Id { get; set; }
/// <summary>
/// verses text as html (ganjoor.net format)
/// </summary>
public string TajikHtmlText { get; set; }
}
}

View File

@ -1,45 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
namespace RMuseum.Models.Ganjoor
{
public class GanjoorTajikPoem
{
/// <summary>
/// id
/// </summary>
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
public int Id { get; set; }
/// <summary>
/// cat id
/// </summary>
public int CatId { get; set; }
/// <summary>
/// title
/// </summary>
public string TajikTitle { get; set; }
/// <summary>
/// verses text
/// </summary>
public string TajikPlainText { get; set; }
/// <summary>
/// sample: /hafez/ghazal/sh1
/// </summary>
public string FullUrl { get; set; }
/// <summary>
/// cat + parent cats title + title
/// </summary>
public string FullTitle { get; set; }
/// <summary>
/// cat
/// </summary>
public GanjoorTajikCat Cat { get; set; }
}
}

View File

@ -1,29 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
namespace RMuseum.Models.Ganjoor
{
public class GanjoorTajikPoet
{
/// <summary>
/// id
/// </summary>
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
public int Id { get; set; }
/// <summary>
/// tajik nick name
/// </summary>
public string TajikNickname { get; set; }
/// <summary>
/// additional description or note in Tajik
/// </summary>
public string TajikDescription { get; set; }
/// <summary>
/// birth year in lunar hijri
/// </summary>
public int BirthYearInLHijri { get; set; }
}
}

View File

@ -1,31 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
namespace RMuseum.Models.Ganjoor
{
public class GanjoorTajikVerse
{
/// <summary>
/// id
/// </summary>
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
public int Id { get; set; }
/// <summary>
/// poem_id
/// </summary>
public int PoemId { get; set; }
/// <summary>
/// vorder
/// </summary>
public int VOrder { get; set; }
/// <summary>
/// tajik text
/// </summary>
public string TajikText { get; set; }
}
}

View File

@ -1,99 +0,0 @@
using System.Collections.Generic;
namespace RMuseum.Models.Ganjoor.PublicExport
{
/// <summary>
/// Root manifest for the Tajik data export — a separate, sparser repository from the main
/// ganjoor-data one. Tajik translation only covers a subset of poets/categories/poems, so this
/// export only ever contains entries that actually have a Tajik counterpart in the database
/// (GanjoorTajikPoet/Cat/Poem/Verse) — it does not mirror the full corpus the way the main
/// export does. Ids and FullUrls match the main repo 1:1 (same underlying entity ids), so a
/// consumer resolves structure/metre/sections for a poem by fetching the same id/path from
/// ganjoor-data and merging verses by VOrder.
/// </summary>
public class TajikPublicExportManifestDto
{
public int SchemaVersion { get; set; } = 1;
public string GeneratedAtUtc { get; set; }
public int PoetsCount { get; set; }
public int PoemsCount { get; set; }
public int IdIndexShardSize { get; set; }
/// <summary>
/// where to find the structural data (metre, rhyme, sections, full verse text in Persian)
/// this repo's ids/paths correspond to
/// </summary>
public string MainDataRepo { get; set; } = "https://github.com/ganjoor/ganjoor-data";
public TajikPublicExportUrlTemplatesDto UrlTemplates { get; set; } = new TajikPublicExportUrlTemplatesDto();
public List<PublicExportManifestPoetEntryDto> Poets { get; set; } = new List<PublicExportManifestPoetEntryDto>();
}
public class TajikPublicExportUrlTemplatesDto
{
public string Poet { get; set; } = "poets/{poetSlug}/poet.json";
public string Category { get; set; } = "poets/{poetSlug}/{catPath}/_cat.json";
public string Poem { get; set; } = "poets/{poetSlug}/{catPath}/{poemSlug}.json";
public string PoetIdIndex { get; set; } = "index/poets-by-id.json";
public string CatIdIndexShard { get; set; } = "index/cats-by-id/{bucket}.json";
public string PoemIdIndexShard { get; set; } = "index/poems-by-id/{bucket}.json";
}
/// <summary>
/// poet.json — only written for a poet with an actual GanjoorTajikPoet record.
/// FullUrl matches the poet's path in the main ganjoor-data repo exactly.
/// </summary>
public class TajikPoetPublicDto
{
public int Id { get; set; }
public string TajikNickname { get; set; }
public string TajikDescription { get; set; }
public int BirthYearInLHijri { get; set; }
public string FullUrl { get; set; }
}
/// <summary>
/// _cat.json — only written for a category with an actual GanjoorTajikCat record.
/// ChildCats/Poems only list children that themselves have a Tajik translation — a consumer
/// never gets a link to a file that doesn't exist.
/// </summary>
public class TajikCatPublicDto
{
public int Id { get; set; }
public int PoetId { get; set; }
public int? ParentId { get; set; }
public string TajikTitle { get; set; }
public string TajikDescription { get; set; }
public string FullUrl { get; set; }
public List<CatChildRefDto> ChildCats { get; set; } = new List<CatChildRefDto>();
public List<PoemChildRefDto> Poems { get; set; } = new List<PoemChildRefDto>();
}
/// <summary>
/// {poem-slug}.json — only written for a poem with an actual GanjoorTajikPoem record.
/// Deliberately doesn't carry metre/rhyme/section data — GanjoorTajikPoem/Verse don't store
/// their own copy of that either; fetch it from the same id/path in the main ganjoor-data repo.
/// </summary>
public class TajikPoemPublicDto
{
public int Id { get; set; }
public int CatId { get; set; }
public string TajikTitle { get; set; }
public string FullTitle { get; set; }
public string FullUrl { get; set; }
public string TajikPlainText { get; set; }
public List<TajikVersePublicDto> Verses { get; set; } = new List<TajikVersePublicDto>();
}
/// <summary>
/// VOrder is the join key back to the corresponding verse in the main repo's poem file — no
/// position/section/couplet data is duplicated here, matching how GanjoorTajikVerse itself
/// stores nothing but the transliterated text and its order.
/// </summary>
public class TajikVersePublicDto
{
public int VOrder { get; set; }
public string TajikText { get; set; }
}
}

View File

@ -60,8 +60,7 @@
public string CoupletSummary { get; set; }
/// <summary>
/// original (Persian) text - only populated for Tajik responses that need to show
/// the Persian original alongside the Tajik transliteration. This is a pure view
/// original text (e.g. before a correction). This is a pure view
/// model (not an EF entity), so no [NotMapped] or migration concerns here.
/// </summary>
public string OriginalText { get; set; }

View File

@ -650,13 +650,6 @@ namespace RMuseum.Services
/// <returns></returns>
RServiceResult<bool> StartBatchExportPublicGitData();
/// <summary>
/// start exporting the Tajik (Cyrillic) overlay data to its own separate git-tracked JSON
/// data set — only entities that actually have a Tajik translation are included
/// </summary>
/// <returns></returns>
RServiceResult<bool> StartBatchExportTajikPublicGitData();
/// <summary>
/// (re)build local Ganjoor content (poets/categories/poems/verses/sections + their pages)
/// from a public data export tree, read locally or over HTTP. Safe to re-run — existing
@ -863,12 +856,6 @@ namespace RMuseum.Services
/// <returns></returns>
RServiceResult<bool> StartBuildingSitemap();
/// <summary>
/// build tj.ganjoor.net site map
/// </summary>
/// <returns></returns>
RServiceResult<bool> StartBuildingTajikSitemap();
/// <summary>
/// start updating stats page
/// </summary>
@ -1584,55 +1571,6 @@ namespace RMuseum.Services
/// <returns></returns>
Task<RServiceResult<CategoryWordCount>> GetCategoryWordCountByTermAsync(string term, int? catId, int? poetId);
/// <summary>
/// tajik import from sqlite
/// </summary>
/// <param name="poetId"></param>
/// <param name="filePath"></param>
/// <returns></returns>
RServiceResult<bool> TajikImportFromSqlite(int poetId, string filePath);
/// <summary>
/// tajik poets
/// </summary>
/// <returns></returns>
Task<RServiceResult<GanjoorTajikPoet[]>> GetTajikPoetsAsync();
/// <summary>
/// tajik page by url
/// </summary>
/// <param name="url"></param>
/// <param name="catPoems"></param>
/// <returns></returns>
Task<RServiceResult<GanjoorPageCompleteViewModel>> GetTajikPageByUrlAsync(string url, bool catPoems = false);
/// <summary>
/// search tajik poems
/// </summary>
/// <param name="paging"></param>
/// <param name="term"></param>
/// <param name="poetId"></param>
/// <param name="catId"></param>
/// <returns></returns>
Task<RServiceResult<(PaginationMetadata PagingMeta, GanjoorPoemCompleteViewModel[] Items)>> SearchTajikAsync(PagingParameterModel paging, string term, int? poetId, int? catId);
/// <summary>
/// re-runs the Tajik poet/category HTML generators for every already-imported poet and
/// category page, overwriting their stored TajikHtmlText - needed one-time after changing
/// either generator function, since the normal SQLite import skips pages that already exist.
/// Runs as a background job; returns immediately once the job is queued.
/// </summary>
/// <returns></returns>
RServiceResult<bool> RegenerateTajikCatAndPoetHtmlTextAsync();
/// <summary>
/// recovery job: regenerates TajikHtmlText for Tajik poems whose stored HTML currently
/// contains a link (the signature left by the CatPage id corruption bug - correct poem
/// HTML never contains one), overwriting just those with correct verse markup.
/// </summary>
/// <returns></returns>
RServiceResult<bool> RestoreTajikPoemHtmlTextAsync();

View File

@ -137,80 +137,5 @@ namespace RMuseum.Services.Implementation
}
}
/// <summary>
/// build tj.ganjoor.net site map
/// </summary>
/// <returns></returns>
public RServiceResult<bool> StartBuildingTajikSitemap()
{
try
{
_backgroundTaskQueue.QueueBackgroundWorkItem
(
async token =>
{
using (RMuseumDbContext context = new RMuseumDbContext(new DbContextOptions<RMuseumDbContext>())) //this is long running job, so _context might be already been freed/collected by GC
{
LongRunningJobProgressServiceEF jobProgressServiceEF = new LongRunningJobProgressServiceEF(context);
var job = (await jobProgressServiceEF.NewJob("BuildTajikSitemap", "Query data")).Result;
try
{
string xmlSitemap = Configuration.GetSection("Ganjoor")["TajikSitemapLocation"];
if (File.Exists(xmlSitemap))
File.Delete(xmlSitemap);
string dir = Path.GetDirectoryName(xmlSitemap);
await jobProgressServiceEF.UpdateJob(job.Id, 0, "", false);
List<string> sitemaps = new List<string>();
foreach (var poet in await context.TajikPoets.AsNoTracking().ToListAsync())
{
await jobProgressServiceEF.UpdateJob(job.Id, poet.Id, "", false);
string poetSitemap = Path.Combine(dir, $"{poet.Id}.xml");
WriteSitemap(poetSitemap,
await context.GanjoorPages.Where(p => p.PoetId == poet.Id && (p.GanjoorPageType == Models.Ganjoor.GanjoorPageType.PoetPage || p.GanjoorPageType == Models.Ganjoor.GanjoorPageType.CatPage || p.GanjoorPageType == Models.Ganjoor.GanjoorPageType.PoemPage)).OrderBy(p => p.Id).Select(p => p.FullUrl).ToListAsync(),
"https://tj.ganjoor.net"
);
sitemaps.Add(poetSitemap);
}
XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
XNamespace xsiNs = "http://www.w3.org/2001/XMLSchema-instance";
XDocument xDoc = new XDocument(
new XDeclaration("1.0", "UTF-8", "no"),
new XElement(ns + "sitemapindex",
new XAttribute(XNamespace.Xmlns + "xsi", xsiNs),
new XAttribute(xsiNs + "schemaLocation",
"http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"),
from sitemap in sitemaps
select new XElement(ns + "sitemap",
new XElement(ns + "loc", $"https://tj.ganjoor.net/{Path.GetFileName(sitemap)}"))
)
);
xDoc.Save(xmlSitemap);
await jobProgressServiceEF.UpdateJob(job.Id, 100, "", true);
}
catch (Exception exp)
{
await jobProgressServiceEF.UpdateJob(job.Id, 100, "", false, exp.ToString());
}
}
});
return new RServiceResult<bool>(true);
}
catch (Exception exp)
{
return new RServiceResult<bool>(false, exp.ToString());
}
}
}
}

View File

@ -1,640 +0,0 @@
using Microsoft.EntityFrameworkCore;
using System;
using RMuseum.Models.Ganjoor;
using System.Threading.Tasks;
using RSecurityBackend.Models.Generic;
using RMuseum.Models.Ganjoor.ViewModels;
using System.Linq;
using RSecurityBackend.Services.Implementation;
using System.Collections.Generic;
using RMuseum.DbContext;
namespace RMuseum.Services.Implementation
{
/// <summary>
/// IGanjoorService implementation
/// </summary>
public partial class GanjoorService : IGanjoorService
{
/// <summary>
/// tajik poets
/// </summary>
/// <returns></returns>
public async Task<RServiceResult<GanjoorTajikPoet[]>> GetTajikPoetsAsync()
{
try
{
return new RServiceResult<GanjoorTajikPoet[]>(await _context.TajikPoets.AsNoTracking().ToArrayAsync());
}
catch (Exception exp)
{
return new RServiceResult<GanjoorTajikPoet[]>(null, exp.ToString());
}
}
/// <summary>
/// tajik page by url
/// </summary>
/// <param name="url"></param>
/// <param name="catPoems"></param>
/// <returns></returns>
public async Task<RServiceResult<GanjoorPageCompleteViewModel>> GetTajikPageByUrlAsync(string url, bool catPoems = false)
{
try
{
var res = await GetPageByUrl(url, catPoems);
if (!string.IsNullOrEmpty(res.ExceptionString))
return res;
GanjoorPageCompleteViewModel page = res.Result;
int poetId = page.Poem != null ? page.Poem.Category.Poet.Id : page.PoetOrCat.Poet.Id;
var tajikPage = await _context.TajikPages.AsNoTracking().Where(p => p.Id == page.Id).SingleOrDefaultAsync();
if (tajikPage == null)
return new RServiceResult<GanjoorPageCompleteViewModel>(null, "Ин сафҳа ҳануз ба тоҷикии баргардонида нашуда.");
page.HtmlText = tajikPage.TajikHtmlText;
var tajikPoet = await _context.TajikPoets.AsNoTracking().Where(p => p.Id == poetId).SingleOrDefaultAsync();
if (tajikPoet == null)
return new RServiceResult<GanjoorPageCompleteViewModel>(null, "Ин суханвар ҳануз ба тоҷикӣ дар дастрас нест.");
if(page.PoetOrCat != null && page.PoetOrCat.Poet != null)
{
page.PoetOrCat.Poet.Nickname = tajikPoet.TajikNickname;
page.PoetOrCat.Poet.Description = tajikPoet.TajikDescription;
}
if(page.Poem != null && page.Poem.Category != null && page.Poem.Category.Poet != null)
{
page.Poem.Category.Poet.Nickname = tajikPoet.TajikNickname;
page.Poem.Category.Poet.Description = tajikPoet.TajikDescription;
}
int catId = page.Poem != null ? page.Poem.Category.Cat.Id : page.PoetOrCat.Cat.Id;
var tajikCat = await _context.TajikCats.AsNoTracking().Where(c => c.Id == catId).SingleOrDefaultAsync();
if (tajikCat == null)
return new RServiceResult<GanjoorPageCompleteViewModel>(null, "Ин бахш ҳануз ба тоҷикӣ баргардонида нашудааст.");
if (page.Poem != null && page.Poem.Category != null && page.Poem.Category.Cat != null)
{
page.Poem.Category.Cat.Title = tajikCat.TajikTitle;
page.Poem.Category.Cat.Description = tajikCat.TajikDescription;
foreach (var parent in page.Poem.Category.Cat.Ancestors)
{
// ancestors/children/siblings are supplementary navigation, not the page's
// own content - a missing translation here shouldn't take down the whole
// page, so fall back to transliteration the same way the poem next/prev
// links already do (see LanguageUtils.TransliteratePersianToTajik)
var tajikParent = await _context.TajikCats.AsNoTracking().Where(c => c.Id == parent.Id).SingleOrDefaultAsync();
if (tajikParent != null)
{
parent.Title = tajikParent.TajikTitle;
parent.Description = tajikParent.TajikDescription;
}
else
{
parent.Title = LanguageUtils.TransliteratePersianToTajik(parent.Title);
parent.Description = LanguageUtils.TransliteratePersianToTajik(parent.Description);
}
}
foreach (var child in page.Poem.Category.Cat.Children)
{
var tajikChild = await _context.TajikCats.AsNoTracking().Where(c => c.Id == child.Id).SingleOrDefaultAsync();
if (tajikChild != null)
{
child.Title = tajikChild.TajikTitle;
child.Description = tajikChild.TajikDescription;
}
else
{
child.Title = LanguageUtils.TransliteratePersianToTajik(child.Title);
child.Description = LanguageUtils.TransliteratePersianToTajik(child.Description);
}
}
}
if (page.PoetOrCat != null && page.PoetOrCat.Cat != null)
{
page.PoetOrCat.Cat.Title = tajikCat.TajikTitle;
page.PoetOrCat.Cat.Description = tajikCat.TajikDescription;
foreach (var parent in page.PoetOrCat.Cat.Ancestors)
{
var tajikParent = await _context.TajikCats.AsNoTracking().Where(c => c.Id == parent.Id).SingleOrDefaultAsync();
if (tajikParent != null)
{
parent.Title = tajikParent.TajikTitle;
parent.Description = tajikParent.TajikDescription;
}
else
{
parent.Title = LanguageUtils.TransliteratePersianToTajik(parent.Title);
parent.Description = LanguageUtils.TransliteratePersianToTajik(parent.Description);
}
}
foreach (var child in page.PoetOrCat.Cat.Children)
{
var tajikChild = await _context.TajikCats.AsNoTracking().Where(c => c.Id == child.Id).SingleOrDefaultAsync();
if (tajikChild != null)
{
child.Title = tajikChild.TajikTitle;
child.Description = tajikChild.TajikDescription;
}
else
{
child.Title = LanguageUtils.TransliteratePersianToTajik(child.Title);
child.Description = LanguageUtils.TransliteratePersianToTajik(child.Description);
}
}
if (page.PoetOrCat.Cat.Next != null)
{
var nextCat = await _context.TajikCats.AsNoTracking().Where(c => c.Id == page.PoetOrCat.Cat.Next.Id).SingleOrDefaultAsync();
page.PoetOrCat.Cat.Next.Title = nextCat != null ? nextCat.TajikTitle : LanguageUtils.TransliteratePersianToTajik(page.PoetOrCat.Cat.Next.Title);
}
if (page.PoetOrCat.Cat.Previous != null)
{
var preCat = await _context.TajikCats.AsNoTracking().Where(c => c.Id == page.PoetOrCat.Cat.Previous.Id).SingleOrDefaultAsync();
page.PoetOrCat.Cat.Previous.Title = preCat != null ? preCat.TajikTitle : LanguageUtils.TransliteratePersianToTajik(page.PoetOrCat.Cat.Previous.Title);
}
if (page.PoetOrCat.Cat.Poems != null)
{
var tajikPoems = await _context.TajikPoems.AsNoTracking().Where(p => p.CatId == catId).ToListAsync();
foreach (var poem in page.PoetOrCat.Cat.Poems)
{
var tajikPoem = tajikPoems.Where(p => p.Id == poem.Id).SingleOrDefault();
if (tajikPoem != null)
{
poem.Title = tajikPoem.TajikTitle;
}
}
}
}
if (page.Poem != null)
{
var tajikPoem = await _context.TajikPoems.AsNoTracking().Where(p => p.Id == page.Poem.Id).SingleAsync();
page.Poem.Title = tajikPoem.TajikTitle;
page.Poem.PlainText = tajikPoem.TajikPlainText;
var tajikVerses = await _context.TajikVerses.AsNoTracking().Where(v => v.PoemId == page.Poem.Id).OrderBy(v => v.VOrder).ToListAsync();
foreach( var verse in page.Poem.Verses)
{
verse.OriginalText = verse.Text;
verse.Text = tajikVerses.Where(v => v.VOrder == verse.VOrder).Single().TajikText;
}
// recompute fresh on every request (verses are already in memory) rather than
// relying on the precomputed TajikHtmlText blob - keeps this in sync with the
// verse data automatically, no regeneration job needed if it ever changes again
page.HtmlText = PrepareBilingualHtmlText(page.Poem.Verses);
if (page.Poem.Recitations != null)
{
// no TajikRecitations table exists - this audio metadata has no real Tajik
// translation source anywhere, so transliteration is the only option
foreach (var recitation in page.Poem.Recitations)
{
recitation.AudioTitle = LanguageUtils.TransliteratePersianToTajik(recitation.AudioTitle);
recitation.AudioArtist = LanguageUtils.TransliteratePersianToTajik(recitation.AudioArtist);
}
}
if(page.Poem.Next != null)
{
var nextPoem = await _context.TajikPoems.AsNoTracking().Where(p => p.Id == page.Poem.Next.Id).SingleOrDefaultAsync();
if(nextPoem != null)
{
// real Tajik translation exists for the neighbouring poem - use it
page.Poem.Next.Title = nextPoem.TajikTitle;
page.Poem.Next.Excerpt = ExtractExcerpt(nextPoem.TajikPlainText);
}
else
{
// no Tajik translation for this poem yet - fall back to a simple
// best-effort letter transliteration rather than showing raw
// Perso-Arabic script (see LanguageUtils.TransliteratePersianToTajik)
page.Poem.Next.Title = LanguageUtils.TransliteratePersianToTajik(page.Poem.Next.Title);
page.Poem.Next.Excerpt = LanguageUtils.TransliteratePersianToTajik(page.Poem.Next.Excerpt);
}
}
if (page.Poem.Previous != null)
{
var prePoem = await _context.TajikPoems.AsNoTracking().Where(p => p.Id == page.Poem.Previous.Id).SingleOrDefaultAsync();
if (prePoem != null)
{
page.Poem.Previous.Title = prePoem.TajikTitle;
page.Poem.Previous.Excerpt = ExtractExcerpt(prePoem.TajikPlainText);
}
else
{
page.Poem.Previous.Title = LanguageUtils.TransliteratePersianToTajik(page.Poem.Previous.Title);
page.Poem.Previous.Excerpt = LanguageUtils.TransliteratePersianToTajik(page.Poem.Previous.Excerpt);
}
}
}
return new RServiceResult<GanjoorPageCompleteViewModel>(page);
}
catch (Exception exp)
{
return new RServiceResult<GanjoorPageCompleteViewModel>(null, exp.ToString());
}
}
/// <summary>
/// short plain-text excerpt (first non-empty line) for a Tajik poem's next/previous nav link
/// </summary>
private static string ExtractExcerpt(string plainText)
{
if (string.IsNullOrWhiteSpace(plainText)) return "";
var firstLine = plainText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "";
return firstLine.Length > 80 ? firstLine.Substring(0, 80) + "…" : firstLine;
}
/// <summary>
/// Tajik counterpart of PrepareHtmlText (GanjoorService-SQLiteImport.cs) - same
/// VersePosition-based structure/CSS classes, but each verse's Persian original
/// (GanjoorVerse.OriginalText, set just before this is called) is rendered as an
/// extra &lt;p class="tg-fa" dir="rtl"&gt; line directly under its Tajik line, for
/// an interlinear/bilingual display. Computed fresh per-request rather than stored,
/// since it's cheap given the verses are already loaded for the response.
/// </summary>
private static string PrepareBilingualHtmlText(GanjoorVerseViewModel[] verses)
{
string FaLine(string text) => string.IsNullOrEmpty(text) ? "" : $"<p class=\"tg-fa\" dir=\"rtl\">{text}</p>";
string htmlText = "";
int coupletIndex = 0;
for (int vIndex = 0; vIndex < verses.Length; vIndex++)
{
GanjoorVerseViewModel v = verses[vIndex];
if (v.VersePosition == VersePosition.CenteredVerse1)
{
coupletIndex++;
if (((vIndex + 1) < verses.Length) && (verses[vIndex + 1].VersePosition == VersePosition.CenteredVerse2))
{
htmlText += $"<div class=\"b2\" id=\"bn{coupletIndex}\"><p>{v.Text}</p>{FaLine(v.OriginalText)}{Environment.NewLine}";
}
else
{
htmlText += $"<div class=\"b2\" id=\"bn{coupletIndex}\"><p>{v.Text}</p>{FaLine(v.OriginalText)}</div>{Environment.NewLine}";
}
}
else
if (v.VersePosition == VersePosition.CenteredVerse2)
{
htmlText += $"<p>{v.Text}</p>{FaLine(v.OriginalText)}</div>{Environment.NewLine}";
}
else
if (v.VersePosition == VersePosition.Right)
{
coupletIndex++;
htmlText += $"<div class=\"b\" id=\"bn{coupletIndex}\"><div class=\"m1\"><p>{v.Text}</p>{FaLine(v.OriginalText)}</div>{Environment.NewLine}";
}
else
if (v.VersePosition == VersePosition.Left)
{
htmlText += $"<div class=\"m2\"><p>{v.Text}</p>{FaLine(v.OriginalText)}</div></div>{Environment.NewLine}";
}
else
if (v.VersePosition == VersePosition.Comment)
{
htmlText += $"<div class=\"c\"><p>{v.Text}</p>{FaLine(v.OriginalText)}</div>{Environment.NewLine}";
}
else
if (v.VersePosition == VersePosition.Paragraph || v.VersePosition == VersePosition.Single)
{
coupletIndex++;
string[] lines = v.Text.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
string[] faLines = string.IsNullOrEmpty(v.OriginalText) ? new string[0] : v.OriginalText.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
string cssClass = v.VersePosition == VersePosition.Paragraph ? "n" : "l";
if (lines.Length != 0)
{
if (v.Text.Length / lines.Length < 150)
{
htmlText += $"<div class=\"{cssClass}\" id=\"bn{coupletIndex}\"><p>{v.Text.Replace("\r\n", " ")}</p>{FaLine(v.OriginalText?.Replace("\r\n", " "))}</div>{Environment.NewLine}";
}
else
{
// Persian original might not split into the exact same number of
// lines as its Tajik translation - matched by index on a best-effort
// basis; this branch only fires for long paragraph/note text, not the
// actual poem verses (Right/Left/CenteredVerse), so a rare misalignment
// here is a minor cosmetic issue, not a poem-text-accuracy one
for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++)
{
string faOne = lineIndex < faLines.Length ? faLines[lineIndex] : "";
htmlText += $"<div class=\"{cssClass}\" id=\"bn{coupletIndex}\"><p>{lines[lineIndex]}</p>{FaLine(faOne)}</div>{Environment.NewLine}";
}
}
}
else
{
if (string.IsNullOrEmpty(v.Text))
{
htmlText += $"<div class=\"{cssClass}\" id=\"bn{coupletIndex}\"><p>&nbsp;</p></div>{Environment.NewLine}";//empty line!
}
else
{
htmlText += $"<div class=\"{cssClass}\" id=\"bn{coupletIndex}\"><p>{v.Text}</p>{FaLine(v.OriginalText)}</div>{Environment.NewLine}";//not brave enough to ignore it!
}
}
}
}
return htmlText.Trim();
}
/// <summary>
/// re-runs the Tajik poet/category HTML generators for every already-imported poet and
/// category page, overwriting their stored TajikHtmlText. Needed one-time after changing
/// either generator function (e.g. adding poem excerpts to the table of contents), since
/// the normal SQLite import skips pages that already exist and never refreshes them.
/// Runs as a background job (same pattern as TajikImportFromSqlite) since the number of
/// poets/categories/poems involved can be large enough to exceed a normal request timeout;
/// progress can be tracked the same way as other long running jobs (e.g. the Admin area's
/// LongRunningJobs page).
/// </summary>
public RServiceResult<bool> RegenerateTajikCatAndPoetHtmlTextAsync()
{
try
{
_backgroundTaskQueue.QueueBackgroundWorkItem
(
async token =>
{
using (RMuseumDbContext context = new RMuseumDbContext(new DbContextOptions<RMuseumDbContext>())) //this is long running job, so _context might be already been freed/collected by GC
{
LongRunningJobProgressServiceEF jobProgressServiceEF = new LongRunningJobProgressServiceEF(context);
var job = (await jobProgressServiceEF.NewJob("RegenerateTajikCatAndPoetHtmlText", "Query data")).Result;
try
{
var tajikPoets = await context.TajikPoets.AsNoTracking().ToListAsync();
int done = 0;
foreach (var tajikPoet in tajikPoets)
{
await jobProgressServiceEF.UpdateJob(job.Id, done, $"poet: {tajikPoet.Id}");
var poetPage = await context.GanjoorPages.AsNoTracking().Where(p => p.GanjoorPageType == GanjoorPageType.PoetPage && p.PoetId == tajikPoet.Id).SingleOrDefaultAsync();
if (poetPage != null)
{
var tajikPage = await context.TajikPages.Where(p => p.Id == poetPage.Id).SingleOrDefaultAsync();
if (tajikPage != null)
{
tajikPage.TajikHtmlText = await PrepareTajikPoetHtmlTextAsync(context, tajikPoet);
await context.SaveChangesAsync();
}
}
done++;
}
var tajikCats = await context.TajikCats.AsNoTracking().ToListAsync();
done = 0;
foreach (var tajikCat in tajikCats)
{
await jobProgressServiceEF.UpdateJob(job.Id, done, $"cat: {tajikCat.Id}");
// tajikCat.Id is the CATEGORY's own id (GanjoorCategories.Id), which is a
// completely different id space from GanjoorPages.Id (catPageId is generated
// independently at import time - see GanjoorService-SQLiteImport.cs). Must
// resolve the actual CatPage's GanjoorPages.Id first, exactly like the poet
// loop above already does - using tajikCat.Id directly here previously
// caused TajikPages rows to be looked up (and overwritten) by the wrong id,
// corrupting whichever unrelated page (often a poem) happened to have that id.
var catPage = await context.GanjoorPages.AsNoTracking().Where(p => p.GanjoorPageType == GanjoorPageType.CatPage && p.CatId == tajikCat.Id).SingleOrDefaultAsync();
if (catPage != null)
{
var tajikPage = await context.TajikPages.Where(p => p.Id == catPage.Id).SingleOrDefaultAsync();
if (tajikPage != null)
{
tajikPage.TajikHtmlText = await PrepareTajikCatHtmlTextAsync(context, tajikCat);
await context.SaveChangesAsync();
}
}
done++;
}
await jobProgressServiceEF.UpdateJob(job.Id, 100, "", true);
}
catch (Exception exp)
{
await jobProgressServiceEF.UpdateJob(job.Id, 100, "", false, exp.ToString());
}
}
}
);
}
catch (Exception exp)
{
return new RServiceResult<bool>(false, exp.ToString());
}
return new RServiceResult<bool>(true);
}
/// <summary>
/// recovery job: regenerates TajikHtmlText for every Tajik poem from its TajikVerses,
/// overwriting whatever is currently stored. GanjoorTajikVerse rows carry no VersePosition
/// of their own, so it's recovered by joining back to the original GanjoorVerses row with
/// the same Id (the SQLite import sets GanjoorTajikVerse.Id = the original verse's Id).
/// Only processes poems whose stored TajikHtmlText currently contains a link - correct poem
/// HTML (from PrepareHtmlText) is pure verse markup (.b/.m1/.m2/.n/.l) and never contains an
/// &lt;a href=...&gt;, so a link inside it is the signature the CatPage id bug left behind
/// (it overwrote the affected poems with category-style link-list HTML). Scoping to that
/// signature instead of every poem cuts the run down to just the actually-corrupted ones.
/// </summary>
public RServiceResult<bool> RestoreTajikPoemHtmlTextAsync()
{
try
{
_backgroundTaskQueue.QueueBackgroundWorkItem
(
async token =>
{
using (RMuseumDbContext context = new RMuseumDbContext(new DbContextOptions<RMuseumDbContext>()))
{
LongRunningJobProgressServiceEF jobProgressServiceEF = new LongRunningJobProgressServiceEF(context);
var job = (await jobProgressServiceEF.NewJob("RestoreTajikPoemHtmlText", "Query data")).Result;
try
{
var corruptedPoemIds = await context.TajikPages
.Where(tp => tp.TajikHtmlText.Contains("<a href=") && context.TajikPoems.Any(pm => pm.Id == tp.Id))
.Select(tp => tp.Id)
.ToListAsync();
int done = 0;
foreach (var poemId in corruptedPoemIds)
{
await jobProgressServiceEF.UpdateJob(job.Id, done, $"poem: {poemId} ({done}/{corruptedPoemIds.Count})");
var tajikVerses = await context.TajikVerses.AsNoTracking().Where(v => v.PoemId == poemId).ToListAsync();
if (tajikVerses.Count > 0)
{
var originalVerses = await context.GanjoorVerses.AsNoTracking().Where(v => v.PoemId == poemId).ToListAsync();
var verses = tajikVerses
.Select(tv =>
{
var original = originalVerses.SingleOrDefault(v => v.Id == tv.Id);
return new GanjoorVerse()
{
VOrder = tv.VOrder,
VersePosition = original != null ? original.VersePosition : VersePosition.Paragraph,
Text = tv.TajikText,
};
})
.OrderBy(v => v.VOrder)
.ToList();
// TajikPages.Id == the poem's own Id for poem pages (see
// GanjoorService-TajikSQLiteImport.cs) - no GanjoorPages lookup needed here
var tajikPage = await context.TajikPages.Where(p => p.Id == poemId).SingleOrDefaultAsync();
if (tajikPage != null)
{
tajikPage.TajikHtmlText = PrepareHtmlText(verses);
await context.SaveChangesAsync();
}
}
done++;
}
await jobProgressServiceEF.UpdateJob(job.Id, 100, "", true);
}
catch (Exception exp)
{
await jobProgressServiceEF.UpdateJob(job.Id, 100, "", false, exp.ToString());
}
}
}
);
}
catch (Exception exp)
{
return new RServiceResult<bool>(false, exp.ToString());
}
return new RServiceResult<bool>(true);
}
/// <summary>
/// Search
/// You need to run this scripts manually on the database before using this method:
///
/// CREATE FULLTEXT CATALOG [TajikPoemPlainTextCatalog] WITH ACCENT_SENSITIVITY = OFF AS DEFAULT
///
/// CREATE FULLTEXT INDEX ON [dbo].[TajikPoems](
/// [TajikPlainText] LANGUAGE 'English')
/// KEY INDEX [PK_TajikPoems] ON ([TajikPoemPlainTextCatalog], FILEGROUP [PRIMARY])
/// WITH (CHANGE_TRACKING = AUTO, STOPLIST = SYSTEM)
/// </summary>
/// <param name="paging"></param>
/// <param name="term"></param>
/// <param name="poetId"></param>
/// <param name="catId"></param>
/// <returns></returns>
public async Task<RServiceResult<(PaginationMetadata PagingMeta, GanjoorPoemCompleteViewModel[] Items)>> SearchTajikAsync(PagingParameterModel paging, string term, int? poetId, int? catId)
{
term = term.Trim();
if (string.IsNullOrEmpty(term))
{
return new RServiceResult<(PaginationMetadata PagingMeta, GanjoorPoemCompleteViewModel[] Items)>((null, null), "Лутфан иборатиро ворид кунед");
}
string searchConditions;
if (term.IndexOf('"') == 0 && term.LastIndexOf('"') == (term.Length - 1))
{
searchConditions = term.Replace("\"", "").Replace("'", "");
searchConditions = $"\"{searchConditions}\"";
}
else
{
string[] words = term.Replace("\"", "").Replace("'", "").Split(' ', StringSplitOptions.RemoveEmptyEntries);
searchConditions = "";
string emptyOrAnd = "";
foreach (string word in words)
{
searchConditions += $" {emptyOrAnd} \"*{word}*\" ";
emptyOrAnd = " AND ";
}
}
if (poetId == null)
{
catId = null;
}
if (poetId != null && catId == null)
{
var poetRes = await GetPoetById((int)poetId);
if (!string.IsNullOrEmpty(poetRes.ExceptionString))
return new RServiceResult<(PaginationMetadata PagingMeta, GanjoorPoemCompleteViewModel[] Items)>((null, null), poetRes.ExceptionString);
catId = poetRes.Result.Cat.Id;
}
List<int> catIdList = new List<int>();
if (catId != null)
{
catIdList.Add((int)catId);
await _populateCategoryChildren(_context, (int)catId, catIdList);
}
var source =
_context.TajikPoems
.Where(p =>
(catId == null || catIdList.Contains(p.CatId))
&&
EF.Functions.Contains(p.TajikPlainText, searchConditions)
)
.Include(p => p.Cat).ThenInclude(c => c.Poet)
.OrderBy(p => p.Cat.Poet.BirthYearInLHijri).ThenBy(p => p.Cat.Poet.TajikNickname).ThenBy(p => p.Id)
.Select
(
poem =>
new GanjoorPoemCompleteViewModel()
{
Id = poem.Id,
Title = poem.TajikTitle,
FullTitle = poem.FullTitle,
FullUrl = poem.FullUrl,
PlainText = poem.TajikPlainText,
Category = new GanjoorPoetCompleteViewModel()
{
Poet = new GanjoorPoetViewModel()
{
Id = poem.Cat.Poet.Id,
}
},
}
).AsNoTracking();
(PaginationMetadata PagingMeta, GanjoorPoemCompleteViewModel[] Items) paginatedResult =
await QueryablePaginator<GanjoorPoemCompleteViewModel>.Paginate(source, paging);
Dictionary<int, GanjoorPoetCompleteViewModel> cachedPoets = new Dictionary<int, GanjoorPoetCompleteViewModel>();
foreach (var item in paginatedResult.Items)
{
if (cachedPoets.TryGetValue(item.Category.Poet.Id, out GanjoorPoetCompleteViewModel poet))
{
item.Category = poet;
}
else
{
poet = (await GetPoetById(item.Category.Poet.Id)).Result;
var tajikPoet = await _context.TajikPoets.AsNoTracking().Where(p => p.Id == item.Category.Poet.Id).SingleAsync();
poet.Poet.Nickname = tajikPoet.TajikNickname;
cachedPoets.Add(item.Category.Poet.Id, poet);
item.Category = poet;
}
}
return new RServiceResult<(PaginationMetadata PagingMeta, GanjoorPoemCompleteViewModel[] Items)>(paginatedResult);
}
}
}

View File

@ -1,359 +0,0 @@
using Microsoft.EntityFrameworkCore;
using RMuseum.DbContext;
using RMuseum.Models.Ganjoor;
using RMuseum.Models.Ganjoor.PublicExport;
using RMuseum.Utils.PublicDataExport;
using RSecurityBackend.Models.Generic;
using RSecurityBackend.Services.Implementation;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace RMuseum.Services.Implementation
{
/// <summary>
/// IGanjoorService implementation
/// </summary>
public partial class GanjoorService : IGanjoorService
{
/// <summary>
/// Exports the Tajik (Cyrillic transliteration) overlay data — GanjoorTajikPoet/Cat/Poem/Verse
/// — to its own git-tracked JSON repository, separate from the main ganjoor-data export.
/// Kept separate deliberately: Tajik translation coverage is sparse (most poets/categories/
/// poems have no Tajik counterpart at all), the consumer audience is different (Cyrillic
/// Tajik apps, not general Ganjoor API consumers), and update cadence is independent of the
/// main Persian corpus. This export only ever writes entries that actually have a Tajik
/// database record; everything else is skipped. Ids and FullUrls match the main
/// ganjoor-data repo exactly, so a consumer resolves structure/metre/sections for a poem by
/// fetching the same id/path from there and merging verses by VOrder.
/// </summary>
public RServiceResult<bool> StartBatchExportTajikPublicGitData()
{
bool acquiredGuard = false;
try
{
// scans every DTO in RMuseum.Models.Ganjoor.PublicExport, which now includes the
// Tajik DTOs too — no separate call needed, they're covered automatically
PublicExportSafetyGuard.AssertSafe();
if (!TryStartExclusiveExportJob("TajikPublicDataExport"))
{
return new RServiceResult<bool>(false,
"A Tajik public data export is already running (check the Jobs page) — wait for it to finish before starting another.");
}
acquiredGuard = true;
_backgroundTaskQueue.QueueBackgroundWorkItem
(
async token =>
{
try
{
using (RMuseumDbContext context = new RMuseumDbContext(new DbContextOptions<RMuseumDbContext>()))
{
LongRunningJobProgressServiceEF jobProgressServiceEF = new LongRunningJobProgressServiceEF(context);
var job = (await jobProgressServiceEF.NewJob("TajikPublicDataExport", "Preparing working copy")).Result;
try
{
var options = ReadTajikPublicDataExportOptions();
var publisher = new GitRepoPublisher(options);
publisher.EnsureWorkingCopyUpToDate();
string repoRoot = options.LocalWorkingCopyPath;
var manifest = new TajikPublicExportManifestDto
{
GeneratedAtUtc = DateTime.UtcNow.ToString("O"),
IdIndexShardSize = IdIndexShardSize,
};
var poetIdIndex = new Dictionary<int, string>();
var catIdIndex = new Dictionary<int, string>();
var poemIdIndex = new Dictionary<int, string>();
var tajikPoets = await context.TajikPoets.AsNoTracking().OrderBy(p => p.Id).ToListAsync();
int poetIndex = 0;
foreach (var tajikPoet in tajikPoets)
{
poetIndex++;
await jobProgressServiceEF.UpdateJob(job.Id, (int)(100.0 * poetIndex / Math.Max(1, tajikPoets.Count)), $"Exporting {tajikPoet.TajikNickname}");
// the Tajik tables have no FullUrl of their own - poet/category/
// poem paths are read from the main tables, since ids match 1:1
var catPoet = await context.GanjoorCategories.AsNoTracking()
.Where(c => c.PoetId == tajikPoet.Id && c.ParentId == null)
.SingleOrDefaultAsync();
if (catPoet == null)
continue;
await ExportTajikPoetToJson(repoRoot, tajikPoet, catPoet);
poetIdIndex[tajikPoet.Id] = catPoet.FullUrl;
int poemCount = await ExportTajikPoetContent(context, repoRoot, tajikPoet.Id, catPoet, catIdIndex, poemIdIndex);
manifest.PoemsCount += poemCount;
manifest.Poets.Add(new PublicExportManifestPoetEntryDto
{
Id = tajikPoet.Id,
Nickname = tajikPoet.TajikNickname,
FullUrl = catPoet.FullUrl,
});
}
manifest.PoetsCount = manifest.Poets.Count;
await jobProgressServiceEF.UpdateJob(job.Id, 98, "Writing id indexes");
await IdIndexWriter.WriteFlatIndexAsync(repoRoot, "index/poets-by-id.json", poetIdIndex);
await IdIndexWriter.WriteShardedIndexAsync(repoRoot, "cats", catIdIndex, IdIndexShardSize);
await IdIndexWriter.WriteShardedIndexAsync(repoRoot, "poems", poemIdIndex, IdIndexShardSize);
await DeterministicJsonWriter.WriteIfChangedAsync(Path.Combine(repoRoot, "manifest.json"), manifest);
await TextFileWriter.WriteIfChangedAsync(Path.Combine(repoRoot, "API.md"), BuildTajikApiMarkdown(manifest));
await TextFileWriter.WriteIfChangedAsync(Path.Combine(repoRoot, "README.md"), BuildTajikReadmeMarkdown(manifest));
await jobProgressServiceEF.UpdateJob(job.Id, 99, "Committing and pushing");
int changed = publisher.CommitAndPush($"data: export {manifest.PoetsCount} poets / {manifest.PoemsCount} poems — {DateTime.UtcNow:yyyy-MM-dd}");
await jobProgressServiceEF.UpdateJob(job.Id, 100, changed == 0 ? "No changes" : $"{changed} files changed", true);
}
catch (Exception exp)
{
await jobProgressServiceEF.UpdateJob(job.Id, 100, "", false, exp.ToString());
}
}
}
finally
{
EndExclusiveExportJob("TajikPublicDataExport");
}
}
);
return new RServiceResult<bool>(true);
}
catch (Exception exp)
{
if (acquiredGuard)
{
EndExclusiveExportJob("TajikPublicDataExport"); // queueing itself threw after we'd already acquired the guard
}
return new RServiceResult<bool>(false, exp.ToString());
}
}
private GitRepoPublisherOptions ReadTajikPublicDataExportOptions()
{
var section = Configuration.GetSection("TajikPublicDataExport");
return new GitRepoPublisherOptions
{
LocalWorkingCopyPath = section["LocalWorkingCopyPath"],
RemoteUrl = section["RemoteUrl"],
Branch = section["Branch"] ?? "main",
CommitAuthorName = section["CommitAuthorName"] ?? "Ganjoor Tajik Export Bot",
CommitAuthorEmail = section["CommitAuthorEmail"] ?? "bot@ganjoor.net",
PushEnabled = bool.TryParse(section["PushEnabled"], out var push) && push,
GitUserName = section["GitUserName"],
GitToken = section["GitToken"],
GitExecutablePath = section["GitExecutablePath"],
CommandTimeoutMinutes = int.TryParse(section["CommandTimeoutMinutes"], out var timeout) ? timeout : 45,
};
}
private async Task ExportTajikPoetToJson(string repoRoot, GanjoorTajikPoet tajikPoet, GanjoorCat catPoet)
{
var dto = new TajikPoetPublicDto
{
Id = tajikPoet.Id,
TajikNickname = tajikPoet.TajikNickname,
TajikDescription = tajikPoet.TajikDescription,
BirthYearInLHijri = tajikPoet.BirthYearInLHijri,
FullUrl = catPoet.FullUrl,
};
string path = Path.Combine(repoRoot, "poets", TrimLeadingSlash(catPoet.FullUrl), "poet.json");
await DeterministicJsonWriter.WriteIfChangedAsync(path, dto);
}
/// <summary>
/// batch-loads everything Tajik for this poet (mirrors the same "load once per poet, not
/// once per poem" fix applied to the main export) before walking the tree
/// </summary>
private async Task<int> ExportTajikPoetContent(RMuseumDbContext context, string repoRoot, int poetId, GanjoorCat catPoet,
Dictionary<int, string> catIdIndex, Dictionary<int, string> poemIdIndex)
{
var tajikCatsById = (await context.TajikCats.AsNoTracking().Where(c => c.PoetId == poetId).ToListAsync())
.ToDictionary(c => c.Id);
if (tajikCatsById.Count == 0)
return 0; // nothing translated for this poet at all
var catIds = tajikCatsById.Keys.ToList();
var tajikPoemsById = (await context.TajikPoems.AsNoTracking().Where(p => catIds.Contains(p.CatId)).ToListAsync())
.ToDictionary(p => p.Id);
var poemIds = tajikPoemsById.Keys.ToList();
var tajikVersesByPoem = (await context.TajikVerses.AsNoTracking()
.Where(v => poemIds.Contains(v.PoemId))
.OrderBy(v => v.VOrder)
.ToListAsync())
.GroupBy(v => v.PoemId)
.ToDictionary(g => g.Key, g => g.ToList());
return await ExportTajikCatTreeToJson(context, repoRoot, catPoet, tajikCatsById, tajikPoemsById, tajikVersesByPoem, catIdIndex, poemIdIndex);
}
/// <summary>
/// walks the *main* category tree (for structure/ordering — Tajik tables have no tree
/// shape of their own) but only ever writes a file where a Tajik counterpart actually
/// exists; still recurses into every child regardless, since a deeper category can have a
/// translation even when its parent doesn't.
/// </summary>
private async Task<int> ExportTajikCatTreeToJson(RMuseumDbContext context, string repoRoot, GanjoorCat cat,
Dictionary<int, GanjoorTajikCat> tajikCatsById, Dictionary<int, GanjoorTajikPoem> tajikPoemsById,
Dictionary<int, List<GanjoorTajikVerse>> tajikVersesByPoem,
Dictionary<int, string> catIdIndex, Dictionary<int, string> poemIdIndex)
{
var childCats = await context.GanjoorCategories.AsNoTracking()
.Where(c => c.ParentId == cat.Id)
.OrderBy(c => c.Id)
.ToListAsync();
int poemCount = 0;
if (tajikCatsById.TryGetValue(cat.Id, out var tajikCat))
{
var poems = await context.GanjoorPoems.AsNoTracking()
.Where(p => p.CatId == cat.Id)
.OrderBy(p => p.Id)
.ToListAsync();
var translatedChildCats = childCats.Where(c => tajikCatsById.ContainsKey(c.Id)).ToList();
var translatedPoems = poems.Where(p => tajikPoemsById.ContainsKey(p.Id)).ToList();
var catDto = new TajikCatPublicDto
{
Id = cat.Id,
PoetId = cat.PoetId,
ParentId = cat.ParentId,
TajikTitle = tajikCat.TajikTitle,
TajikDescription = tajikCat.TajikDescription,
FullUrl = cat.FullUrl,
ChildCats = translatedChildCats.Select(c => new CatChildRefDto { Id = c.Id, Title = tajikCatsById[c.Id].TajikTitle, FullUrl = c.FullUrl }).ToList(),
Poems = translatedPoems.Select(p => new PoemChildRefDto { Id = p.Id, Title = tajikPoemsById[p.Id].TajikTitle, FullUrl = p.FullUrl }).ToList(),
};
string catDir = Path.Combine(repoRoot, "poets", TrimLeadingSlash(cat.FullUrl));
await DeterministicJsonWriter.WriteIfChangedAsync(Path.Combine(catDir, "_cat.json"), catDto);
catIdIndex[cat.Id] = cat.FullUrl;
foreach (var poem in translatedPoems)
{
var tajikPoem = tajikPoemsById[poem.Id];
tajikVersesByPoem.TryGetValue(poem.Id, out var verses);
await ExportTajikPoemToJson(repoRoot, poem, tajikPoem, verses ?? new List<GanjoorTajikVerse>());
poemIdIndex[poem.Id] = poem.FullUrl;
poemCount++;
}
}
foreach (var childCat in childCats)
{
poemCount += await ExportTajikCatTreeToJson(context, repoRoot, childCat, tajikCatsById, tajikPoemsById, tajikVersesByPoem, catIdIndex, poemIdIndex);
}
return poemCount;
}
private async Task ExportTajikPoemToJson(string repoRoot, GanjoorPoem poem, GanjoorTajikPoem tajikPoem, List<GanjoorTajikVerse> verses)
{
var dto = new TajikPoemPublicDto
{
Id = poem.Id,
CatId = poem.CatId,
TajikTitle = tajikPoem.TajikTitle,
FullTitle = tajikPoem.FullTitle,
FullUrl = poem.FullUrl,
TajikPlainText = tajikPoem.TajikPlainText,
Verses = verses.OrderBy(v => v.VOrder).Select(v => new TajikVersePublicDto { VOrder = v.VOrder, TajikText = v.TajikText }).ToList(),
};
string path = Path.Combine(repoRoot, "poets", TrimLeadingSlash(poem.FullUrl) + ".json");
await DeterministicJsonWriter.WriteIfChangedAsync(path, dto);
}
private static string BuildTajikReadmeMarkdown(TajikPublicExportManifestDto manifest)
{
return
$@"# ganjoor-tajik-data
**[▶ Live demo](https://ganjoor.github.io/tjmini/)** — Мини-Ганҷур, a minimal reading app built
on this data, running client-side in your browser with no server of its own.
Cyrillic Tajik transliteration/translation overlay for [Ganjoor](https://ganjoor.net)'s poetry
content. This is a **separate, sparser** data set from
[ganjoor-data](https://github.com/ganjoor/ganjoor-data) — Tajik coverage only exists for some
poets/categories/poems, not the full corpus, so this repo only ever contains entries that
actually have a Tajik translation.
Ids and paths in this repo match [ganjoor-data]({manifest.MainDataRepo}) exactly (same underlying
poet/category/poem). This repo carries only the Tajik title/text; for metre, rhyme, verse
structure, and sections, fetch the same id/path from `ganjoor-data` and merge verses by `vOrder`.
Currently tracks **{manifest.PoetsCount} poets** / **{manifest.PoemsCount} poems** with Tajik
content, generated {manifest.GeneratedAtUtc}.
## Where do I start?
- **[`manifest.json`](manifest.json)** — the list of poets that have any Tajik content, plus URL
templates for every other file kind here.
- **[`API.md`](API.md)** — how to fetch any of this over plain HTTPS, and how to cross-reference
`ganjoor-data` for structural data this repo doesn't duplicate.
";
}
private static string BuildTajikApiMarkdown(TajikPublicExportManifestDto manifest)
{
var t = manifest.UrlTemplates;
return
$@"# ganjoor-tajik-data — static API
Same static-API-via-jsDelivr approach as [ganjoor-data]({manifest.MainDataRepo}) — no server,
every ""endpoint"" below is a plain HTTPS file fetch:
https://cdn.jsdelivr.net/gh/ganjoor/ganjoor-tajik-data@main/
## Content
- `GET {t.Poet}` — Tajik nickname/description for a poet
- `GET {t.Category}` — Tajik title/description for a category, plus only the children that
themselves have Tajik content
- `GET {t.Poem}` — Tajik title and per-verse text (`vOrder` + `tajikText` only)
## This repo does not stand alone
A poem file here has no metre, rhyme, or verse-position/section data — those live in
[ganjoor-data]({manifest.MainDataRepo}) under the *same* id and path. To render a Tajik poem
fully: fetch this repo's poem file for the Tajik text, fetch `ganjoor-data`'s poem file (same
`{t.Poem}` path) for structure, and merge the two verse lists by `vOrder`.
## Resolving a numeric id
Same scheme as `ganjoor-data`: `{t.PoetIdIndex}` (flat), `{t.CatIdIndexShard}` /
`{t.PoemIdIndexShard}` (bucketed, `bucket = id / {manifest.IdIndexShardSize}`). A missing id here
just means that poet/category/poem has no Tajik content yet — check `ganjoor-data`'s own id index
to confirm the id is valid at all.
## Not included
No comments, bookmarks, reading history, edit/correction history, or any other data linked to a
user account — same allowlist-only approach as `ganjoor-data`.
";
}
}
}

View File

@ -1,238 +0,0 @@
using Microsoft.EntityFrameworkCore;
using System;
using RMuseum.Models.Ganjoor;
using RSecurityBackend.Models.Generic;
using System.Linq;
using RSecurityBackend.Services.Implementation;
using Microsoft.Data.Sqlite;
using RMuseum.DbContext;
using System.Data;
using System.IO;
using Dapper;
namespace RMuseum.Services.Implementation
{
/// <summary>
/// IGanjoorService implementation
/// </summary>
public partial class GanjoorService : IGanjoorService
{
public RServiceResult<bool> TajikImportFromSqlite(int poetId, string filePath)
{
try
{
_backgroundTaskQueue.QueueBackgroundWorkItem
(
async token =>
{
using (RMuseumDbContext context = new RMuseumDbContext(new DbContextOptions<RMuseumDbContext>())) //this is long running job, so _context might be already been freed/collected by GC
{
LongRunningJobProgressServiceEF jobProgressServiceEF = new LongRunningJobProgressServiceEF(context);
var job = (await jobProgressServiceEF.NewJob("ImportFromSqlite", "Query data")).Result;
try
{
SqliteConnectionStringBuilder connectionStringBuilder = new SqliteConnectionStringBuilder();
connectionStringBuilder.DataSource = filePath;
using (SqliteConnection sqliteConnection = new SqliteConnection(connectionStringBuilder.ToString()))
{
await sqliteConnection.OpenAsync();
IDbConnection sqlite = sqliteConnection;
var poets = (await sqlite.QueryAsync("SELECT * FROM poet ORDER BY id")).ToList();
if (poetId != 0 && poets.Count != 1)
{
await jobProgressServiceEF.UpdateJob(job.Id, 100, "", false, "poets count in sqlite db is not equal to 1");
}
foreach (var poet in poets)
{
int poetId = (int)poet.id;
await jobProgressServiceEF.UpdateJob(job.Id, poetId, $"poet: {poetId}");
if (await context.GanjoorPoets.AnyAsync(p => p.Id == poetId) == false) continue;
var cats = (await sqlite.QueryAsync($"SELECT * FROM cat WHERE poet_id = {poetId} ORDER BY id")).ToList();
if(cats.Count == 0) continue;
if (await context.TajikPoets.AnyAsync(p => p.Id == poetId) == false)
{
var tajikPoet = new GanjoorTajikPoet()
{
Id = poetId,
TajikNickname = poet.name,
TajikDescription = poet.description,
BirthYearInLHijri = (await context.GanjoorPoets.AsNoTracking().Where(p => p.Id == poetId).SingleAsync()).BirthYearInLHijri
};
context.Add(tajikPoet);
await context.SaveChangesAsync();
}
foreach (var cat in cats)
{
int catId = (int)cat.id;
await jobProgressServiceEF.UpdateJob(job.Id, poetId, $"poet: {poetId} - cat: {catId}");
if (await context.GanjoorCategories.AnyAsync(c => c.Id == catId) == false) continue;
if(await context.TajikCats.AnyAsync(c => c.Id == catId) == false)
{
var tajikCat = new GanjoorTajikCat()
{
Id = catId,
PoetId = poetId,
TajikTitle = cat.text
};
context.Add(tajikCat);
await context.SaveChangesAsync();
}
var poems = (await sqlite.QueryAsync($"SELECT * FROM poem WHERE cat_id = {catId} ORDER BY id")).ToList();
foreach (var poem in poems)
{
int poemId = (int)poem.id;
if (await context.GanjoorPoems.AnyAsync(p => p.Id == poemId) == false) continue;
if(await context.TajikPoems.AnyAsync(p => p.Id == poemId) == false)
{
var verses = (await sqlite.QueryAsync($"SELECT * FROM verse WHERE poem_id = {poemId} ORDER BY vorder")).ToList();
var ganjoorVerses = await context.GanjoorVerses.AsNoTracking().Where(v => v.PoemId == poemId).ToListAsync();
if (verses.Count != ganjoorVerses.Count) continue;
await jobProgressServiceEF.UpdateJob(job.Id, poetId, $"poet: {poetId} - cat: {catId} - poem: {poemId}");
int currentCatId = catId;
string fullTitle = poem.title;
while (currentCatId != 0)
{
var tajikCat = await context.TajikCats.AsNoTracking().Where(c => c.Id == currentCatId).SingleOrDefaultAsync();
if(tajikCat == null)
{
if(null == await context.TajikCats.AsNoTracking().Where(c => c.Id == catId).SingleOrDefaultAsync())
{
currentCatId = -1;
}
break;
}
var gCat = await context.GanjoorCategories.AsNoTracking().Where(c => c.Id == tajikCat.Id).SingleAsync();
fullTitle = tajikCat.TajikTitle + " - " + fullTitle;
currentCatId = gCat.ParentId ?? 0;
}
if (currentCatId == -1)
continue;
var ganjoorPoem = await context.GanjoorPoems.AsNoTracking().Where(p => p.Id == poemId).SingleAsync();
var tajikPoem = new GanjoorTajikPoem()
{
Id = poemId,
CatId = catId,
FullUrl = ganjoorPoem.FullUrl,
FullTitle = fullTitle,
TajikTitle = poem.title,
TajikPlainText = "",
};
context.Add(tajikPoem);
await context.SaveChangesAsync();
await jobProgressServiceEF.UpdateJob(job.Id, poetId, $"poet: {poetId} - cat: {catId} - poem: {poemId} - query verses");
string plaintText = "";
foreach (var verse in verses)
{
await jobProgressServiceEF.UpdateJob(job.Id, poetId, $"poet: {poetId} - cat: {catId} - poem: {poemId} - verse: {verse.vorder}");
var ganjoorVerse = ganjoorVerses.Where(v => v.VOrder == (int)verse.vorder).SingleOrDefault();
if (ganjoorVerse == null) continue;
var tajikVerse = new GanjoorTajikVerse()
{
Id = ganjoorVerse.Id,
PoemId = poemId,
TajikText = verse.text,
VOrder = (int)verse.vorder,
};
context.Add(tajikVerse);
plaintText += tajikVerse.TajikText;
plaintText += "\r\n";
}
await context.SaveChangesAsync();
tajikPoem.TajikPlainText = plaintText;
context.Update(tajikPoem);
await context.SaveChangesAsync();
await jobProgressServiceEF.UpdateJob(job.Id, poetId, $"poet: {poetId} - cat: {catId} - poem: {poemId} - page");
if (await context.TajikPages.AnyAsync(p => p.Id == poemId) == false)
{
GanjoorTajikPage page = new GanjoorTajikPage()
{
Id = poemId,
TajikHtmlText = PrepareHtmlText
(
verses.Select(s => new GanjoorVerse()
{
VOrder = (int)s.vorder,
Text = s.text,
VersePosition = (VersePosition) ((int)s.position)
}
).ToList()
),
};
context.Add(page);
await context.SaveChangesAsync();
}
}
}
}
foreach (var cat in cats)
{
int catId = (int)cat.id;
if (await context.GanjoorCategories.AnyAsync(c => c.Id == catId) == false) continue;
await jobProgressServiceEF.UpdateJob(job.Id, poetId, $"poet: {poetId} - cat: {catId} - page");
var catPage = await context.GanjoorPages.AsNoTracking().Where(p => p.GanjoorPageType == GanjoorPageType.CatPage && p.CatId == catId).SingleOrDefaultAsync();
if(catPage == null) continue;
if (await context.TajikPages.AnyAsync(p => p.Id == catPage.Id) == false)
{
var tajikCat = await context.TajikCats.AsNoTracking().Where(c => c.Id == catId).SingleAsync();
GanjoorTajikPage page = new GanjoorTajikPage()
{
Id = catPage.Id,
TajikHtmlText = await PrepareTajikCatHtmlTextAsync(context, tajikCat),
};
context.Add(page);
await context.SaveChangesAsync();
}
}
var poetPage = await context.GanjoorPages.AsNoTracking().Where(p => p.GanjoorPageType == GanjoorPageType.PoetPage && p.PoetId == poetId).SingleAsync();
if (await context.TajikPages.AnyAsync(p => p.Id == poetPage.Id) == false)
{
await jobProgressServiceEF.UpdateJob(job.Id, poetId, $"poet: {poetId} - page");
var tajikPoet = await context.TajikPoets.AsNoTracking().Where(p => p.Id == poetId).SingleAsync();
GanjoorTajikPage page = new GanjoorTajikPage()
{
Id = poetPage.Id,
TajikHtmlText = await PrepareTajikPoetHtmlTextAsync(context, tajikPoet),
};
context.Add(page);
await context.SaveChangesAsync();
}
}
}
await jobProgressServiceEF.UpdateJob(job.Id, 100, "", true);
}
catch (Exception exp)
{
await jobProgressServiceEF.UpdateJob(job.Id, 100, "", false, exp.ToString());
}
}
File.Delete(filePath);
}
);
}
catch (Exception exp)
{
return new RServiceResult<bool>(false, exp.ToString());
}
return new RServiceResult<bool>(true);
}
}
}

View File

@ -1,98 +0,0 @@
using Microsoft.EntityFrameworkCore;
using System;
using System.Data;
using System.Linq;
using RMuseum.DbContext;
using System.Collections.Generic;
using RSecurityBackend.Services.Implementation;
using RMuseum.Utils;
using RMuseum.Models.Ganjoor;
using System.Threading.Tasks;
namespace RMuseum.Services.Implementation
{
/// <summary>
/// IGanjoorService implementation
/// </summary>
public partial class GanjoorService : IGanjoorService
{
private async Task<string> PrepareTajikPoetHtmlTextAsync(RMuseumDbContext context, GanjoorTajikPoet poet)
{
string[] lines = poet.TajikDescription.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
string html = "";
foreach (var line in lines)
{
html += $"<p>{line}</p>";
}
var poetCat = await context.GanjoorCategories.AsNoTracking().Where(c => c.PoetId == poet.Id && c.ParentId == null).SingleAsync();
var subCats = await context.GanjoorCategories.AsNoTracking().Where(c => c.ParentId == poetCat.Id).OrderBy(p => p.Id).ToListAsync();
foreach (var subCat in subCats)
{
var tajikCat = await context.TajikCats.AsNoTracking().Where(t => t.Id == subCat.Id).SingleOrDefaultAsync();
if(tajikCat != null)
{
html += $"<p><a href=\"{subCat.FullUrl}\">{LanguageUtils.CleanTextForTransileration(tajikCat.TajikTitle)}</a></p>";
}
}
var catPoems = await context.GanjoorPoems.AsNoTracking().Where(p => p.CatId == poetCat.Id).OrderBy(p => p.Id).ToListAsync();
var tajikPoems = await context.TajikPoems.AsNoTracking().Where(p => p.CatId == poetCat.Id).OrderBy(p => p.Id).ToListAsync();
foreach (var catPoem in catPoems)
{
var tajikPoem = tajikPoems.Where(p => p.Id == catPoem.Id).SingleOrDefault();
if(tajikPoem != null)
{
string excerpt = ExtractExcerpt(tajikPoem.TajikPlainText);
string title = LanguageUtils.CleanTextForTransileration(tajikPoem.TajikTitle);
html += string.IsNullOrEmpty(excerpt)
? $"<p><a href=\"{catPoem.FullUrl}\">{title}</a></p>"
: $"<p><a href=\"{catPoem.FullUrl}\">{title}</a>: {excerpt}</p>";
}
}
return html;
}
private async Task<string> PrepareTajikCatHtmlTextAsync(RMuseumDbContext context, GanjoorTajikCat cat)
{
if(cat.TajikDescription == null)
{
cat.TajikDescription = "";
}
string[] lines = cat.TajikDescription.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
string html = "";
foreach (var line in lines)
{
html += $"<p>{line}</p>";
}
var subCats = await context.GanjoorCategories.AsNoTracking().Where(c => c.ParentId == cat.Id).OrderBy(p => p.Id).ToListAsync();
foreach (var subCat in subCats)
{
var tajikCat = await context.TajikCats.AsNoTracking().Where(t => t.Id == subCat.Id).SingleOrDefaultAsync();
if (tajikCat != null)
{
html += $"<p><a href=\"{subCat.FullUrl}\">{LanguageUtils.CleanTextForTransileration(tajikCat.TajikTitle)}</a></p>";
}
}
var catPoems = await context.GanjoorPoems.AsNoTracking().Where(p => p.CatId == cat.Id).OrderBy(p => p.Id).ToListAsync();
var tajikPoems = await context.TajikPoems.AsNoTracking().Where(p => p.CatId == cat.Id).OrderBy(p => p.Id).ToListAsync();
foreach (var catPoem in catPoems)
{
var tajikPoem = tajikPoems.Where(p => p.Id == catPoem.Id).SingleOrDefault();
if(tajikPoem != null)
{
string excerpt = ExtractExcerpt(tajikPoem.TajikPlainText);
string title = LanguageUtils.CleanTextForTransileration(tajikPoem.TajikTitle);
html += string.IsNullOrEmpty(excerpt)
? $"<p><a href=\"{catPoem.FullUrl}\">{title}</a></p>"
: $"<p><a href=\"{catPoem.FullUrl}\">{title}</a>: {excerpt}</p>";
}
}
return html;
}
}
}

View File

@ -92,85 +92,6 @@ namespace RMuseum.Services.Implementation
;
}
/// <summary>
/// simple, best-effort Perso-Arabic -> Tajik Cyrillic letter substitution.
/// NOT a linguistically correct transliteration: Perso-Arabic script does not
/// write short vowels, so a letter-by-letter map cannot recover them (that's
/// why a dedicated tool like persian-tajik.ir exists, and why the bulk of
/// TajikGanjoor's content comes from real TajikPoems/TajikCats/TajikPoets rows
/// rather than on-the-fly conversion). This exists only as a fallback for
/// spots - like a neighbouring poem's excerpt on the next/previous nav - that
/// don't have a proper Tajik translation in the database yet, so at least
/// something in Cyrillic is shown instead of raw Perso-Arabic script.
/// </summary>
private static readonly Dictionary<char, string> _persianToTajikMap = new Dictionary<char, string>()
{
['آ'] = "о",
['ا'] = "о",
['ب'] = "б",
['پ'] = "п",
['ت'] = "т",
['ث'] = "с",
['ج'] = "ҷ",
['چ'] = "ч",
['ح'] = "ҳ",
['خ'] = "х",
['د'] = "д",
['ذ'] = "з",
['ر'] = "р",
['ز'] = "з",
['ژ'] = "ж",
['س'] = "с",
['ش'] = "ш",
['ص'] = "с",
['ض'] = "з",
['ط'] = "т",
['ظ'] = "з",
['ع'] = "ъ",
['غ'] = "ғ",
['ف'] = "ф",
['ق'] = "қ",
['ک'] = "к",
['ك'] = "к",
['گ'] = "г",
['ل'] = "л",
['م'] = "м",
['ن'] = "н",
['و'] = "в",
['ه'] = "ҳ",
['ی'] = "ӣ",
['ي'] = "ӣ",
['ئ'] = "ъ",
['ؤ'] = "в",
['ء'] = "ъ",
['ة'] = "ат",
['؟'] = "?",
['،'] = ",",
['؛'] = ";",
['۰'] = "0",
['۱'] = "1",
['۲'] = "2",
['۳'] = "3",
['۴'] = "4",
['۵'] = "5",
['۶'] = "6",
['۷'] = "7",
['۸'] = "8",
['۹'] = "9",
};
public static string TransliteratePersianToTajik(string text)
{
if (string.IsNullOrEmpty(text)) return text;
text = CleanTextForTransileration(text);
var sb = new System.Text.StringBuilder(text.Length);
foreach (char c in text)
{
sb.Append(_persianToTajikMap.TryGetValue(c, out string mapped) ? mapped : c.ToString());
}
return sb.ToString();
}
public static string SameSoundLetters(string text)
{

View File

@ -68,8 +68,7 @@
"BooksCoverTitleFontName": "IranNastaliq",
"BooksCoverTitleFontSize": "40",
"ImportCommentsAsPoemSummary": "True",
"MaxWordLengthInComments": 200,
"TajikSitemapLocation": "C:\\inetpub\\tj\\wwwroot\\sitemap.xml"
"MaxWordLengthInComments": 200
},
"PublicDataExport": {
"LocalWorkingCopyPath": "C:\\ganjoor-public-data",
@ -83,18 +82,6 @@
"GitExecutablePath": "",
"CommandTimeoutMinutes": "45"
},
"TajikPublicDataExport": {
"LocalWorkingCopyPath": "C:\\ganjoor-tajik-public-data",
"RemoteUrl": "https://github.com/ganjoor/ganjoor-tajik-data.git",
"Branch": "main",
"CommitAuthorName": "Ganjoor Tajik Export Bot",
"CommitAuthorEmail": "bot@ganjoor.net",
"PushEnabled": "False",
"GitUserName": "",
"GitToken": "",
"GitExecutablePath": "",
"CommandTimeoutMinutes": "45"
},
"ExternalFTPServer": {
"Host": "localhost",
"Port": "22",

View File

@ -1,46 +0,0 @@
namespace TajikGanjoor
{
/// <summary>
/// API Root
/// </summary>
public static class APIRoot
{
/// <summary>
/// url
/// </summary>
public static string Url
{
get
{
if (!string.IsNullOrEmpty(_url))
return _url;
IConfigurationRoot configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory()).AddJsonFile("appsettings.json")
.Build();
_url = configuration["APIRoot"] ?? "https://api.ganjoor.net";
return _url;
}
}
private static string _url = "";
private static string _InternetUrl = "";
/// <summary>
/// internet accessible end point
/// </summary>
public static string InternetUrl
{
get
{
if (!string.IsNullOrEmpty(_InternetUrl))
return _InternetUrl;
IConfigurationRoot configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory()).AddJsonFile("appsettings.json")
.Build();
_InternetUrl = configuration["GlobalAPIRoot"] ?? "https://api.ganjoor.net";
return _InternetUrl;
}
}
}
}

View File

@ -1,26 +0,0 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

View File

@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Diagnostics;
namespace TajikGanjoor.Pages
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private readonly ILogger<ErrorModel> _logger;
public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
}

View File

@ -1,215 +0,0 @@
@page
@model IndexModel
@if (!string.IsNullOrEmpty(Model.LastError))
{
@Html.Raw(Model.LastError)
return;
}
@if (Model.Poets == null)
{
<p>
Хато дар дарёфти феҳристи суханварон
</p>
return;
}
<div class="text-center">
@if (Model.IsSearchPage)
{
@if (Model.Poems == null)
{
<div class="poem">
<h2>Ёфт менашавад ҷустаем мо</h2>
<p>
Шеъре шомили ин иборат ёфт нашуд.
</p>
</div>
}
else
for (int poemIndex = 0; poemIndex < Model.Poems.Count; poemIndex++)
{
var poem = Model.Poems[poemIndex];
<div class="sitem">
<a class="sitem-poet-portrait" href="@poem.Category.Poet.FullUrl" title="@poem.Category.Poet.Nickname">
<img src="/images/poets/@(poem.Category.Poet.Id).png" alt="@poem.Category.Poet.Nickname" loading="lazy"
onerror="this.onerror=null;this.src='/images/poets/999.png';" />
</a>
<div class="sitem-body">
<h2>
<a href="@poem.FullUrl">@poem.FullTitle</a>
</h2>
<div class="sit">
@Html.Raw(poem.PlainText)
</div>
</div>
</div>
}
<div class="spacer">&nbsp;</div>
@Html.Raw(Model.PagingToolsHtml)
}
else
@if (Model.IsHomePage)
{
var popularGroup = Model.PoetGroups.SingleOrDefault(g => g.Id == 0);
var centuryGroups = Model.PoetGroups.Where(g => g.Id != 0).ToList();
<div class="tg-poet-search">
<input type="text" id="tg-findpoet" placeholder="Ҷустуҷӯи шоир" oninput="tgInlineSearch(this.value)" />
<div class="tg-poet-grid" id="tg-found-poets"></div>
</div>
<div class="tg-view-switch">
<button type="button" class="tg-view-switch-btn active" data-view="century" onclick="tgSwitchView('century')">Аз рӯи аср</button>
<button type="button" class="tg-view-switch-btn" data-view="alpha" onclick="tgSwitchView('alpha')">Аз рӯи алифбо</button>
</div>
<div id="tg-view-century">
@if (popularGroup != null)
{
<div class="tg-century-heading">Шоирони маъмул</div>
<div class="tg-poet-grid">
@foreach (var poet in popularGroup.Poets)
{
<a class="tg-poet-card" href="@poet.FullUrl" title="@poet.Name" data-poet-id="@poet.Id" data-value="@poet.Nickname">
<span class="tg-poet-portrait">
<img src="/images/poets/@(poet.Id).png" alt="@poet.Nickname" loading="lazy"
onerror="this.onerror=null;this.src='/images/poets/999.png';" />
</span>
<span class="tg-poet-name">@poet.Nickname</span>
</a>
}
</div>
}
@if (centuryGroups.Count > 0)
{
<div class="tg-century-heading">Гурӯҳбандӣ бар асоси аср</div>
<div class="tg-century-nav">
@foreach (var g in centuryGroups)
{
<a href="#tg-pg-@g.Id" class="circled-number">@(g.HalfCenturyOrder + 2)</a>
}
</div>
@foreach (var century in centuryGroups)
{
@if (century.ShowInTimeLine)
{
<div class="tg-century-heading" id="tg-pg-@century.Id">@century.Name</div>
}
<div class="tg-poet-grid">
@foreach (var poet in century.Poets)
{
<a class="tg-poet-card" href="@poet.FullUrl" title="@poet.Name" data-poet-id="@poet.Id" data-value="@poet.Nickname">
<span class="tg-poet-portrait">
<img src="/images/poets/@(poet.Id).png" alt="@poet.Nickname" loading="lazy"
onerror="this.onerror=null;this.src='/images/poets/999.png';" />
</span>
<span class="tg-poet-name">@poet.Nickname</span>
</a>
}
</div>
}
}
</div>
<div id="tg-view-alpha" style="display:none">
<div class="tg-century-heading">Аз рӯи алифбо</div>
<div class="tg-poet-grid">
@foreach (var poet in Model.AlphabeticalPoets)
{
<a class="tg-poet-card" href="@poet.FullUrl" title="@poet.Name" data-poet-id="@poet.Id" data-value="@poet.Nickname">
<span class="tg-poet-portrait">
<img src="/images/poets/@(poet.Id).png" alt="@poet.Nickname" loading="lazy"
onerror="this.onerror=null;this.src='/images/poets/999.png';" />
</span>
<span class="tg-poet-name">@poet.Nickname</span>
</a>
}
</div>
</div>
}
else
{
@if (Model.IsPoetPage)
{
<div class="tg-poet-header">
<span class="tg-poet-portrait-lg">
<img src="/images/poets/@(Model.GanjoorPage.PoetOrCat.Poet.Id).png" alt="@Model.GanjoorPage.PoetOrCat.Poet.Nickname" loading="lazy"
onerror="this.onerror=null;this.src='/images/poets/999.png';" />
</span>
</div>
<div class="tg-poet-page">
@Html.Raw(Model.GanjoorPage.HtmlText)
</div>
}
else if (Model.IsCatPage)
{
<div class="tg-cat-page">
@Html.Raw(Model.GanjoorPage.HtmlText)
</div>
}
else if (Model.IsPoemPage)
{
@Html.Raw(Model.GanjoorPage.HtmlText)
@if (Model.GanjoorPage.Poem.Recitations != null && Model.GanjoorPage.Poem.Recitations.Length > 0)
{
<div class="tg-recitations">
<div class="tg-recitations-title">Хониш</div>
@foreach (var recitation in Model.GanjoorPage.Poem.Recitations)
{
<div class="tg-recitation">
<audio id="tg-audio-@recitation.Id" controls preload="none">
<source src="@recitation.Mp3Url" />
</audio>
<div class="tg-recitation-caption">
<span class="tg-recitation-title">@recitation.AudioTitle</span>
@if (!string.IsNullOrEmpty(recitation.AudioArtist))
{
<span class="tg-recitation-artist">— @recitation.AudioArtist</span>
}
<a class="tg-recitation-download" href="@recitation.Mp3Url" download title="Боргирӣ">⬇</a>
</div>
@if (recitation.InSyncWithText)
{
<script>
tgInitRecitationSync(
document.getElementById('tg-audio-@recitation.Id'),
'@Html.Raw($"{TajikGanjoor.APIRoot.InternetUrl}/api/audio/file/{recitation.Id}.xml")',
'@Html.Raw(recitation.AudioArtist)',
@recitation.Id
);
</script>
}
</div>
}
</div>
<div id="tg-sticky-player" class="tg-sticky-player" style="display:none;">
<span id="tg-sticky-narrator" class="tg-sticky-player-narrator"></span>
<div id="tg-sticky-audio-slot" class="tg-sticky-player-audio-slot"></div>
<a role="button" id="tg-sticky-lock" class="tg-sticky-player-btn" onclick="tgSwitchScrollLock()" title="Қулфи ҳамоҳангӣ бо матн"><span id="tg-sticky-lock-icon"></span></a>
<a role="button" class="tg-sticky-player-btn" onclick="tgScrollToCurrentVerse()" title="Гузариш ба байти ҷорӣ">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 8a4 4 0 100 8 4 4 0 000-8m8.94 3A9 9 0 0013 3.06V1h-2v2.06A9 9 0 003.06 11H1v2h2.06A9 9 0 0011 20.94V23h2v-2.06A9 9 0 0020.94 13H23v-2zM12 19a7 7 0 110-14 7 7 0 010 14z"/></svg>
</a>
<a role="button" class="tg-sticky-player-btn" onclick="tgCloseStickyPlayer()" title="Пӯшидан">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</a>
</div>
}
}
else
{
@Html.Raw(Model.GanjoorPage.HtmlText)
}
}
</div>

View File

@ -1,553 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using RMuseum.Models.Ganjoor.ViewModels;
using RMuseum.Models.Ganjoor;
using System.Net;
using System.Text.RegularExpressions;
using RSecurityBackend.Models.Generic;
using RMuseum.Services.Implementation;
namespace TajikGanjoor.Pages
{
public class IndexModel : PageModel
{
public bool IsHomePage { get; set; }
public bool IsSearchPage { get; set; }
public List<GanjoorPoetViewModel>? Poets { get; set; }
public bool IsPoetPage { get; set; }
public bool IsCatPage { get; set; }
public bool IsPoemPage { get; set; }
public GanjoorPageCompleteViewModel? GanjoorPage { get; set; }
public string NextUrl { get; set; }
public string NextTitle { get; set; }
public string PreviousUrl { get; set; }
public string PreviousTitle { get; set; }
public string BreadCrumpUrls { get; set; }
public string Query { get; set; }
public PaginationMetadata PaginationMetadata { get; set; }
public string PagingToolsHtml { get; set; }
public List<GanjoorPoemCompleteViewModel> Poems { get; set; }
/// <summary>
/// poets grouped by century, shown on the home page - mirrors GanjooRazor's Index.cshtml.cs
/// </summary>
public List<GanjoorCenturyViewModel> PoetGroups { get; set; }
/// <summary>
/// all translated poets, sorted alphabetically by Tajik nickname - alternate flat view,
/// switched to on the home page instead of the century grouping
/// </summary>
public List<GanjoorPoetViewModel> AlphabeticalPoets { get; set; }
public async Task<IActionResult> OnGetAsync()
{
if (bool.Parse(Configuration["MaintenanceMode"] ?? false.ToString()))
{
return StatusCode(503);
}
if (false == await PreparePoetsAsync())
{
return Page();
}
IsSearchPage = !string.IsNullOrEmpty(Request.Query["s"]);
IsHomePage = !IsSearchPage && Request.Path == "/";
if (IsHomePage)
{
if (false == await PreparePoetGroupsAsync())
{
return Page();
}
// plain ordinal sort gets this wrong: the six Tajik-specific letters
// (ғ ӣ қ ӯ ҳ ҷ) sit outside the main Cyrillic code point block, so they'd
// sort out of alphabetical order (e.g. after "я") under Ordinal - a
// culture-aware comparer places them correctly relative to their base
// letter (ғ after г, etc). Falls back to Ordinal if "tg-TJ" collation
// data isn't available on the server, rather than failing the page.
try
{
var tajikCulture = System.Globalization.CultureInfo.GetCultureInfo("tg-TJ");
AlphabeticalPoets = Poets.OrderBy(p => p.Nickname, StringComparer.Create(tajikCulture, false)).ToList();
}
catch (System.Globalization.CultureNotFoundException)
{
AlphabeticalPoets = Poets.OrderBy(p => p.Nickname, StringComparer.Ordinal).ToList();
}
}
int pageNumber = 1;
if (IsSearchPage)
{
Query = Request.Query["s"].ToString().Trim();
if (!string.IsNullOrEmpty(Request.Query["page"]))
{
pageNumber = int.Parse(Request.Query["page"]);
}
HttpResponseMessage searchQueryResponse = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/tajik/search?term={Query}&PageNumber={pageNumber}&PageSize=20");
if (!searchQueryResponse.IsSuccessStatusCode)
{
LastError = JsonConvert.DeserializeObject<string>(await searchQueryResponse.Content.ReadAsStringAsync());
return Page();
}
Poems = JArray.Parse(await searchQueryResponse.Content.ReadAsStringAsync()).ToObject<List<GanjoorPoemCompleteViewModel>>();
if (Poems != null && Poems.Count == 0)
{
Poems = null;
}
if (Poems != null)
{
// highlight searched word
string[] queryParts = Query.IndexOf('"') == 0 && Query.LastIndexOf('"') == (Query.Length - 1) ?
[Query.Replace("\"", "")]
:
Query.Replace("\"", "").Split(' ', StringSplitOptions.RemoveEmptyEntries);
foreach (var poem in Poems)
{
string[] lines = poem.PlainText.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
List<int> linesInExcerpt = new List<int>();
for (int i = 0; i < lines.Length; i++)
{
foreach (var queryPart in queryParts)
{
if (lines[i].IndexOf(queryPart) != -1)
{
if (i > 0)
{
if (linesInExcerpt.IndexOf(i - 1) == -1)
{
linesInExcerpt.Add(i - 1);
}
}
if (linesInExcerpt.IndexOf(i) == -1)
{
linesInExcerpt.Add(i);
}
if (i < (lines.Length - 1))
linesInExcerpt.Add(i + 1);
break;
}
}
}
string plainText = "";
for (int i = 0; i < linesInExcerpt.Count; i++)
{
if (linesInExcerpt[i] > 0 && linesInExcerpt.IndexOf(linesInExcerpt[i] - 1) == -1)
plainText += "... ";
plainText += $"{lines[linesInExcerpt[i]]}";
if (linesInExcerpt[i] < (lines.Length - 1) && linesInExcerpt.IndexOf(linesInExcerpt[i] + 1) == -1)
plainText += " ...";
plainText += $"{Environment.NewLine}";
}
string finalPlainText = "";
foreach (string line in plainText.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries))
{
finalPlainText += $"<p>{line}</p>";
}
if (linesInExcerpt.Count > 0)
{
poem.PlainText = finalPlainText;
}
for (int i = 0; i < queryParts.Length; i++)
{
string cssClass = i % 3 == 0 ? "hilite" : i % 3 == 1 ? "hilite2" : "hilite3";
poem.PlainText = Regex.Replace(poem.PlainText, queryParts[i], $"<span class=\"{cssClass}\">{queryParts[i]}</span>", RegexOptions.IgnoreCase | RegexOptions.RightToLeft); ;
}
}
string paginationMetadataJsonValue = searchQueryResponse.Headers.GetValues("paging-headers").FirstOrDefault();
if (!string.IsNullOrEmpty(paginationMetadataJsonValue))
{
PaginationMetadata = JsonConvert.DeserializeObject<PaginationMetadata>(paginationMetadataJsonValue);
string catQuery = "";
if (!string.IsNullOrEmpty(Request.Query["cat"]))
{
catQuery = $"&cat={Request.Query["cat"]}";
}
PagingToolsHtml = GeneratePagingBarHtml(PaginationMetadata, $"/?s={WebUtility.UrlEncode(Query)}");
}
}
}
else
if (!IsHomePage)
{
var pageQuery = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/tajik/page?url={Request.Path}&catPoems=true");
if (!pageQuery.IsSuccessStatusCode)
{
if (pageQuery.StatusCode == HttpStatusCode.NotFound)
{
var redirectQuery = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/redirecturl?url={Request.Path}");
if (redirectQuery.IsSuccessStatusCode)
{
var redirectUrl = JsonConvert.DeserializeObject<string>(await redirectQuery.Content.ReadAsStringAsync());
return Redirect(redirectUrl ?? "/");
}
return NotFound();
}
}
if (!pageQuery.IsSuccessStatusCode)
{
LastError = JsonConvert.DeserializeObject<string>(await pageQuery.Content.ReadAsStringAsync());
return Page();
}
GanjoorPage = JObject.Parse(await pageQuery.Content.ReadAsStringAsync()).ToObject<GanjoorPageCompleteViewModel>();
if (GanjoorPage == null)
{
LastError = "GanjoorPage == null";
return Page();
}
switch (GanjoorPage.GanjoorPageType)
{
case GanjoorPageType.PoemPage:
GanjoorPage.PoetOrCat = GanjoorPage.Poem.Category;
PrepareNextPre();
IsPoemPage = true;
break;
case GanjoorPageType.PoetPage:
IsPoetPage = true;
break;
case GanjoorPageType.CatPage:
PrepareNextPre();
IsCatPage = true;
break;
}
}
BreadCrumpUrls = "<a href=\"/\">Ганҷур</a>";
if (IsSearchPage)
{
ViewData["Title"] = $"Ганҷур - ҷустуҷӯ - {Query}";
if(pageNumber > 1)
{
ViewData["Title"] += "Сафҳаи " + pageNumber.ToString();
}
}
else
if (IsHomePage)
{
ViewData["Title"] = "Ганҷур";
}
else
if (IsPoetPage)
{
ViewData["Title"] = $"Ганҷур - {GanjoorPage?.PoetOrCat.Poet.Nickname}";
BreadCrumpUrls += $" - <a href=\"{GanjoorPage?.PoetOrCat.Poet.FullUrl}\" class=\"tg-crumb-poet\"><img src=\"/images/poets/{GanjoorPage?.PoetOrCat.Poet.Id}.png\" alt=\"\" onerror=\"this.onerror=null;this.src='/images/poets/999.png';\" />{GanjoorPage?.PoetOrCat.Poet.Nickname}</a>";
}
else
if (IsCatPage || IsPoemPage)
{
string title = $"Ганҷур - ";
bool firstAncestor = true;
foreach (var gran in GanjoorPage.PoetOrCat.Cat.Ancestors)
{
title += $"{gran.Title} - ";
if (firstAncestor)
{
// Ancestors[0] is always the poet's own root category (see GanjoorService._GetCatById,
// which walks Parent all the way up) - prefix it with the poet's portrait instead of
// adding a separate crumb, so the image and the poet's name stay a single link.
BreadCrumpUrls += $" - <a href=\"{gran.FullUrl}\" class=\"tg-crumb-poet\"><img src=\"/images/poets/{GanjoorPage.PoetOrCat.Poet.Id}.png\" alt=\"\" onerror=\"this.onerror=null;this.src='/images/poets/999.png';\" />{gran.Title}</a>";
firstAncestor = false;
}
else
{
BreadCrumpUrls += $" - <a href=\"{gran.FullUrl}\">{gran.Title}</a>";
}
}
title += GanjoorPage.PoetOrCat.Cat.Title;
BreadCrumpUrls += $" - <a href=\"{GanjoorPage?.PoetOrCat.Cat.FullUrl}\">{GanjoorPage?.PoetOrCat.Cat.Title}</a>";
if (IsPoemPage)
{
title += GanjoorPage.Poem.Title;
BreadCrumpUrls += $" - <a href=\"{GanjoorPage?.FullUrl}\">{GanjoorPage?.Poem.Title}</a>";
}
ViewData["Title"] = title;
}
ViewData["BreadCrumpUrls"] = BreadCrumpUrls;
ViewData["TrackingScript"] = Configuration["TrackingScript"] != null && string.IsNullOrEmpty(Request.Cookies["Token"]) ? Configuration["TrackingScript"].Replace("loggedon", "") : Configuration["TrackingScript"];
ViewData["GoogleTranslateLink"] = $"https://translate.google.com/translate?hl=fa&sl=fa&tl=tg&u={Uri.EscapeDataString(stringToEscape: $"https://ganjoor.net{Request.Path}")}";
ViewData["NextUrl"] = NextUrl;
ViewData["NextTitle"] = NextTitle;
ViewData["PreviousUrl"] = PreviousUrl;
ViewData["PreviousTitle"] = PreviousTitle;
return Page();
}
private async Task<bool> PreparePoetsAsync()
{
var cacheKey = $"/api/ganjoor/poets";
if (!_memoryCache.TryGetValue(cacheKey, out List<GanjoorPoetViewModel>? poets))
{
try
{
var res1 = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/poets");
if (!res1.IsSuccessStatusCode)
{
LastError = JsonConvert.DeserializeObject<string>(await res1.Content.ReadAsStringAsync()) ?? "!res1.IsSuccessStatusCode";
return false;
}
var ganjoorPoets = JArray.Parse(await res1.Content.ReadAsStringAsync()).ToObject<List<GanjoorPoetViewModel>>();
ganjoorPoets = ganjoorPoets ?? [];
var response = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/tajik/poets");
if (!response.IsSuccessStatusCode)
{
LastError = JsonConvert.DeserializeObject<string>(await response.Content.ReadAsStringAsync()) ?? "!response.IsSuccessStatusCode";
return false;
}
var tajkPoets = JArray.Parse(await response.Content.ReadAsStringAsync()).ToObject<List<GanjoorTajikPoet>>();
poets = new List<GanjoorPoetViewModel>();
foreach (var tajikPoet in tajkPoets)
{
var poet = ganjoorPoets.Where(p => p.Id == tajikPoet.Id).Single();
poet.Nickname = tajikPoet.TajikNickname;
poet.Description = tajikPoet.TajikDescription;
poets.Add(poet);
}
Poets = poets;
if (AggressiveCacheEnabled)
{
_memoryCache.Set(cacheKey, poets, TimeSpan.FromHours(1));
}
}
catch (Exception e)
{
LastError = e.ToString();
return false;
}
}
Poets = poets ?? [];
return true;
}
/// <summary>
/// fetches the same /api/ganjoor/centuries grouping GanjooRazor's homepage uses, then
/// overlays Tajik nickname/description onto each group's poets the same way
/// PreparePoetsAsync does, dropping poets with no Tajik translation and dropping any
/// century that ends up empty as a result - century Name itself has no dedicated Tajik
/// translation source anywhere, so it falls back to LanguageUtils.TransliteratePersianToTajik
/// (a handful of these are short/fixed enough that hand-correcting the wording later,
/// directly in this list, would be easy if the transliteration reads awkwardly)
/// </summary>
private async Task<bool> PreparePoetGroupsAsync()
{
var cacheKey = $"/api/ganjoor/tajik/centuries";
if (!_memoryCache.TryGetValue(cacheKey, out List<GanjoorCenturyViewModel>? poetGroups))
{
try
{
var res1 = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/centuries");
if (!res1.IsSuccessStatusCode)
{
LastError = JsonConvert.DeserializeObject<string>(await res1.Content.ReadAsStringAsync()) ?? "!res1.IsSuccessStatusCode";
return false;
}
var centuries = JArray.Parse(await res1.Content.ReadAsStringAsync()).ToObject<List<GanjoorCenturyViewModel>>();
centuries = centuries ?? [];
var response = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/tajik/poets");
if (!response.IsSuccessStatusCode)
{
LastError = JsonConvert.DeserializeObject<string>(await response.Content.ReadAsStringAsync()) ?? "!response.IsSuccessStatusCode";
return false;
}
var tajikPoets = JArray.Parse(await response.Content.ReadAsStringAsync()).ToObject<List<GanjoorTajikPoet>>() ?? [];
poetGroups = new List<GanjoorCenturyViewModel>();
foreach (var century in centuries)
{
var translatedPoets = new List<GanjoorPoetViewModel>();
foreach (var poet in century.Poets)
{
var tajikPoet = tajikPoets.SingleOrDefault(p => p.Id == poet.Id);
if (tajikPoet != null)
{
poet.Nickname = tajikPoet.TajikNickname;
poet.Description = tajikPoet.TajikDescription;
translatedPoets.Add(poet);
}
}
if (translatedPoets.Count > 0)
{
century.Poets = translatedPoets;
century.Name = LanguageUtils.TransliteratePersianToTajik(century.Name);
poetGroups.Add(century);
}
}
if (AggressiveCacheEnabled)
{
_memoryCache.Set(cacheKey, poetGroups, TimeSpan.FromHours(1));
}
}
catch (Exception e)
{
LastError = e.ToString();
return false;
}
}
PoetGroups = poetGroups ?? [];
return true;
}
private void PrepareNextPre()
{
if (GanjoorPage == null) return;
switch (GanjoorPage.GanjoorPageType)
{
case GanjoorPageType.PoemPage:
{
if (GanjoorPage.Poem.Next != null)
{
NextUrl = GanjoorPage.PoetOrCat.Cat.FullUrl + "/" + GanjoorPage.Poem.Next.UrlSlug;
NextTitle = GanjoorPage.Poem.Next.Title + ": " + GanjoorPage.Poem.Next.Excerpt;
}
else
if (GanjoorPage.Poem.MixedModeOrder > 0
&& GanjoorPage.Poem.Category.Cat.Children.Where(c => c.MixedModeOrder == 0 || c.MixedModeOrder > GanjoorPage.Poem.MixedModeOrder).Any())
{
var nextCat = GanjoorPage.Poem.Category.Cat.Children.Where(c => c.MixedModeOrder == 0 || c.MixedModeOrder > GanjoorPage.Poem.MixedModeOrder).OrderBy(c => c.MixedModeOrder).First();
NextUrl = nextCat.FullUrl;
NextTitle = nextCat.Title;
}
else
if (GanjoorPage.Poem.Category.Cat.Next != null)
{
NextUrl = GanjoorPage.Poem.Category.Cat.Next.FullUrl;
NextTitle = GanjoorPage.Poem.Category.Cat.Next.Title;
}
if (GanjoorPage.Poem.Previous != null)
{
PreviousUrl = GanjoorPage.PoetOrCat.Cat.FullUrl + "/" + GanjoorPage.Poem.Previous.UrlSlug;
PreviousTitle = GanjoorPage.Poem.Previous.Title + ": " + GanjoorPage.Poem.Previous.Excerpt;
}
else
if (GanjoorPage.Poem.MixedModeOrder > 0
&& GanjoorPage.Poem.Category.Cat.Children.Where(c => c.MixedModeOrder != 0 && c.MixedModeOrder < GanjoorPage.Poem.MixedModeOrder).Any())
{
var prevCat = GanjoorPage.Poem.Category.Cat.Children.Where(c => c.MixedModeOrder != 0 && c.MixedModeOrder < GanjoorPage.Poem.MixedModeOrder).OrderByDescending(c => c.MixedModeOrder).First();
PreviousUrl = prevCat.FullUrl;
PreviousTitle = prevCat.Title;
}
else
if (GanjoorPage.Poem.Category.Cat.Previous != null)
{
PreviousUrl = GanjoorPage.Poem.Category.Cat.Previous.FullUrl;
PreviousTitle = GanjoorPage.Poem.Category.Cat.Previous.Title;
}
}
break;
case GanjoorPageType.CatPage:
{
if (GanjoorPage.PoetOrCat.Cat.Next != null)
{
NextUrl = GanjoorPage.PoetOrCat.Cat.Next.FullUrl;
NextTitle = GanjoorPage.PoetOrCat.Cat.Next.Title;
}
if (GanjoorPage.PoetOrCat.Cat.Previous != null)
{
PreviousUrl = GanjoorPage.PoetOrCat.Cat.Previous.FullUrl;
PreviousTitle = GanjoorPage.PoetOrCat.Cat.Previous.Title;
}
}
break;
}
}
private string GeneratePagingBarHtml(PaginationMetadata paginationMetadata, string routeStartWithQueryStrings)
{
string htmlText = $"<div>{Environment.NewLine}";
if (paginationMetadata != null && paginationMetadata.totalPages > 1)
{
if (paginationMetadata.currentPage > 3)
{
htmlText += $"<a href=\"{routeStartWithQueryStrings.Replace("\"", "%22")}&amp;page=1\"><div class=\"circled-number\">1</div></a>{Environment.NewLine} …";
}
for (int i = paginationMetadata.currentPage - 2; i <= (paginationMetadata.currentPage + 2); i++)
{
if (i >= 1 && i <= paginationMetadata.totalPages)
{
if (i == paginationMetadata.currentPage)
{
htmlText += $"<div class=\"circled-number-diff\">{i}</div>";
}
else
{
htmlText += $"<a href=\"{routeStartWithQueryStrings.Replace("\"", "%22")}&amp;page={i}\"><div class=\"circled-number\">{i}</div></a>{Environment.NewLine}";
}
}
}
if (paginationMetadata.totalPages > (paginationMetadata.currentPage + 2))
{
htmlText += $"… <a href=\"{routeStartWithQueryStrings.Replace("\"", "%22")}&amp;page={paginationMetadata.totalPages}\"><div class=\"circled-number\">{paginationMetadata.totalPages}</div></a>{Environment.NewLine}";
}
}
htmlText += $"</div>{Environment.NewLine}";
return htmlText;
}
public string? LastError { get; set; }
public bool AggressiveCacheEnabled
{
get
{
try
{
return bool.Parse(Configuration["AggressiveCacheEnabled"] ?? false.ToString());
}
catch
{
return false;
}
}
}
protected readonly IConfiguration Configuration;
protected readonly HttpClient _httpClient;
private readonly IMemoryCache _memoryCache;
public IndexModel(IConfiguration configuration, HttpClient httpClient, IMemoryCache memoryCache)
{
Configuration = configuration;
_httpClient = httpClient;
_memoryCache = memoryCache;
}
}
}

View File

@ -1,80 +0,0 @@
<!DOCTYPE html>
<html lang="tg">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"]</title>
<link rel="icon" href="~/favicon.ico" sizes="any" />
<meta name="theme-color" content="#6b3f1d" />
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/tajik-ganjoor.css" asp-append-version="true" />
<link rel="stylesheet" href="~/TajikGanjoor.styles.css" asp-append-version="true" />
<script src="~/js/recitation-sync.js" asp-append-version="true"></script>
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="tg-breadcrumb">
@Html.Raw(ViewData["BreadCrumpUrls"])
</div>
<div class="flex-fill"></div>
<form class="d-flex" action="/" method="get" id="searchhorm">
<input type="search" class="form-control" placeholder="ҷустуҷӯ" aria-label="ҷустуҷӯ" name="s" id="s" value="@Model.Query">
</form>
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
@if (!string.IsNullOrEmpty(ViewData["PreviousUrl"]?.ToString()) || !string.IsNullOrEmpty(ViewData["NextUrl"]?.ToString()))
{
<nav class="tg-poem-nav">
@if (!string.IsNullOrEmpty(ViewData["PreviousUrl"]?.ToString()))
{
<a class="tg-poem-nav-link tg-poem-nav-prev" href="@ViewData["PreviousUrl"]" title="@ViewData["PreviousTitle"]">
<span class="tg-poem-nav-arrow">←</span>
<span class="tg-poem-nav-text">
<span class="tg-poem-nav-caption">қаблӣ</span>
<span class="tg-poem-nav-title">@ViewData["PreviousTitle"]</span>
</span>
</a>
}
else
{
<span></span>
}
@if (!string.IsNullOrEmpty(ViewData["NextUrl"]?.ToString()))
{
<a class="tg-poem-nav-link tg-poem-nav-next" href="@ViewData["NextUrl"]" title="@ViewData["NextTitle"]">
<span class="tg-poem-nav-text">
<span class="tg-poem-nav-caption">баъдӣ</span>
<span class="tg-poem-nav-title">@ViewData["NextTitle"]</span>
</span>
<span class="tg-poem-nav-arrow">→</span>
</a>
}
</nav>
}
</div>
<footer class="border-top footer text-muted">
<span>Ба неруии <a href="http://persian-tajik.ir/FarsiTotajiki.aspx" target="_blank">пиршяни тоҷик</a></span>
<span><a href="@ViewData["GoogleTranslateLink"]" target="_blank">Дар ганҷури форсӣ</a></span>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
@if (ViewData["TrackingScript"] != null && !string.IsNullOrEmpty(ViewData["TrackingScript"].ToString()))
{
@Html.Raw(ViewData["TrackingScript"])
}
</body>
</html>

View File

@ -1,2 +0,0 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

View File

@ -1,3 +0,0 @@
@using TajikGanjoor
@namespace TajikGanjoor.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -1,3 +0,0 @@
@{
Layout = "_Layout";
}

View File

@ -1,32 +0,0 @@
using System.Text.Encodings.Web;
using System.Text.Unicode;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(
HtmlEncoder.Create(allowedRanges: [ UnicodeRanges.BasicLatin,
UnicodeRanges.Arabic, UnicodeRanges.Cyrillic ]));
builder.Services.AddRazorPages(options =>
{
options.Conventions.AddPageRoute("/index", "{*url}");
});
builder.Services.AddHttpClient();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<DeleteExistingFiles>true</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>bin\Release\net10.0-windows7.0\publish\</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<_TargetId>Folder</_TargetId>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net10.0-windows7.0</TargetFramework>
<ProjectGuid>1c51eb06-5190-4646-84c7-5ee712c86f57</ProjectGuid>
<SelfContained>false</SelfContained>
</PropertyGroup>
</Project>

View File

@ -1,29 +0,0 @@
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:33082"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:37226",
"sslPort": 0
}
}
}

View File

@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0-windows7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<NoWarn>1701;1702;8602;8618;8601;8604;8625;8600</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<NoWarn>1701;1702;8602;8618;8601;8604;8625;8600</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\RMuseum\RMuseum.csproj" />
</ItemGroup>
</Project>

View File

@ -1,9 +0,0 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,17 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"TrackingScript": "<script src='https://cdn.kntr.ir/tracker.js'></script><script>kntrTracking.startTracking(\"2\", \"loggedon\");</script>",
"APIRoot": "https://api.ganjoor.net",
"GlobalAPIRoot": "https://api.ganjoor.net",
"SiteUrl": "http://localhost:33082",
"AggressiveCacheEnabled": "False",
"BannersEnabled": "False",
"ReadOnlyMode": "False",
"MaintenanceMode": "False"
}

View File

@ -1,13 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@ -1,42 +0,0 @@
html {
font-size: 14px;
}
@media (min-width: 768px) {
html {
font-size: 16px;
}
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
html {
position: relative;
min-height: 100%;
}
body {
margin-bottom: 60px;
}
.hilite {
color: #000;
background-color: #9cf
}
.hilite1 {
color: #000;
background-color: #fc6
}
.hilite2 {
color: #000;
background-color: #9f6
}
.hilite3 {
color: #000;
background-color: #f99
}

View File

@ -1,859 +0,0 @@
/* ==========================================================================
TajikGanjoor typography & layout
Self-hosted fonts: PT Serif (poem/body text) + PT Sans (UI chrome).
Subset used: cyrillic-ext — the plain "cyrillic" subset is MISSING the
six Tajik-specific letters (ғ ӣ қ ӯ ҳ ҷ), cyrillic-ext has them.
========================================================================== */
@font-face {
font-family: 'PT Serif';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/ptserif/pt-serif-cyrillic-ext-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'PT Serif';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/ptserif/pt-serif-cyrillic-ext-400-italic.woff2') format('woff2');
}
@font-face {
font-family: 'PT Serif';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/ptserif/pt-serif-cyrillic-ext-700-normal.woff2') format('woff2');
}
@font-face {
font-family: 'PT Serif';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('/fonts/ptserif/pt-serif-cyrillic-ext-700-italic.woff2') format('woff2');
}
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/ptsans/pt-sans-cyrillic-ext-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'PT Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/ptsans/pt-sans-cyrillic-ext-400-italic.woff2') format('woff2');
}
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/ptsans/pt-sans-cyrillic-ext-700-normal.woff2') format('woff2');
}
@font-face {
font-family: 'PT Sans';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('/fonts/ptsans/pt-sans-cyrillic-ext-700-italic.woff2') format('woff2');
}
/* Persian original text (interlinear comparison lines) - Cyrillic fonts above have no
Arabic-script glyphs at all, so this needs its own font. Vazirmatn is the same font
the main ganjoor.net site self-hosts for Persian, reused here for a consistent,
recognizable feel across the two sites. */
@font-face {
font-family: 'Vazirmatn';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/vazirmatn/vazirmatn-arabic-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Vazirmatn';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/vazirmatn/vazirmatn-arabic-700-normal.woff2') format('woff2');
}
/* ---------- base page: warm paper tone, PT Sans chrome ---------- */
:root {
--tg-paper: #faf6ee;
--tg-paper-edge: #efe6d3;
--tg-surface: #ffffff;
--tg-ink: #2b2620;
--tg-ink-soft: #5b5346;
--tg-accent: #8a5a2b;
--tg-accent-deep: #6b3f1d;
--tg-rule: #d9cdb2;
}
@media (prefers-color-scheme: dark) {
:root {
--tg-paper: #211d18;
--tg-paper-edge: #2c2620;
--tg-surface: #2c2620;
--tg-ink: #ece6d9;
--tg-ink-soft: #bfb6a4;
--tg-accent: #d1a06a;
--tg-accent-deep: #e3b988;
--tg-rule: #453d32;
}
/* poet portrait circles keep a light backing regardless of theme -
the portraits are illustrations on a light background, and a
pure-white disc reads as a deliberate frame against the dark
page rather than a mismatched surface */
.tg-poet-portrait,
.tg-poet-portrait-lg,
.sitem-poet-portrait,
.tg-crumb-poet img {
background: #f4efe4;
}
}
html, body {
background-color: var(--tg-paper);
color: var(--tg-ink);
font-family: 'PT Sans', Tahoma, sans-serif;
}
.navbar {
background-color: var(--tg-paper) !important;
border-bottom: 1px solid var(--tg-rule) !important;
font-family: 'PT Sans', sans-serif;
}
/* BreadCrumpUrls always starts with <a href="/">Ганҷур</a> (see Index.cshtml.cs) -
style that first link as the site's wordmark rather than duplicating it. */
.tg-breadcrumb {
font-family: 'PT Sans', sans-serif;
font-size: 0.95rem;
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 4px;
row-gap: 2px;
}
.tg-breadcrumb a:first-child {
font-family: 'PT Serif', serif;
font-weight: 700;
font-size: 1.3rem;
color: var(--tg-accent-deep);
text-decoration: none;
margin-inline-end: 4px;
}
.navbar input[type="search"] {
font-family: 'PT Sans', sans-serif;
border: 1px solid var(--tg-rule);
background: var(--tg-surface);
color: var(--tg-ink);
}
.footer {
font-family: 'PT Sans', sans-serif;
font-size: 0.85rem;
}
.footer a {
color: var(--tg-accent-deep);
}
/* ---------- poem prev/next navigation ---------- */
.tg-poem-nav {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 12px;
max-width: 720px;
margin: 24px auto 8px auto;
padding-top: 14px;
border-top: 1px solid var(--tg-rule);
}
.tg-poem-nav-link {
display: flex;
align-items: center;
gap: 8px;
max-width: 46%;
padding: 6px 10px;
text-decoration: none;
color: var(--tg-ink-soft);
border-radius: 6px;
}
.tg-poem-nav-link:hover {
background-color: var(--tg-paper-edge);
color: var(--tg-accent-deep);
}
.tg-poem-nav-next {
margin-inline-start: auto;
text-align: end;
flex-direction: row-reverse;
}
.tg-poem-nav-arrow {
font-size: 1.1rem;
flex: 0 0 auto;
}
.tg-poem-nav-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.tg-poem-nav-caption {
font-family: 'PT Sans', sans-serif;
font-size: 0.72rem;
opacity: 0.75;
}
.tg-poem-nav-title {
font-family: 'PT Serif', serif;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---------- poem page: printed-book feel ---------- */
.container {
max-width: 720px;
}
.b, .b2, .n, .l {
font-family: 'PT Serif', serif;
}
/* one beyt (couplet): two stacked mesras, book-style, not side-by-side
columns — matches how Cyrillic verse is actually typeset (and matches
ganjoor.net's own <768px fallback), and needs no breakpoint since it's
already a single column at any width. */
.b {
max-width: 480px;
margin: 0 auto 1.35em auto;
text-align: center;
clear: both;
}
.b .m1, .b .m2 {
float: none;
width: auto;
display: block;
}
.b .m1 p, .b .m2 p {
font-family: 'PT Serif', serif;
font-weight: 400;
font-size: 1.15rem;
line-height: 1.9;
margin: 0;
text-shadow: none;
text-align: center;
}
.b .m2 p {
opacity: 0.92;
}
/* rubaʼi-style already-centered couplet */
.b2 p {
font-family: 'PT Serif', serif;
font-size: 1.15rem;
line-height: 1.9;
text-align: center;
margin: 0;
}
/* paragraph / single-line verses */
.n p, .l p {
font-family: 'PT Serif', serif;
font-size: 1.1rem;
line-height: 1.9;
text-align: center;
margin: 0 auto 1.2em auto;
max-width: 560px;
}
/* comments / notes: quieter, sans-serif, clearly not part of the poem */
.c {
max-width: 90%;
margin: 10px auto;
padding: 8px 14px;
background-color: var(--tg-paper-edge);
border-inline-start: 3px solid var(--tg-accent);
clear: both;
}
.c p {
font-family: 'PT Sans', sans-serif;
font-size: 0.85rem;
color: var(--tg-ink-soft);
margin: 0;
}
/* Persian original, shown interlinear directly beneath its Tajik line (per
verse.OriginalText - see PrepareBilingualHtmlText). Deliberately smaller
and quieter than the Tajik line above it: this is a Tajik-first site, the
Persian is a reference/comparison aid, not the primary reading text.
Selectors repeat the base verse rules' specificity + the .tg-fa class so
this reliably wins regardless of source order. */
.b .m1 p.tg-fa,
.b .m2 p.tg-fa,
.b2 p.tg-fa,
.n p.tg-fa,
.l p.tg-fa,
.c p.tg-fa {
font-family: 'Vazirmatn', Tahoma, sans-serif;
font-size: 0.82em;
line-height: 1.7;
color: var(--tg-ink-soft);
opacity: 0.85;
margin: 0.2em 0 0 0;
unicode-bidi: isolate;
}
/* poem title */
.text-center h1, .text-center h2 {
font-family: 'PT Serif', serif;
font-weight: 700;
color: var(--tg-accent-deep);
}
.text-center h1 a, .text-center h2 a {
color: inherit;
text-decoration: none;
}
/* faint rule between poems on a listing/category page */
.item {
padding-top: 20px;
border-top: 1px solid var(--tg-rule);
margin-top: 20px;
}
.item:first-child {
border-top: none;
margin-top: 0;
}
/* search hit highlighting - keep legible on the paper background */
.hilite, .hilite1, .hilite2, .hilite3 {
padding: 0 2px;
border-radius: 2px;
}
/* ---------- homepage: poet card grid ---------- */
.tg-poet-grid {
max-width: 980px;
margin: 10px auto 0 auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 18px 12px;
padding: 0 8px;
}
.tg-poet-card {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
color: var(--tg-ink);
}
.tg-poet-card .tg-poet-portrait {
width: 84px;
height: 84px;
border-radius: 50%;
overflow: hidden;
border: 2px solid var(--tg-rule);
background: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.tg-poet-card .tg-poet-portrait img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tg-poet-card .tg-poet-name {
margin-top: 8px;
font-family: 'PT Serif', serif;
font-size: 0.92rem;
text-align: center;
line-height: 1.25;
}
.tg-poet-card:hover .tg-poet-portrait {
border-color: var(--tg-accent);
}
.tg-poet-card:hover .tg-poet-name {
color: var(--tg-accent-deep);
}
@media (max-width: 480px) {
.tg-poet-grid {
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: 14px 8px;
}
.tg-poet-card .tg-poet-portrait {
width: 64px;
height: 64px;
}
.tg-poet-card .tg-poet-name {
font-size: 0.8rem;
}
}
/* ---------- poet bio page ---------- */
.tg-poet-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 18px;
}
.tg-poet-header .tg-poet-portrait-lg {
width: 128px;
height: 128px;
border-radius: 50%;
overflow: hidden;
border: 3px solid var(--tg-rule);
background: #fff;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.12);
}
.tg-poet-header .tg-poet-portrait-lg img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* small poet portrait inlined into the breadcrumb, prefixing the poet's
name wherever it appears there (poet page's own crumb, and the first
ancestor crumb on category/poem pages, which is always the poet's root
category - see Index.cshtml.cs) */
.tg-crumb-poet {
display: inline-flex;
align-items: center;
gap: 5px;
vertical-align: middle;
}
.tg-crumb-poet img {
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--tg-rule);
background: #fff;
vertical-align: middle;
flex: 0 0 auto;
}
/* GanjoorPage.HtmlText for poet/cat pages is server-generated plain
<p> tags (bio prose) followed by more <p><a>...</a></p> tags (sub-
category / poem links) with no distinguishing classes at all - the
markup is identical for both, so it's told apart here structurally:
a paragraph whose only content is a single link is a nav row, any
other paragraph is bio prose. */
.tg-poet-page p,
.tg-cat-page p {
font-family: 'PT Serif', serif;
font-size: 1.05rem;
line-height: 1.85;
text-align: start;
margin: 0 auto 0.9em auto;
max-width: 620px;
}
.tg-poet-page p:has(> a:only-child),
.tg-cat-page p:has(> a:only-child) {
font-family: 'PT Sans', sans-serif;
font-size: 1rem;
text-align: start;
margin: 0;
padding: 10px 14px;
border-bottom: 1px solid var(--tg-rule);
max-width: 620px;
line-height: 1.6;
}
.tg-poet-page p:has(> a:only-child):first-of-type,
.tg-cat-page p:has(> a:only-child):first-of-type {
border-top: 1px solid var(--tg-rule);
margin-top: 12px;
}
.tg-poet-page p:has(> a:only-child) a,
.tg-cat-page p:has(> a:only-child) a {
color: var(--tg-accent-deep);
text-decoration: none;
font-weight: 700;
}
/* the excerpt trailing the link (": ..." from PrepareTajikCatHtmlTextAsync /
PrepareTajikPoetHtmlTextAsync) is plain text in the same <p>, so it can only
be dimmed via the paragraph's own color - the link's explicit color above
still wins for the title itself */
.tg-poet-page p:has(> a:only-child),
.tg-cat-page p:has(> a:only-child) {
color: var(--tg-ink-soft);
}
.tg-poet-page p:has(> a:only-child):hover,
.tg-cat-page p:has(> a:only-child):hover {
background-color: var(--tg-paper-edge);
}
/* ---------- search results ---------- */
.sitem {
max-width: 620px;
margin: 0 auto;
padding: 16px 4px;
border-top: 1px solid var(--tg-rule);
text-align: start;
display: flex;
gap: 14px;
align-items: flex-start;
}
.sitem:first-of-type {
border-top: none;
}
.sitem-poet-portrait {
flex: 0 0 auto;
display: block;
width: 52px;
height: 52px;
border-radius: 50%;
overflow: hidden;
border: 2px solid var(--tg-rule);
background: #fff;
}
.sitem-poet-portrait img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.sitem-poet-portrait:hover {
border-color: var(--tg-accent);
}
.sitem-body {
flex: 1 1 auto;
min-width: 0;
}
.sitem h2 {
font-family: 'PT Serif', serif;
font-size: 1.15rem;
font-weight: 700;
margin-bottom: 6px;
}
.sitem h2 a {
color: var(--tg-accent-deep);
text-decoration: none;
}
.sit {
float: none;
max-width: none;
font-family: 'PT Serif', serif;
font-size: 0.98rem;
line-height: 1.8;
color: var(--tg-ink-soft);
}
.sit p {
margin: 0 0 4px 0;
}
@media (max-width: 420px) {
.sitem-poet-portrait {
width: 40px;
height: 40px;
}
}
/* ---------- pagination bar (GeneratePagingBarHtml in Index.cshtml.cs) ---------- */
.circled-number, .circled-number-diff {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin: 2px;
border-radius: 50%;
font-family: 'PT Sans', sans-serif;
font-size: 0.9rem;
text-decoration: none;
}
.circled-number {
border: 1px solid var(--tg-rule);
color: var(--tg-ink-soft);
}
.circled-number:hover {
border-color: var(--tg-accent);
color: var(--tg-accent-deep);
}
.circled-number-diff {
background-color: var(--tg-accent-deep);
color: #fff;
}
/* ---------- homepage: century grouping + inline poet search ---------- */
.tg-century-heading {
max-width: 980px;
margin: 28px auto 4px auto;
padding: 0 8px;
font-family: 'PT Serif', serif;
font-weight: 700;
font-size: 1.05rem;
color: var(--tg-accent-deep);
text-align: start;
border-bottom: 1px solid var(--tg-rule);
padding-bottom: 6px;
}
.tg-century-nav {
max-width: 980px;
margin: 8px auto 0 auto;
padding: 0 8px;
text-align: center;
}
.tg-poet-search {
max-width: 480px;
margin: 20px auto 0 auto;
padding: 0 8px;
}
.tg-poet-search input {
width: 100%;
padding: 8px 12px;
font-family: 'PT Sans', sans-serif;
font-size: 0.95rem;
border: 1px solid var(--tg-rule);
border-radius: 6px;
background: var(--tg-surface);
color: var(--tg-ink);
}
.tg-poet-search input:focus {
outline: none;
border-color: var(--tg-accent);
}
/* search results grid only takes up space once populated by tgInlineSearch (site.js) */
.tg-poet-search .tg-poet-grid:empty {
margin: 0;
}
.tg-poet-search .tg-poet-grid {
margin-top: 14px;
}
/* century-grouped vs. alphabetical homepage view switch */
.tg-view-switch {
display: flex;
justify-content: center;
gap: 8px;
max-width: 480px;
margin: 18px auto 0 auto;
padding: 0 8px;
}
.tg-view-switch-btn {
font-family: 'PT Sans', sans-serif;
font-size: 0.9rem;
padding: 7px 16px;
border: 1px solid var(--tg-rule);
border-radius: 20px;
background: var(--tg-surface);
color: var(--tg-ink-soft);
cursor: pointer;
}
.tg-view-switch-btn:hover {
border-color: var(--tg-accent);
color: var(--tg-accent-deep);
}
.tg-view-switch-btn.active {
background: var(--tg-accent-deep);
border-color: var(--tg-accent-deep);
color: #fff;
}
/* ---------- error page ---------- */
.text-danger {
font-family: 'PT Serif', serif;
color: var(--tg-accent-deep) !important;
}
/* ---------- poem recitations (audio) ---------- */
.tg-recitations {
max-width: 560px;
margin: 28px auto 0 auto;
padding-top: 16px;
border-top: 1px solid var(--tg-rule);
text-align: start;
}
.tg-recitations-title {
font-family: 'PT Serif', serif;
font-weight: 700;
font-size: 1rem;
color: var(--tg-accent-deep);
margin-bottom: 10px;
}
.tg-recitation {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin-bottom: 8px;
background-color: var(--tg-paper-edge);
border-radius: 8px;
}
.tg-recitation audio {
max-width: 100%;
height: 34px;
}
.tg-recitation-caption {
font-family: 'PT Sans', sans-serif;
font-size: 0.88rem;
color: var(--tg-ink-soft);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tg-recitation-title {
color: var(--tg-ink);
}
.tg-recitation-download {
color: var(--tg-accent-deep);
text-decoration: none;
margin-inline-start: 4px;
}
.tg-recitation-download:hover {
opacity: 0.7;
}
@media (max-width: 480px) {
.tg-recitation audio {
width: 100%;
}
}
/* verse currently being recited, highlighted by recitation-sync.js */
.tg-verse-active {
color: var(--tg-accent-deep) !important;
background-color: var(--tg-paper-edge);
border-radius: 4px;
transition: background-color 0.2s ease;
}
/* ---------- sticky mini audio-player (recitation seek controls stay
reachable while scrolling the poem text) ---------- */
.tg-sticky-player {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: var(--tg-paper-edge);
border-top: 1px solid var(--tg-rule);
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.15);
}
.tg-sticky-player-narrator {
font-weight: 600;
white-space: nowrap;
color: var(--tg-ink);
flex-shrink: 0;
}
.tg-sticky-player-audio-slot {
flex: 1;
min-width: 0;
}
.tg-sticky-player-audio-slot audio {
width: 100%;
display: block;
}
.tg-sticky-player-btn {
display: inline-flex;
align-items: center;
cursor: pointer;
color: var(--tg-ink);
padding: 4px 6px;
flex-shrink: 0;
}
.tg-sticky-player-btn:hover {
color: var(--tg-accent);
}
@media (max-width: 600px) {
.tg-sticky-player-narrator {
display: none;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Some files were not shown because too many files have changed in this diff Show More