StartImportFromPublicDataRepo captured the injected, request-scoped HttpClient inside the queued work item. The request completes long before the background job runs, so the client was already disposed and the import died immediately with ObjectDisposedException. Let the job own a client for its lifetime.
331 lines
16 KiB
C#
331 lines
16 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using RMuseum.DbContext;
|
|
using RMuseum.Models.Ganjoor;
|
|
using RMuseum.Models.Ganjoor.PublicExport;
|
|
using RMuseum.Utils.PublicDataImport;
|
|
using RSecurityBackend.Models.Generic;
|
|
using RSecurityBackend.Services.Implementation;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace RMuseum.Services.Implementation
|
|
{
|
|
/// <summary>
|
|
/// IGanjoorService implementation
|
|
/// </summary>
|
|
public partial class GanjoorService : IGanjoorService
|
|
{
|
|
/// <summary>
|
|
/// Category (and root-poet) pages don't carry a real production page id in the public
|
|
/// export — GanjoorPage isn't part of that data set — so this importer mints one
|
|
/// deterministically from the category id, kept well clear of any real id range so it can
|
|
/// never collide with an actual production GanjoorPage/GanjoorPoem id. Poem pages don't
|
|
/// need this: they reuse the poem's own id, matching the convention already used by
|
|
/// _ImportSQLiteCatChildren (see GanjoorService-SQLiteImport.cs).
|
|
/// </summary>
|
|
private static int SyntheticCatPageId(int catId) => 900_000_000 + catId;
|
|
|
|
private static readonly JsonSerializerOptions _importJsonOptions = new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
/// <summary>
|
|
/// (Re)builds Ganjoor content — poets, categories, poems, verses, sections, and their
|
|
/// GanjoorPage routing entries — from a public data export tree, read either from a local
|
|
/// `git clone` or fetched over HTTP. Safe to run against an empty database (bootstrap) or
|
|
/// one that already has some content (merge): every entity is looked up by its id first
|
|
/// and only inserted if missing, so re-running never duplicates or overwrites anything —
|
|
/// including content a developer may have hand-edited locally after a previous import.
|
|
/// </summary>
|
|
/// <param name="useHttp">true: fetch over HTTP (location is a base URL). false: read from a local folder (location is a path).</param>
|
|
/// <param name="location">base URL or local folder path of the exported data tree</param>
|
|
/// <param name="poetId">0 imports every poet in the export's manifest; a specific id imports only that poet — useful on a slow connection, or when a developer only needs one poet's data for local testing</param>
|
|
public RServiceResult<bool> StartImportFromPublicDataRepo(bool useHttp, string location, int poetId = 0)
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(location))
|
|
return new RServiceResult<bool>(false, "location is required");
|
|
|
|
_backgroundTaskQueue.QueueBackgroundWorkItem
|
|
(
|
|
async token =>
|
|
{
|
|
// the injected _httpClient is request-scoped and already disposed by the
|
|
// time this queued work item runs, so the job owns its own client
|
|
using HttpClient importHttpClient = new HttpClient() { Timeout = TimeSpan.FromMinutes(5) };
|
|
using (RMuseumDbContext context = new RMuseumDbContext(new DbContextOptions<RMuseumDbContext>()))
|
|
{
|
|
LongRunningJobProgressServiceEF jobProgressServiceEF = new LongRunningJobProgressServiceEF(context);
|
|
var job = (await jobProgressServiceEF.NewJob("ImportFromPublicDataRepo", "Reading manifest")).Result;
|
|
|
|
try
|
|
{
|
|
IPublicDataSource source = useHttp
|
|
? new HttpPublicDataSource(importHttpClient, location)
|
|
: new LocalFileSystemPublicDataSource(location);
|
|
|
|
string manifestJson = await source.ReadTextAsync("manifest.json");
|
|
if (manifestJson == null)
|
|
throw new Exception($"manifest.json not found at '{location}' — check the path/URL");
|
|
|
|
var manifest = JsonSerializer.Deserialize<PublicExportManifestDto>(manifestJson, _importJsonOptions);
|
|
|
|
var poetsToImport = manifest.Poets;
|
|
if (poetId != 0)
|
|
{
|
|
poetsToImport = manifest.Poets.Where(p => p.Id == poetId).ToList();
|
|
if (poetsToImport.Count == 0)
|
|
throw new Exception($"poet id {poetId} was not found in manifest.json");
|
|
}
|
|
|
|
int poetIndex = 0;
|
|
foreach (var poetEntry in poetsToImport)
|
|
{
|
|
poetIndex++;
|
|
await jobProgressServiceEF.UpdateJob(job.Id, (int)(100.0 * poetIndex / Math.Max(1, poetsToImport.Count)), $"Importing {poetEntry.Nickname}");
|
|
await ImportPoetFromPublicData(context, source, poetEntry.Id, poetEntry.FullUrl);
|
|
}
|
|
|
|
// GanjooRazor's home page groups poets by century (GetCenturiesAsync /
|
|
// the "PoetGroups" the view renders) — that grouping is a materialized
|
|
// table (GanjorCentury/GanjoorCenturyPoet), not computed on the fly, and
|
|
// newly-imported poets aren't in it until this runs. Skipping this step
|
|
// is exactly what leaves PoetGroups empty and throws on the home page
|
|
// (see Index.cshtml.cs's IsDatabaseEffectivelyEmpty check) even though
|
|
// poets now exist.
|
|
await jobProgressServiceEF.UpdateJob(job.Id, 99, "Regenerating century groupings");
|
|
await _RegenerateHalfCenturies(context);
|
|
_memoryCache.Remove("ganjoor/centuries"); // same cache key GetCenturiesAsync/the "periods" endpoint use
|
|
|
|
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());
|
|
}
|
|
}
|
|
|
|
private async Task ImportPoetFromPublicData(RMuseumDbContext context, IPublicDataSource source, int poetId, string poetFullUrl)
|
|
{
|
|
string nickname = poetFullUrl;
|
|
|
|
if (!await context.GanjoorPoets.AnyAsync(p => p.Id == poetId))
|
|
{
|
|
string poetJson = await source.ReadTextAsync($"poets{poetFullUrl}/poet.json");
|
|
if (poetJson == null)
|
|
return; // referenced in manifest but file missing — skip rather than fail the whole run
|
|
|
|
var poetDto = JsonSerializer.Deserialize<PoetPublicDto>(poetJson, _importJsonOptions);
|
|
nickname = poetDto.Nickname;
|
|
|
|
context.GanjoorPoets.Add(new GanjoorPoet
|
|
{
|
|
Id = poetDto.Id,
|
|
Name = poetDto.Name,
|
|
Nickname = poetDto.Nickname,
|
|
Description = poetDto.Description,
|
|
Published = true,
|
|
BirthYearInLHijri = poetDto.BirthYearInLHijri,
|
|
ValidBirthDate = poetDto.ValidBirthDate,
|
|
DeathYearInLHijri = poetDto.DeathYearInLHijri,
|
|
ValidDeathDate = poetDto.ValidDeathDate,
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
else
|
|
{
|
|
nickname = (await context.GanjoorPoets.AsNoTracking().Where(p => p.Id == poetId).SingleAsync()).Nickname;
|
|
}
|
|
|
|
// the poet's root category IS the poet's landing page (e.g. /hafez) — imported the
|
|
// same way as any category, just flagged as the tree's root for page-type purposes
|
|
await ImportCatTreeFromPublicData(context, source, poetId, poetFullUrl, null, nickname, isRoot: true);
|
|
}
|
|
|
|
private async Task ImportCatTreeFromPublicData(RMuseumDbContext context, IPublicDataSource source, int poetId,
|
|
string catFullUrl, int? parentPageId, string parentFullTitle, bool isRoot)
|
|
{
|
|
string catJson = await source.ReadTextAsync($"poets{catFullUrl}/_cat.json");
|
|
if (catJson == null)
|
|
return;
|
|
|
|
var catDto = JsonSerializer.Deserialize<CatPublicDto>(catJson, _importJsonOptions);
|
|
string fullTitle = isRoot ? parentFullTitle : $"{parentFullTitle} » {catDto.Title}";
|
|
int catPageId = SyntheticCatPageId(catDto.Id);
|
|
|
|
if (!await context.GanjoorCategories.AnyAsync(c => c.Id == catDto.Id))
|
|
{
|
|
context.GanjoorCategories.Add(new GanjoorCat
|
|
{
|
|
Id = catDto.Id,
|
|
PoetId = catDto.PoetId,
|
|
ParentId = catDto.ParentId,
|
|
Title = catDto.Title,
|
|
UrlSlug = LastUrlSegment(catDto.FullUrl),
|
|
FullUrl = catDto.FullUrl,
|
|
Description = catDto.Description,
|
|
DescriptionHtml = catDto.DescriptionHtml,
|
|
BookName = catDto.BookName,
|
|
Published = true,
|
|
TableOfContentsStyle = GanjoorTOC.Analyse,
|
|
});
|
|
|
|
context.GanjoorPages.Add(new GanjoorPage
|
|
{
|
|
Id = catPageId,
|
|
GanjoorPageType = isRoot ? GanjoorPageType.PoetPage : GanjoorPageType.CatPage,
|
|
Published = true,
|
|
PageOrder = -1,
|
|
Title = catDto.Title,
|
|
FullTitle = fullTitle,
|
|
UrlSlug = LastUrlSegment(catDto.FullUrl),
|
|
FullUrl = catDto.FullUrl,
|
|
HtmlText = "",
|
|
PoetId = poetId,
|
|
CatId = catDto.Id,
|
|
PostDate = DateTime.Now,
|
|
ParentId = parentPageId,
|
|
});
|
|
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
foreach (var poemRef in catDto.Poems)
|
|
{
|
|
if (await context.GanjoorPoems.AnyAsync(p => p.Id == poemRef.Id))
|
|
continue; // already imported — never re-fetch or overwrite
|
|
|
|
await ImportPoemFromPublicData(context, source, poetId, catDto.Id, poemRef.Id, poemRef.FullUrl, catPageId, fullTitle);
|
|
}
|
|
|
|
foreach (var childRef in catDto.ChildCats)
|
|
{
|
|
await ImportCatTreeFromPublicData(context, source, poetId, childRef.FullUrl, catPageId, fullTitle, isRoot: false);
|
|
}
|
|
}
|
|
|
|
private async Task ImportPoemFromPublicData(RMuseumDbContext context, IPublicDataSource source, int poetId, int catId,
|
|
int poemId, string poemFullUrl, int parentPageId, string parentFullTitle)
|
|
{
|
|
string poemJson = await source.ReadTextAsync($"poets{poemFullUrl}.json");
|
|
if (poemJson == null)
|
|
return;
|
|
|
|
var poemDto = JsonSerializer.Deserialize<PoemPublicDto>(poemJson, _importJsonOptions);
|
|
|
|
var verses = poemDto.Verses.Select(v => new GanjoorVerse
|
|
{
|
|
PoemId = poemId,
|
|
VOrder = v.VOrder,
|
|
VersePosition = Enum.Parse<VersePosition>(v.Position),
|
|
Text = v.Text,
|
|
CoupletIndex = v.CoupletIndex,
|
|
SectionIndex1 = v.SectionIndex1,
|
|
SectionIndex2 = v.SectionIndex2,
|
|
SectionIndex3 = v.SectionIndex3,
|
|
SectionIndex4 = v.SectionIndex4,
|
|
}).ToList();
|
|
|
|
// HtmlText/PlainText aren't duplicated in the export — regenerated here with the same
|
|
// formatting helpers the app itself uses (see GanjoorService-SQLiteImport.cs), so a
|
|
// locally-imported poem renders exactly the way the current codebase renders it rather
|
|
// than however it happened to render at export time.
|
|
string htmlText = PrepareHtmlText(verses);
|
|
string plainText = PreparePlainText(verses);
|
|
|
|
var dbPoem = new GanjoorPoem
|
|
{
|
|
Id = poemId,
|
|
CatId = catId,
|
|
Title = poemDto.Title,
|
|
FullTitle = poemDto.FullTitle,
|
|
UrlSlug = LastUrlSegment(poemDto.FullUrl),
|
|
FullUrl = poemDto.FullUrl,
|
|
PlainText = plainText,
|
|
HtmlText = htmlText,
|
|
GanjoorMetreId = poemDto.Metre?.Id,
|
|
RhymeLetters = poemDto.RhymeLetters,
|
|
SourceName = poemDto.SourceName,
|
|
SourceUrlSlug = poemDto.SourceUrlSlug,
|
|
Language = poemDto.Language,
|
|
PoemSummary = poemDto.PoemSummary,
|
|
Published = true,
|
|
};
|
|
context.GanjoorPoems.Add(dbPoem);
|
|
await context.SaveChangesAsync();
|
|
|
|
foreach (var verse in verses)
|
|
{
|
|
context.GanjoorVerses.Add(verse);
|
|
}
|
|
await context.SaveChangesAsync();
|
|
|
|
foreach (var section in poemDto.Sections)
|
|
{
|
|
context.GanjoorPoemSections.Add(new GanjoorPoemSection
|
|
{
|
|
PoemId = poemId,
|
|
PoetId = poetId,
|
|
Index = section.Index,
|
|
Number = section.Number,
|
|
SectionType = Enum.Parse<PoemSectionType>(section.SectionType),
|
|
VerseType = Enum.Parse<VersePoemSectionType>(section.VerseType),
|
|
GanjoorMetreId = poemDto.Metre?.Id,
|
|
RhymeLetters = section.RhymeLetters,
|
|
PlainText = section.PlainText,
|
|
HtmlText = section.HtmlText,
|
|
PoemFormat = string.IsNullOrEmpty(section.PoemFormat) ? (GanjoorPoemFormat?)null : Enum.Parse<GanjoorPoemFormat>(section.PoemFormat),
|
|
Language = section.Language,
|
|
CoupletsCount = section.CoupletsCount,
|
|
});
|
|
}
|
|
await context.SaveChangesAsync();
|
|
|
|
context.GanjoorPages.Add(new GanjoorPage
|
|
{
|
|
// matches production convention: a poem's page id equals the poem's own id
|
|
// (see GanjoorService-SQLiteImport.cs, dbPoemPage.Id = poemId)
|
|
Id = poemId,
|
|
GanjoorPageType = GanjoorPageType.PoemPage,
|
|
Published = true,
|
|
PageOrder = -1,
|
|
Title = dbPoem.Title,
|
|
FullTitle = dbPoem.FullTitle,
|
|
UrlSlug = dbPoem.UrlSlug,
|
|
FullUrl = dbPoem.FullUrl,
|
|
HtmlText = dbPoem.HtmlText,
|
|
PoetId = poetId,
|
|
CatId = catId,
|
|
PoemId = poemId,
|
|
PostDate = DateTime.Now,
|
|
ParentId = parentPageId,
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private static string LastUrlSegment(string fullUrl)
|
|
{
|
|
if (string.IsNullOrEmpty(fullUrl)) return fullUrl;
|
|
string trimmed = fullUrl.TrimEnd('/');
|
|
int idx = trimmed.LastIndexOf('/');
|
|
return idx == -1 ? trimmed : trimmed.Substring(idx + 1);
|
|
}
|
|
}
|
|
}
|