diff --git a/Epub/KoeBook.Epub/Services/AiStoryAnalyzerService.cs b/Epub/KoeBook.Epub/Services/AiStoryAnalyzerService.cs new file mode 100644 index 0000000..262a26d --- /dev/null +++ b/Epub/KoeBook.Epub/Services/AiStoryAnalyzerService.cs @@ -0,0 +1,34 @@ +using KoeBook.Epub.Contracts.Services; +using KoeBook.Epub.Models; +using KoeBook.Models; + +namespace KoeBook.Epub.Services; + +public partial class AiStoryAnalyzerService(ISplitBraceService splitBraceService) +{ + private readonly ISplitBraceService _splitBraceService = splitBraceService; + + + public EpubDocument CreateEpubDocument(AiStory aiStory, Guid id) + { + int sectionNumber = 1; + return new EpubDocument(aiStory.Title, "AI", "", id) + { + Chapters = [new Chapter() + { + Sections = aiStory.Sections.Select(s => new Section($"第{sectionNumber++}章") + { + Elements = s.Paragraphs.SelectMany(p => + _splitBraceService.SplitBrace(p.GetText()) + .Zip(_splitBraceService.SplitBrace(p.GetScript())) + .Select(Element (p) => new Paragraph + { + Text = p.First, + ScriptLine = new(p.Second, "", "") + }) + ).ToList(), + }).ToList(), + }] + }; + } +} diff --git a/Epub/KoeBook.Epub/Services/AnalyzerService.cs b/Epub/KoeBook.Epub/Services/AnalyzerService.cs index b65da6a..85ef87e 100644 --- a/Epub/KoeBook.Epub/Services/AnalyzerService.cs +++ b/Epub/KoeBook.Epub/Services/AnalyzerService.cs @@ -1,18 +1,25 @@ -using System.Text; +using System.Diagnostics; +using System.Text; using System.Text.RegularExpressions; using KoeBook.Core; using KoeBook.Core.Contracts.Services; using KoeBook.Core.Models; using KoeBook.Epub.Contracts.Services; using KoeBook.Epub.Models; +using KoeBook.Models; namespace KoeBook.Epub.Services; -public partial class AnalyzerService(IScraperSelectorService scrapingService, IEpubDocumentStoreService epubDocumentStoreService, ILlmAnalyzerService llmAnalyzerService) : IAnalyzerService +public partial class AnalyzerService( + IScraperSelectorService scrapingService, + IEpubDocumentStoreService epubDocumentStoreService, + ILlmAnalyzerService llmAnalyzerService, + AiStoryAnalyzerService aiStoryAnalyzerService) : IAnalyzerService { private readonly IScraperSelectorService _scrapingService = scrapingService; private readonly IEpubDocumentStoreService _epubDocumentStoreService = epubDocumentStoreService; private readonly ILlmAnalyzerService _llmAnalyzerService = llmAnalyzerService; + private readonly AiStoryAnalyzerService _aiStoryAnalyzerService = aiStoryAnalyzerService; public async ValueTask AnalyzeAsync(BookProperties bookProperties, string tempDirectory, CancellationToken cancellationToken) { @@ -22,26 +29,36 @@ public async ValueTask AnalyzeAsync(BookProperties bookProperties, await fs.WriteAsync(CoverFile.ToArray(), cancellationToken); await fs.FlushAsync(cancellationToken); - EpubDocument? document; + var rubyReplaced = false; + EpubDocument document; try { - document = await _scrapingService.ScrapingAsync(bookProperties.Source, coverFilePath, tempDirectory, bookProperties.Id, cancellationToken); - } - catch (EbookException) - { - throw; + switch (bookProperties) + { + case { SourceType: SourceType.Url or SourceType.FilePath, Source: string uri }: + document = await _scrapingService.ScrapingAsync(uri, coverFilePath, tempDirectory, bookProperties.Id, cancellationToken); + break; + case { SourceType: SourceType.AiStory, Source: AiStory aiStory }: + document = _aiStoryAnalyzerService.CreateEpubDocument(aiStory, bookProperties.Id); + rubyReplaced = true; + break; + default: + throw new UnreachableException($"SourceType: {bookProperties.SourceType}, Source: {bookProperties.Source}"); + } } + catch (EbookException) { throw; } catch (Exception ex) { - EbookException.Throw(ExceptionType.WebScrapingFailed, innerException: ex); - return default; + throw new EbookException(ExceptionType.WebScrapingFailed, innerException: ex); } _epubDocumentStoreService.Register(document, cancellationToken); var scriptLines = document.Chapters.SelectMany(c => c.Sections) .SelectMany(s => s.Elements) .OfType() - .Select(p => + .Select(rubyReplaced + ? p => p.ScriptLine! + : p => { // ルビを置換 var line = ReplaceBaseTextWithRuby(p.Text); diff --git a/KoeBook.Core/EbookException.cs b/KoeBook.Core/EbookException.cs index a0e7647..bd88010 100644 --- a/KoeBook.Core/EbookException.cs +++ b/KoeBook.Core/EbookException.cs @@ -62,4 +62,7 @@ public enum ExceptionType /// [EnumMember(Value = "無効なURLです")] InvalidUrl, + + [EnumMember(Value = "不正なXMLです")] + InvalidXml, } diff --git a/KoeBook.Core/Models/AiStory.cs b/KoeBook.Core/Models/AiStory.cs new file mode 100644 index 0000000..f48324d --- /dev/null +++ b/KoeBook.Core/Models/AiStory.cs @@ -0,0 +1,52 @@ +using System.Xml.Serialization; + +namespace KoeBook.Models; + +[XmlRoot("Book")] +public record AiStory( + [property: XmlElement("Title", typeof(string), IsNullable = false)] string Title, + [property: XmlArray("Content", IsNullable = false), XmlArrayItem("Section", IsNullable = false)] AiStory.Section[] Sections) +{ + private AiStory() : this("", []) { } + + public record Section( + [property: XmlArrayItem("Paragraph", IsNullable = false)] Paragraph[] Paragraphs) + { + private Section() : this([]) { } + } + + + public record Paragraph( + [property: XmlElement("Text", typeof(TextElement), IsNullable = false), XmlElement("Ruby", typeof(Ruby), IsNullable = false)] InlineElement[] Inlines) + { + private Paragraph() : this([]) { } + + public string GetText() => string.Concat(Inlines.Select(e => e.Text)); + + public string GetScript() => string.Concat(Inlines.Select(e => e.Script)); + } + + public abstract record class InlineElement + { + public abstract string Text { get; } + public abstract string Script { get; } + } + + public record TextElement([property: XmlText] string InnerText) : InlineElement + { + private TextElement() : this("") { } + + public override string Text => InnerText; + public override string Script => InnerText; + } + + public record Ruby( + [property: XmlElement("Rb", IsNullable = false)] string Rb, + [property: XmlElement("Rt", IsNullable = false)] string Rt) : InlineElement + { + private Ruby() : this("", "") { } + + public override string Text => Rb; + public override string Script => Rt; + } +} diff --git a/KoeBook.Core/Models/BookProperties.cs b/KoeBook.Core/Models/BookProperties.cs index ebe3618..f2ac50e 100644 --- a/KoeBook.Core/Models/BookProperties.cs +++ b/KoeBook.Core/Models/BookProperties.cs @@ -1,13 +1,34 @@ -namespace KoeBook.Core.Models; +using KoeBook.Models; + +namespace KoeBook.Core.Models; /// /// 読み上げる本の情報 /// -public class BookProperties(Guid id, string source, SourceType sourceType) +public class BookProperties { - public Guid Id { get; } = id; + public BookProperties(Guid id, string source, SourceType sourceType) + { + if (sourceType != SourceType.FilePath && sourceType != SourceType.Url) + throw new ArgumentException($"{nameof(sourceType)}は{nameof(SourceType.FilePath)}か{nameof(SourceType.Url)}である必要があります。"); + Id = id; + Source = source; + SourceType = sourceType; + } + + public BookProperties(Guid id, AiStory aiStory) + { + Id = id; + Source = aiStory; + SourceType = SourceType.AiStory; + } + + public Guid Id { get; } - public string Source { get; } = source; + /// + /// UriまたはAiStory + /// + public object Source { get; } - public SourceType SourceType { get; } = sourceType; + public SourceType SourceType { get; } } diff --git a/KoeBook.Core/Models/SourceType.cs b/KoeBook.Core/Models/SourceType.cs index bc59068..5af40a0 100644 --- a/KoeBook.Core/Models/SourceType.cs +++ b/KoeBook.Core/Models/SourceType.cs @@ -9,4 +9,7 @@ public enum SourceType [EnumMember(Value = "ローカルファイル")] FilePath, + + [EnumMember(Value = "AI生成")] + AiStory, } diff --git a/KoeBook/Models/GenerationTask.cs b/KoeBook/Models/GenerationTask.cs index 24dd4f7..33e044d 100644 --- a/KoeBook/Models/GenerationTask.cs +++ b/KoeBook/Models/GenerationTask.cs @@ -4,27 +4,57 @@ namespace KoeBook.Models; -public partial class GenerationTask(Guid id, string source, SourceType sourceType, bool skipEdit) : ObservableObject +public partial class GenerationTask : ObservableObject { - public Guid Id { get; } = id; + public GenerationTask(Guid id, string source, SourceType sourceType, bool skipEdit) + { + if (sourceType != SourceType.FilePath && sourceType != SourceType.Url) + throw new ArgumentException($"{nameof(sourceType)}は{nameof(SourceType.FilePath)}か{nameof(SourceType.Url)}である必要があります。"); + Id = id; + _rawSource = source; + SourceType = sourceType; + _skipEdit = skipEdit; + _title = sourceType == SourceType.FilePath ? Path.GetFileName(source) : source; + } + + public GenerationTask(Guid id, AiStory aiStory, bool skipEdit) + { + Id = id; + _rawSource = aiStory; + SourceType = SourceType.AiStory; + _skipEdit = skipEdit; + _title = aiStory.Title; + } + + public BookProperties ToBookProperties() + { + return SourceType == SourceType.AiStory + ? new BookProperties(Id, (AiStory)_rawSource) + : new BookProperties(Id, Source, SourceType); + } + + public Guid Id { get; } public CancellationTokenSource CancellationTokenSource { get; } = new(); public CancellationToken CancellationToken => CancellationTokenSource.Token; - public string Source { get; } = source; + public string Source => _rawSource is string uri ? uri : "AI生成"; + + private readonly object _rawSource; - public SourceType SourceType { get; } = sourceType; + public SourceType SourceType { get; } public string SourceDescription => SourceType switch { SourceType.Url => "URL", SourceType.FilePath => "ファイルパス", + SourceType.AiStory => "AI生成", _ => string.Empty, }; [ObservableProperty] - private string _title = sourceType == SourceType.FilePath ? Path.GetFileName(source) : source; + private string _title; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ProgressText))] @@ -36,7 +66,7 @@ public partial class GenerationTask(Guid id, string source, SourceType sourceTyp [ObservableProperty] [NotifyPropertyChangedFor(nameof(StateText))] - [NotifyPropertyChangedFor(nameof(SkipEditChangable))] + [NotifyPropertyChangedFor(nameof(SkipEditChangeable))] [NotifyPropertyChangedFor(nameof(Editable))] private GenerationState _state; @@ -49,7 +79,7 @@ public bool SkipEdit get => _skipEdit; set { - if (_skipEdit != value && SkipEditChangable) + if (_skipEdit != value && SkipEditChangeable) { OnPropertyChanging(nameof(SkipEdit)); _skipEdit = value; @@ -57,9 +87,9 @@ public bool SkipEdit } } } - private bool _skipEdit = skipEdit; + private bool _skipEdit; - public bool SkipEditChangable => State < GenerationState.Editting; + public bool SkipEditChangeable => State < GenerationState.Editting; public bool Editable => State == GenerationState.Editting; diff --git a/KoeBook/Services/GenerationTaskRunnerService.cs b/KoeBook/Services/GenerationTaskRunnerService.cs index 68fddbe..d728437 100644 --- a/KoeBook/Services/GenerationTaskRunnerService.cs +++ b/KoeBook/Services/GenerationTaskRunnerService.cs @@ -71,7 +71,7 @@ private async ValueTask RunAsyncCore(GenerationTask task, bool firstStep) { if (firstStep) { - var scripts = await _analyzerService.AnalyzeAsync(new(task.Id, task.Source, task.SourceType), tempDirectory, task.CancellationToken); + var scripts = await _analyzerService.AnalyzeAsync(task.ToBookProperties(), tempDirectory, task.CancellationToken); task.BookScripts = scripts; task.State = GenerationState.Editting; task.Progress = 0; diff --git a/KoeBook/Startup.cs b/KoeBook/Startup.cs index 1a546b5..d19c6a9 100644 --- a/KoeBook/Startup.cs +++ b/KoeBook/Startup.cs @@ -42,7 +42,8 @@ public static IHostBuilder UseCoreStartup(this IHostBuilder builder) .AddKeyedSingleton(nameof(ScrapingNaroService)) .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/KoeBook/Views/EditDetailsTab.xaml b/KoeBook/Views/EditDetailsTab.xaml index 970837a..5c7ee2b 100644 --- a/KoeBook/Views/EditDetailsTab.xaml +++ b/KoeBook/Views/EditDetailsTab.xaml @@ -61,7 +61,7 @@ Margin="{StaticResource XXSmallLeftTopRightBottomMargin}" OffContent="編集する" OnContent="編集しない" - IsEnabled="{x:Bind ViewModel.Task.SkipEditChangable, Mode=OneWay}" + IsEnabled="{x:Bind ViewModel.Task.SkipEditChangeable, Mode=OneWay}" IsOn="{x:Bind ViewModel.Task.SkipEdit, Mode=TwoWay}"/>