[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:
gamer147
2025-12-08 18:30:00 -05:00
parent c9d93a4e55
commit 81e4e88ad4
48 changed files with 3298 additions and 329 deletions

View File

@@ -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();
}
}

View File

@@ -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)