[FA-misc] Switches to using DTOs, updates frontend with details and reader page, updates novel import to be an upsert
This commit is contained in:
@@ -10,10 +10,20 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
|
||||
: FictionArchiveDbContext(options, logger)
|
||||
{
|
||||
public DbSet<Novel> Novels { get; set; }
|
||||
public DbSet<Chapter> Chapters { get; set; }
|
||||
public DbSet<Source> Sources { get; set; }
|
||||
public DbSet<TranslationEngine> TranslationEngines { get; set; }
|
||||
public DbSet<NovelTag> Tags { get; set; }
|
||||
public DbSet<LocalizationKey> LocalizationKeys { get; set; }
|
||||
public DbSet<LocalizationRequest> LocalizationRequests { get; set; }
|
||||
public DbSet<Image> Images { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Novel>()
|
||||
.HasIndex("ExternalId", "SourceId")
|
||||
.IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using FictionArchive.Common.Enums;
|
||||
using FictionArchive.Service.FileService.IntegrationEvents;
|
||||
using FictionArchive.Service.NovelService.Models.Configuration;
|
||||
using FictionArchive.Service.NovelService.Models.Enums;
|
||||
@@ -31,14 +32,241 @@ public class NovelUpdateService
|
||||
_novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value;
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task<Source> GetOrCreateSource(SourceDescriptor descriptor)
|
||||
{
|
||||
var existingSource = await _dbContext.Sources
|
||||
.FirstOrDefaultAsync(s => s.Key == descriptor.Key);
|
||||
|
||||
if (existingSource != null)
|
||||
{
|
||||
return existingSource;
|
||||
}
|
||||
|
||||
return new Source
|
||||
{
|
||||
Name = descriptor.Name,
|
||||
Url = descriptor.Url,
|
||||
Key = descriptor.Key
|
||||
};
|
||||
}
|
||||
|
||||
private Person GetOrCreateAuthor(
|
||||
string authorName,
|
||||
string? authorUrl,
|
||||
Language rawLanguage,
|
||||
Person? existingAuthor)
|
||||
{
|
||||
// Case 1: No existing author - create new
|
||||
if (existingAuthor == null)
|
||||
{
|
||||
return new Person
|
||||
{
|
||||
Name = LocalizationKey.CreateFromText(authorName, rawLanguage),
|
||||
ExternalUrl = authorUrl
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: ExternalUrl differs - create new Person
|
||||
if (existingAuthor.ExternalUrl != authorUrl)
|
||||
{
|
||||
return new Person
|
||||
{
|
||||
Name = LocalizationKey.CreateFromText(authorName, rawLanguage),
|
||||
ExternalUrl = authorUrl
|
||||
};
|
||||
}
|
||||
|
||||
// Case 3: Same URL - update name if different
|
||||
UpdateLocalizationKey(existingAuthor.Name, authorName, rawLanguage);
|
||||
return existingAuthor;
|
||||
}
|
||||
|
||||
private static void UpdateLocalizationKey(LocalizationKey key, string newText, Language language)
|
||||
{
|
||||
var existingText = key.Texts.FirstOrDefault(t => t.Language == language);
|
||||
if (existingText != null)
|
||||
{
|
||||
existingText.Text = newText;
|
||||
}
|
||||
else
|
||||
{
|
||||
key.Texts.Add(new LocalizationText
|
||||
{
|
||||
Language = language,
|
||||
Text = newText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNovelMetadata(Novel novel, NovelMetadata metadata)
|
||||
{
|
||||
UpdateLocalizationKey(novel.Name, metadata.Name, metadata.RawLanguage);
|
||||
UpdateLocalizationKey(novel.Description, metadata.Description, metadata.RawLanguage);
|
||||
novel.RawStatus = metadata.RawStatus;
|
||||
novel.Url = metadata.Url;
|
||||
}
|
||||
|
||||
private async Task<List<NovelTag>> SynchronizeTags(
|
||||
List<string> sourceTags,
|
||||
List<string> systemTags,
|
||||
Language rawLanguage)
|
||||
{
|
||||
var allTagKeys = sourceTags.Concat(systemTags).ToHashSet();
|
||||
|
||||
// Query existing tags from DB by Key
|
||||
var existingTagsInDb = await _dbContext.Tags
|
||||
.Where(t => allTagKeys.Contains(t.Key))
|
||||
.ToListAsync();
|
||||
|
||||
var existingTagKeyMap = existingTagsInDb.ToDictionary(t => t.Key);
|
||||
var result = new List<NovelTag>();
|
||||
|
||||
// Process source tags
|
||||
foreach (var tagKey in sourceTags)
|
||||
{
|
||||
if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag))
|
||||
{
|
||||
result.Add(existingTag);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(new NovelTag
|
||||
{
|
||||
Key = tagKey,
|
||||
DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage),
|
||||
TagType = TagType.External
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process system tags
|
||||
foreach (var tagKey in systemTags)
|
||||
{
|
||||
if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag))
|
||||
{
|
||||
result.Add(existingTag);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(new NovelTag
|
||||
{
|
||||
Key = tagKey,
|
||||
DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage),
|
||||
TagType = TagType.System
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Chapter> SynchronizeChapters(
|
||||
List<ChapterMetadata> metadataChapters,
|
||||
Language rawLanguage,
|
||||
List<Chapter>? existingChapters)
|
||||
{
|
||||
existingChapters ??= new List<Chapter>();
|
||||
var existingOrderSet = existingChapters.Select(c => c.Order).ToHashSet();
|
||||
|
||||
// Only add chapters that don't already exist (by Order)
|
||||
var newChapters = metadataChapters
|
||||
.Where(mc => !existingOrderSet.Contains(mc.Order))
|
||||
.Select(mc => new Chapter
|
||||
{
|
||||
Order = mc.Order,
|
||||
Url = mc.Url,
|
||||
Revision = mc.Revision,
|
||||
Name = LocalizationKey.CreateFromText(mc.Name, rawLanguage),
|
||||
Body = new LocalizationKey
|
||||
{
|
||||
Texts = new List<LocalizationText>()
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Combine existing chapters with new ones
|
||||
return existingChapters.Concat(newChapters).ToList();
|
||||
}
|
||||
|
||||
private static (Image? image, bool shouldPublishEvent) HandleCoverImage(
|
||||
ImageData? newCoverData,
|
||||
Image? existingCoverImage)
|
||||
{
|
||||
// Case 1: No new cover image - keep existing or null
|
||||
if (newCoverData == null)
|
||||
{
|
||||
return (existingCoverImage, false);
|
||||
}
|
||||
|
||||
// Case 2: New cover, no existing
|
||||
if (existingCoverImage == null)
|
||||
{
|
||||
var newImage = new Image { OriginalPath = newCoverData.Url };
|
||||
return (newImage, true);
|
||||
}
|
||||
|
||||
// Case 3: Both exist - check if URL changed
|
||||
if (existingCoverImage.OriginalPath != newCoverData.Url)
|
||||
{
|
||||
existingCoverImage.OriginalPath = newCoverData.Url;
|
||||
existingCoverImage.NewPath = null; // Reset uploaded path
|
||||
return (existingCoverImage, true);
|
||||
}
|
||||
|
||||
// Case 4: Same cover URL - no change needed
|
||||
return (existingCoverImage, false);
|
||||
}
|
||||
|
||||
private async Task<Novel> CreateNewNovel(NovelMetadata metadata, Source source)
|
||||
{
|
||||
var author = new Person
|
||||
{
|
||||
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
|
||||
ExternalUrl = metadata.AuthorUrl
|
||||
};
|
||||
|
||||
var tags = await SynchronizeTags(
|
||||
metadata.SourceTags,
|
||||
metadata.SystemTags,
|
||||
metadata.RawLanguage);
|
||||
|
||||
var chapters = SynchronizeChapters(metadata.Chapters, metadata.RawLanguage, null);
|
||||
|
||||
var novel = new Novel
|
||||
{
|
||||
Author = author,
|
||||
RawLanguage = metadata.RawLanguage,
|
||||
Url = metadata.Url,
|
||||
ExternalId = metadata.ExternalId,
|
||||
CoverImage = metadata.CoverImage != null
|
||||
? new Image { OriginalPath = metadata.CoverImage.Url }
|
||||
: null,
|
||||
Chapters = chapters,
|
||||
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
|
||||
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
|
||||
RawStatus = metadata.RawStatus,
|
||||
Tags = tags,
|
||||
Source = source
|
||||
};
|
||||
|
||||
_dbContext.Novels.Add(novel);
|
||||
return novel;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public async Task<Novel> ImportNovel(string novelUrl)
|
||||
{
|
||||
// Step 1: Get metadata from source adapter
|
||||
NovelMetadata? metadata = null;
|
||||
foreach (ISourceAdapter sourceAdapter in _sourceAdapters)
|
||||
{
|
||||
if (await sourceAdapter.CanProcessNovel(novelUrl))
|
||||
{
|
||||
metadata = await sourceAdapter.GetMetadata(novelUrl);
|
||||
break; // Stop after finding adapter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,72 +275,82 @@ public class NovelUpdateService
|
||||
throw new NotSupportedException("The provided novel url is currently unsupported.");
|
||||
}
|
||||
|
||||
var systemTags = metadata.SystemTags.Select(tag => new NovelTag()
|
||||
{
|
||||
Key = tag,
|
||||
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
|
||||
TagType = TagType.System
|
||||
});
|
||||
var sourceTags = metadata.SourceTags.Select(tag => new NovelTag()
|
||||
{
|
||||
Key = tag,
|
||||
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
|
||||
TagType = TagType.External
|
||||
});
|
||||
// Step 2: Resolve or create Source
|
||||
var source = await GetOrCreateSource(metadata.SourceDescriptor);
|
||||
|
||||
var addedNovel = _dbContext.Novels.Add(new Novel()
|
||||
// Step 3: Check for existing novel by ExternalId + Source.Key
|
||||
var existingNovel = await _dbContext.Novels
|
||||
.Include(n => n.Author)
|
||||
.ThenInclude(a => a.Name)
|
||||
.ThenInclude(lk => lk.Texts)
|
||||
.Include(n => n.Source)
|
||||
.Include(n => n.Name)
|
||||
.ThenInclude(lk => lk.Texts)
|
||||
.Include(n => n.Description)
|
||||
.ThenInclude(lk => lk.Texts)
|
||||
.Include(n => n.Tags)
|
||||
.Include(n => n.Chapters)
|
||||
.Include(n => n.CoverImage)
|
||||
.FirstOrDefaultAsync(n =>
|
||||
n.ExternalId == metadata.ExternalId &&
|
||||
n.Source.Key == metadata.SourceDescriptor.Key);
|
||||
|
||||
Novel novel;
|
||||
bool shouldPublishCoverEvent;
|
||||
|
||||
if (existingNovel == null)
|
||||
{
|
||||
Author = new Person()
|
||||
{
|
||||
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
|
||||
ExternalUrl = metadata.AuthorUrl,
|
||||
},
|
||||
RawLanguage = metadata.RawLanguage,
|
||||
Url = metadata.Url,
|
||||
ExternalId = metadata.ExternalId,
|
||||
CoverImage = metadata.CoverImage != null ? new Image()
|
||||
{
|
||||
OriginalPath = metadata.CoverImage.Url,
|
||||
} : null,
|
||||
Chapters = metadata.Chapters.Select(chapter =>
|
||||
{
|
||||
return new Chapter()
|
||||
{
|
||||
Order = chapter.Order,
|
||||
Url = chapter.Url,
|
||||
Revision = chapter.Revision,
|
||||
Name = LocalizationKey.CreateFromText(chapter.Name, metadata.RawLanguage),
|
||||
Body = new LocalizationKey()
|
||||
{
|
||||
Texts = new List<LocalizationText>()
|
||||
}
|
||||
};
|
||||
}).ToList(),
|
||||
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
|
||||
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
|
||||
RawStatus = metadata.RawStatus,
|
||||
Tags = sourceTags.Concat(systemTags).ToList(),
|
||||
Source = new Source()
|
||||
{
|
||||
Name = metadata.SourceDescriptor.Name,
|
||||
Url = metadata.SourceDescriptor.Url,
|
||||
Key = metadata.SourceDescriptor.Key,
|
||||
}
|
||||
});
|
||||
// CREATE PATH: New novel
|
||||
novel = await CreateNewNovel(metadata, source);
|
||||
shouldPublishCoverEvent = novel.CoverImage != null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// UPDATE PATH: Existing novel
|
||||
novel = existingNovel;
|
||||
|
||||
// Update author
|
||||
novel.Author = GetOrCreateAuthor(
|
||||
metadata.AuthorName,
|
||||
metadata.AuthorUrl,
|
||||
metadata.RawLanguage,
|
||||
existingNovel.Author);
|
||||
|
||||
// Update metadata (Name, Description, RawStatus)
|
||||
UpdateNovelMetadata(novel, metadata);
|
||||
|
||||
// Synchronize tags (remove old, add new, reuse existing)
|
||||
novel.Tags = await SynchronizeTags(
|
||||
metadata.SourceTags,
|
||||
metadata.SystemTags,
|
||||
metadata.RawLanguage);
|
||||
|
||||
// Synchronize chapters (add only)
|
||||
novel.Chapters = SynchronizeChapters(
|
||||
metadata.Chapters,
|
||||
metadata.RawLanguage,
|
||||
existingNovel.Chapters);
|
||||
|
||||
// Handle cover image
|
||||
(novel.CoverImage, shouldPublishCoverEvent) = HandleCoverImage(
|
||||
metadata.CoverImage,
|
||||
existingNovel.CoverImage);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Signal request for cover image if present
|
||||
if (addedNovel.Entity.CoverImage != null)
|
||||
|
||||
// Publish cover image event if needed
|
||||
if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null)
|
||||
{
|
||||
await _eventBus.Publish(new FileUploadRequestCreatedEvent()
|
||||
await _eventBus.Publish(new FileUploadRequestCreatedEvent
|
||||
{
|
||||
RequestId = addedNovel.Entity.CoverImage.Id,
|
||||
RequestId = novel.CoverImage.Id,
|
||||
FileData = metadata.CoverImage.Data,
|
||||
FilePath = $"Novels/{addedNovel.Entity.Id}/Images/cover.jpg"
|
||||
FilePath = $"Novels/{novel.Id}/Images/cover.jpg"
|
||||
});
|
||||
}
|
||||
|
||||
return addedNovel.Entity;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
public async Task<Chapter> PullChapterContents(uint novelId, uint chapterNumber)
|
||||
|
||||
Reference in New Issue
Block a user