From fda4c01c226e14c33208cfabc7356616e9af9ea6 Mon Sep 17 00:00:00 2001 From: jackfiled Date: Mon, 24 Mar 2025 22:38:18 +0800 Subject: [PATCH] feat: compress command. --- YaeBlog/Abstraction/IEssayScanService.cs | 2 - .../Binders/ImageCompressServiceBinder.cs | 21 ++ YaeBlog/Commands/YaeBlogCommand.cs | 80 +++++-- YaeBlog/Models/BlogContent.cs | 24 +- YaeBlog/Models/BlogContents.cs | 13 +- YaeBlog/Models/BlogImageInfo.cs | 44 ++++ YaeBlog/Models/ImageScanResult.cs | 3 - YaeBlog/Services/EssayScanService.cs | 214 ++++++++++-------- YaeBlog/Services/ImageCompressService.cs | 108 +++++++++ YaeBlog/Services/RendererService.cs | 18 +- YaeBlog/YaeBlog.csproj | 2 + 11 files changed, 383 insertions(+), 146 deletions(-) create mode 100644 YaeBlog/Commands/Binders/ImageCompressServiceBinder.cs create mode 100644 YaeBlog/Models/BlogImageInfo.cs delete mode 100644 YaeBlog/Models/ImageScanResult.cs create mode 100644 YaeBlog/Services/ImageCompressService.cs diff --git a/YaeBlog/Abstraction/IEssayScanService.cs b/YaeBlog/Abstraction/IEssayScanService.cs index f5aad84..d0d2f5e 100644 --- a/YaeBlog/Abstraction/IEssayScanService.cs +++ b/YaeBlog/Abstraction/IEssayScanService.cs @@ -7,6 +7,4 @@ public interface IEssayScanService public Task ScanContents(); public Task SaveBlogContent(BlogContent content, bool isDraft = true); - - public Task ScanImages(); } diff --git a/YaeBlog/Commands/Binders/ImageCompressServiceBinder.cs b/YaeBlog/Commands/Binders/ImageCompressServiceBinder.cs new file mode 100644 index 0000000..95d9f51 --- /dev/null +++ b/YaeBlog/Commands/Binders/ImageCompressServiceBinder.cs @@ -0,0 +1,21 @@ +using System.CommandLine.Binding; +using YaeBlog.Abstraction; +using YaeBlog.Services; + +namespace YaeBlog.Commands.Binders; + +public sealed class ImageCompressServiceBinder : BinderBase +{ + protected override ImageCompressService GetBoundValue(BindingContext bindingContext) + { + bindingContext.AddService(provider => + { + IEssayScanService essayScanService = provider.GetRequiredService(); + ILogger logger = provider.GetRequiredService>(); + + return new ImageCompressService(essayScanService, logger); + }); + + return bindingContext.GetRequiredService(); + } +} diff --git a/YaeBlog/Commands/YaeBlogCommand.cs b/YaeBlog/Commands/YaeBlogCommand.cs index 94815c5..2f1d04e 100644 --- a/YaeBlog/Commands/YaeBlogCommand.cs +++ b/YaeBlog/Commands/YaeBlogCommand.cs @@ -1,4 +1,6 @@ using System.CommandLine; +using Microsoft.Extensions.Options; +using YaeBlog.Abstraction; using YaeBlog.Commands.Binders; using YaeBlog.Components; using YaeBlog.Extensions; @@ -19,6 +21,7 @@ public sealed class YaeBlogCommand AddNewCommand(_rootCommand); AddPublishCommand(_rootCommand); AddScanCommand(_rootCommand); + AddCompressCommand(_rootCommand); } public Task RunAsync(string[] args) @@ -94,22 +97,20 @@ public sealed class YaeBlogCommand Argument filenameArgument = new(name: "blog name", description: "The created blog filename."); newCommand.AddArgument(filenameArgument); - newCommand.SetHandler(async (file, _, _, essayScanService) => + newCommand.SetHandler(async (file, blogOption, _, essayScanService) => { BlogContents contents = await essayScanService.ScanContents(); - if (contents.Posts.Any(content => content.FileName == file)) + if (contents.Posts.Any(content => content.BlogName == file)) { Console.WriteLine("There exists the same title blog in posts."); return; } - await essayScanService.SaveBlogContent(new BlogContent - { - FileName = file, - FileContent = string.Empty, - Metadata = new MarkdownMetadata { Title = file, Date = DateTime.Now } - }); + await essayScanService.SaveBlogContent(new BlogContent( + new FileInfo(Path.Combine(blogOption.Value.Root, "drafts", file + ".md")), + new MarkdownMetadata { Title = file, Date = DateTime.Now }, + string.Empty, true, [], [])); Console.WriteLine($"Created new blog '{file}."); }, filenameArgument, new BlogOptionsBinder(), new LoggerBinder(), @@ -126,15 +127,15 @@ public sealed class YaeBlogCommand BlogContents contents = await essyScanService.ScanContents(); Console.WriteLine($"All {contents.Posts.Count} Posts:"); - foreach (BlogContent content in contents.Posts.OrderBy(x => x.FileName)) + foreach (BlogContent content in contents.Posts.OrderBy(x => x.BlogName)) { - Console.WriteLine($" - {content.FileName}"); + Console.WriteLine($" - {content.BlogName}"); } Console.WriteLine($"All {contents.Drafts.Count} Drafts:"); - foreach (BlogContent content in contents.Drafts.OrderBy(x => x.FileName)) + foreach (BlogContent content in contents.Drafts.OrderBy(x => x.BlogName)) { - Console.WriteLine($" - {content.FileName}"); + Console.WriteLine($" - {content.BlogName}"); } }, new BlogOptionsBinder(), new LoggerBinder(), new EssayScanServiceBinder()); } @@ -150,32 +151,39 @@ public sealed class YaeBlogCommand command.SetHandler(async (_, _, essayScanService, removeOptionValue) => { - ImageScanResult result = await essayScanService.ScanImages(); + BlogContents contents = await essayScanService.ScanContents(); + List unusedImages = (from content in contents + from image in content.Images + where image is { IsUsed: false } + select image).ToList(); - if (result.UnusedImages.Count != 0) + if (unusedImages.Count != 0) { Console.WriteLine("Found unused images:"); Console.WriteLine("HINT: use '--rm' to remove unused images."); } - foreach (FileInfo image in result.UnusedImages) + foreach (BlogImageInfo image in unusedImages) { - Console.WriteLine($" - {image.FullName}"); + Console.WriteLine($" - {image.File.FullName}"); } if (removeOptionValue) { - foreach (FileInfo image in result.UnusedImages) + foreach (BlogImageInfo image in unusedImages) { - image.Delete(); + image.File.Delete(); } } Console.WriteLine("Used not existed images:"); - foreach (FileInfo image in result.NotFoundImages) + foreach (BlogContent content in contents) { - Console.WriteLine($" - {image.FullName}"); + foreach (FileInfo file in content.NotfoundImages) + { + Console.WriteLine($"- {file.Name} in {content.BlogName}"); + } } }, new BlogOptionsBinder(), new LoggerBinder(), new EssayScanServiceBinder(), removeOption); } @@ -193,7 +201,7 @@ public sealed class YaeBlogCommand BlogContents contents = await essayScanService.ScanContents(); BlogContent? content = (from blog in contents.Drafts - where blog.FileName == filename + where blog.BlogName == filename select blog).FirstOrDefault(); if (content is null) @@ -202,14 +210,17 @@ public sealed class YaeBlogCommand return; } + // 设置发布的时间 + content.Metadata.Date = DateTime.Now; + // 将选中的博客文件复制到posts await essayScanService.SaveBlogContent(content, isDraft: false); // 复制图片文件夹 DirectoryInfo sourceImageDirectory = - new(Path.Combine(blogOptions.Value.Root, "drafts", content.FileName)); + new(Path.Combine(blogOptions.Value.Root, "drafts", content.BlogName)); DirectoryInfo targetImageDirectory = - new(Path.Combine(blogOptions.Value.Root, "posts", content.FileName)); + new(Path.Combine(blogOptions.Value.Root, "posts", content.BlogName)); if (sourceImageDirectory.Exists) { @@ -223,9 +234,30 @@ public sealed class YaeBlogCommand } // 删除原始的文件 - FileInfo sourceBlogFile = new(Path.Combine(blogOptions.Value.Root, "drafts", content.FileName + ".md")); + FileInfo sourceBlogFile = new(Path.Combine(blogOptions.Value.Root, "drafts", content.BlogName + ".md")); sourceBlogFile.Delete(); }, new BlogOptionsBinder(), new LoggerBinder(), new EssayScanServiceBinder(), filenameArgument); } + + private static void AddCompressCommand(RootCommand rootCommand) + { + Command command = new("compress", "Compress png/jpeg image to webp image to reduce size."); + rootCommand.Add(command); + + Option dryRunOption = new("--dry-run", description: "Dry run the compression task but not write.", + getDefaultValue: () => false); + command.AddOption(dryRunOption); + + command.SetHandler(ImageCommandHandler, + new BlogOptionsBinder(), new LoggerBinder(), new LoggerBinder(), + new EssayScanServiceBinder(), new ImageCompressServiceBinder(), dryRunOption); + } + + private static async Task ImageCommandHandler(IOptions _, ILogger _1, + ILogger _2, + IEssayScanService _3, ImageCompressService imageCompressService, bool dryRun) + { + await imageCompressService.Compress(dryRun); + } } diff --git a/YaeBlog/Models/BlogContent.cs b/YaeBlog/Models/BlogContent.cs index 6ab5b7d..1765249 100644 --- a/YaeBlog/Models/BlogContent.cs +++ b/YaeBlog/Models/BlogContent.cs @@ -1,12 +1,20 @@ namespace YaeBlog.Models; -public class BlogContent +/// +/// 单个博客文件的所有数据和元数据 +/// +/// 博客文件 +/// 文件中的MD元数据 +/// 文件内容 +/// 是否为草稿 +/// 博客中使用的文件 +public record BlogContent( + FileInfo BlogFile, + MarkdownMetadata Metadata, + string Content, + bool IsDraft, + List Images, + List NotfoundImages) { - public required string FileName { get; init; } - - public required MarkdownMetadata Metadata { get; init; } - - public required string FileContent { get; set; } - - public bool IsDraft { get; set; } = false; + public string BlogName => BlogFile.Name.Split('.')[0]; } diff --git a/YaeBlog/Models/BlogContents.cs b/YaeBlog/Models/BlogContents.cs index 8a6f7d9..11dd937 100644 --- a/YaeBlog/Models/BlogContents.cs +++ b/YaeBlog/Models/BlogContents.cs @@ -1,10 +1,15 @@ -using System.Collections.Concurrent; +using System.Collections; +using System.Collections.Concurrent; namespace YaeBlog.Models; -public sealed class BlogContents(ConcurrentBag drafts, ConcurrentBag posts) +public record BlogContents(ConcurrentBag Drafts, ConcurrentBag Posts) + : IEnumerable { - public ConcurrentBag Drafts { get; } = drafts; + IEnumerator IEnumerable.GetEnumerator() + { + return Posts.Concat(Drafts).GetEnumerator(); + } - public ConcurrentBag Posts { get; } = posts; + public IEnumerator GetEnumerator() => ((IEnumerable)this).GetEnumerator(); } diff --git a/YaeBlog/Models/BlogImageInfo.cs b/YaeBlog/Models/BlogImageInfo.cs new file mode 100644 index 0000000..ee63641 --- /dev/null +++ b/YaeBlog/Models/BlogImageInfo.cs @@ -0,0 +1,44 @@ +using System.Text; + +namespace YaeBlog.Models; + +public record BlogImageInfo(FileInfo File, long Width, long Height, string MineType, byte[] Content, bool IsUsed) + : IComparable +{ + public int Size => Content.Length; + + public override string ToString() + { + StringBuilder builder = new(); + + builder.AppendLine($"Blog image {File.Name}:"); + builder.AppendLine($"\tWidth: {Width}; Height: {Height}"); + builder.AppendLine($"\tSize: {FormatSize()}"); + builder.AppendLine($"\tImage Format: {MineType}"); + + return builder.ToString(); + } + + public int CompareTo(BlogImageInfo? other) + { + if (other is null) + { + return -1; + } + + return other.Size.CompareTo(Size); + } + + private string FormatSize() + { + double size = Size; + if (size / 1024 > 3) + { + size /= 1024; + + return size / 1024 > 3 ? $"{size / 1024}MB" : $"{size}KB"; + } + + return $"{size}B"; + } +} diff --git a/YaeBlog/Models/ImageScanResult.cs b/YaeBlog/Models/ImageScanResult.cs deleted file mode 100644 index 85f3cc7..0000000 --- a/YaeBlog/Models/ImageScanResult.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace YaeBlog.Models; - -public record struct ImageScanResult(List UnusedImages, List NotFoundImages); diff --git a/YaeBlog/Services/EssayScanService.cs b/YaeBlog/Services/EssayScanService.cs index 1ef9b13..be50ab6 100644 --- a/YaeBlog/Services/EssayScanService.cs +++ b/YaeBlog/Services/EssayScanService.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; using System.Text.RegularExpressions; +using Imageflow.Bindings; +using Imageflow.Fluent; using Microsoft.Extensions.Options; using YaeBlog.Abstraction; using YaeBlog.Core.Exceptions; @@ -9,17 +11,30 @@ using YamlDotNet.Serialization; namespace YaeBlog.Services; -public partial class EssayScanService( - ISerializer yamlSerializer, - IDeserializer yamlDeserializer, - IOptions blogOptions, - ILogger logger) : IEssayScanService +public partial class EssayScanService : IEssayScanService { - private readonly BlogOptions _blogOptions = blogOptions.Value; + private readonly BlogOptions _blogOptions; + private readonly ISerializer _yamlSerializer; + private readonly IDeserializer _yamlDeserializer; + private readonly ILogger _logger; + + public EssayScanService(ISerializer yamlSerializer, + IDeserializer yamlDeserializer, + IOptions blogOptions, + ILogger logger) + { + _yamlSerializer = yamlSerializer; + _yamlDeserializer = yamlDeserializer; + _logger = logger; + _blogOptions = blogOptions.Value; + RootDirectory = ValidateRootDirectory(); + } + + private DirectoryInfo RootDirectory { get; } public async Task ScanContents() { - ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts); + ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts); return new BlogContents( await ScanContentsInternal(drafts, true), @@ -28,82 +43,92 @@ public partial class EssayScanService( public async Task SaveBlogContent(BlogContent content, bool isDraft = true) { - ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts); + ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts); FileInfo targetFile = isDraft - ? new FileInfo(Path.Combine(drafts.FullName, content.FileName + ".md")) - : new FileInfo(Path.Combine(posts.FullName, content.FileName + ".md")); - - if (!isDraft) - { - content.Metadata.Date = DateTime.Now; - } + ? new FileInfo(Path.Combine(drafts.FullName, content.BlogName + ".md")) + : new FileInfo(Path.Combine(posts.FullName, content.BlogName + ".md")); if (targetFile.Exists) { - logger.LogWarning("Blog {} exists, overriding.", targetFile.Name); + _logger.LogWarning("Blog {} exists, overriding.", targetFile.Name); } await using StreamWriter writer = targetFile.CreateText(); await writer.WriteAsync("---\n"); - await writer.WriteAsync(yamlSerializer.Serialize(content.Metadata)); + await writer.WriteAsync(_yamlSerializer.Serialize(content.Metadata)); await writer.WriteAsync("---\n"); - if (isDraft) + if (string.IsNullOrEmpty(content.Content) && isDraft) { + // 如果博客为操作且内容为空 + // 创建简介隔断符号 await writer.WriteLineAsync(""); } else { - await writer.WriteAsync(content.FileContent); + await writer.WriteAsync(content.Content); } + + // 保存图片文件 + await Task.WhenAll(from image in content.Images + select File.WriteAllBytesAsync(image.File.FullName, image.Content)); } + private record struct BlogResult( + FileInfo BlogFile, + string BlogContent, + List Images, + List NotFoundImages); + private async Task> ScanContentsInternal(DirectoryInfo directory, bool isDraft) { - // 扫描以md结果的但是不是隐藏文件的文件 + // 扫描以md结尾且不是隐藏文件的文件 IEnumerable markdownFiles = from file in directory.EnumerateFiles() where file.Extension == ".md" && !file.Name.StartsWith('.') select file; - ConcurrentBag<(string, string)> fileContents = []; + ConcurrentBag fileContents = []; await Parallel.ForEachAsync(markdownFiles, async (file, token) => { using StreamReader reader = file.OpenText(); - fileContents.Add((file.Name, await reader.ReadToEndAsync(token))); + string blogName = file.Name.Split('.')[0]; + string blogContent = await reader.ReadToEndAsync(token); + ImageResult imageResult = + await ScanImagePreBlog(directory, blogName, + blogContent); + + fileContents.Add(new BlogResult(file, blogContent, imageResult.Images, imageResult.NotfoundImages)); }); ConcurrentBag contents = []; await Task.Run(() => { - foreach ((string filename, string content) in fileContents) + foreach (BlogResult blog in fileContents) { - int endPos = content.IndexOf("---", 4, StringComparison.Ordinal); - if (!content.StartsWith("---") || endPos is -1 or 0) + int endPos = blog.BlogContent.IndexOf("---", 4, StringComparison.Ordinal); + if (!blog.BlogContent.StartsWith("---") || endPos is -1 or 0) { - logger.LogWarning("Failed to parse metadata from {}, skipped.", filename); + _logger.LogWarning("Failed to parse metadata from {}, skipped.", blog.BlogFile.Name); return; } - string metadataString = content[4..endPos]; + string metadataString = blog.BlogContent[4..endPos]; try { - MarkdownMetadata metadata = yamlDeserializer.Deserialize(metadataString); - logger.LogDebug("Scan metadata title: '{}' for {}.", metadata.Title, filename); + MarkdownMetadata metadata = _yamlDeserializer.Deserialize(metadataString); + _logger.LogDebug("Scan metadata title: '{}' for {}.", metadata.Title, blog.BlogFile.Name); - contents.Add(new BlogContent - { - FileName = filename[..^3], Metadata = metadata, FileContent = content[(endPos + 3)..], - IsDraft = isDraft - }); + contents.Add(new BlogContent(blog.BlogFile, metadata, blog.BlogContent[(endPos + 3)..], isDraft, + blog.Images, blog.NotFoundImages)); } catch (YamlException e) { - logger.LogWarning("Failed to parser metadata from {} due to {}, skipping", filename, e); + _logger.LogWarning("Failed to parser metadata from {} due to {}, skipping", blog.BlogFile.Name, e); } } }); @@ -111,99 +136,96 @@ public partial class EssayScanService( return contents; } - public async Task ScanImages() + private record struct ImageResult(List Images, List NotfoundImages); + + private async Task ScanImagePreBlog(DirectoryInfo directory, string blogName, string content) { - BlogContents contents = await ScanContents(); - ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts); + MatchCollection matchResult = ImagePattern.Matches(content); + DirectoryInfo imageDirectory = new(Path.Combine(directory.FullName, blogName)); - List unusedFiles = []; - List notFoundFiles = []; + Dictionary usedImages = imageDirectory.Exists + ? imageDirectory.EnumerateFiles().ToDictionary(file => file.FullName, _ => false) + : []; + List notFoundImages = []; - ImageScanResult draftResult = await ScanUnusedImagesInternal(contents.Drafts, drafts); - ImageScanResult postResult = await ScanUnusedImagesInternal(contents.Posts, posts); - - unusedFiles.AddRange(draftResult.UnusedImages); - notFoundFiles.AddRange(draftResult.NotFoundImages); - unusedFiles.AddRange(postResult.UnusedImages); - notFoundFiles.AddRange(postResult.NotFoundImages); - - return new ImageScanResult(unusedFiles, notFoundFiles); - } - - private static Task ScanUnusedImagesInternal(IEnumerable contents, - DirectoryInfo root) - { - ConcurrentBag unusedImage = []; - ConcurrentBag notFoundImage = []; - - Parallel.ForEach(contents, content => + foreach (Match match in matchResult) { - MatchCollection result = ImagePattern.Matches(content.FileContent); - DirectoryInfo imageDirectory = new(Path.Combine(root.FullName, content.FileName)); + string imageName = match.Groups[1].Value; - Dictionary usedDictionary; + // 判断md文件中的图片名称中是否包含文件夹名称 + // 例如 blog-1/image.png 或者 image.png + // 如果不带文件夹名称 + // 默认添加同博客名文件夹 + FileInfo usedFile = imageName.Contains(blogName) + ? new FileInfo(Path.Combine(directory.FullName, imageName)) + : new FileInfo(Path.Combine(directory.FullName, blogName, imageName)); - if (imageDirectory.Exists) + if (usedImages.TryGetValue(usedFile.FullName, out _)) { - usedDictionary = (from file in imageDirectory.EnumerateFiles() - select new KeyValuePair(file.FullName, false)).ToDictionary(); + usedImages[usedFile.FullName] = true; } else { - usedDictionary = []; + notFoundImages.Add(usedFile); } + } - foreach (Match match in result) - { - string imageName = match.Groups[1].Value; + List images = (await Task.WhenAll((from pair in usedImages + select GetImageInfo(new FileInfo(pair.Key), pair.Value)).ToArray())).ToList(); - FileInfo usedFile = imageName.Contains(content.FileName) - ? new FileInfo(Path.Combine(root.FullName, imageName)) - : new FileInfo(Path.Combine(root.FullName, content.FileName, imageName)); + return new ImageResult(images, notFoundImages); + } - if (usedDictionary.TryGetValue(usedFile.FullName, out _)) - { - usedDictionary[usedFile.FullName] = true; - } - else - { - notFoundImage.Add(usedFile); - } - } + private static async Task GetImageInfo(FileInfo file, bool isUsed) + { + byte[] image = await File.ReadAllBytesAsync(file.FullName); - foreach (KeyValuePair pair in usedDictionary.Where(p => !p.Value)) - { - unusedImage.Add(new FileInfo(pair.Key)); - } - }); + if (file.Extension is ".jpg" or ".jpeg" or ".png") + { + ImageInfo imageInfo = + await ImageJob.GetImageInfoAsync(MemorySource.Borrow(image), SourceLifetime.NowOwnedAndDisposedByTask); - return Task.FromResult(new ImageScanResult(unusedImage.ToList(), notFoundImage.ToList())); + return new BlogImageInfo(file, imageInfo.ImageWidth, imageInfo.ImageWidth, imageInfo.PreferredMimeType, + image, isUsed); + } + + return new BlogImageInfo(file, 0, 0, file.Extension switch + { + "svg" => "image/svg", + "avif" => "image/avif", + _ => string.Empty + }, image, isUsed); } [GeneratedRegex(@"\!\[.*?\]\((.*?)\)")] private static partial Regex ImagePattern { get; } - private void ValidateDirectory(string root, out DirectoryInfo drafts, out DirectoryInfo posts) + + private DirectoryInfo ValidateRootDirectory() { - root = Path.Combine(Environment.CurrentDirectory, root); - DirectoryInfo rootDirectory = new(root); + DirectoryInfo rootDirectory = new(Path.Combine(Environment.CurrentDirectory, _blogOptions.Root)); if (!rootDirectory.Exists) { - throw new BlogFileException($"'{root}' is not a directory."); + throw new BlogFileException($"'{_blogOptions.Root}' is not a directory."); } - if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "drafts")) + return rootDirectory; + } + + private void ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts) + { + if (RootDirectory.EnumerateDirectories().All(dir => dir.Name != "drafts")) { - throw new BlogFileException($"'{root}/drafts' not exists."); + throw new BlogFileException($"'{_blogOptions.Root}/drafts' not exists."); } - if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "posts")) + if (RootDirectory.EnumerateDirectories().All(dir => dir.Name != "posts")) { - throw new BlogFileException($"'{root}/posts' not exists."); + throw new BlogFileException($"'{_blogOptions.Root}/posts' not exists."); } - drafts = new DirectoryInfo(Path.Combine(root, "drafts")); - posts = new DirectoryInfo(Path.Combine(root, "posts")); + drafts = new DirectoryInfo(Path.Combine(_blogOptions.Root, "drafts")); + posts = new DirectoryInfo(Path.Combine(_blogOptions.Root, "posts")); } } diff --git a/YaeBlog/Services/ImageCompressService.cs b/YaeBlog/Services/ImageCompressService.cs new file mode 100644 index 0000000..9d50661 --- /dev/null +++ b/YaeBlog/Services/ImageCompressService.cs @@ -0,0 +1,108 @@ +using Imageflow.Fluent; +using YaeBlog.Abstraction; +using YaeBlog.Core.Exceptions; +using YaeBlog.Models; + +namespace YaeBlog.Services; + +public sealed class ImageCompressService(IEssayScanService essayScanService, ILogger logger) +{ + private record struct CompressResult(BlogImageInfo ImageInfo, byte[] CompressContent); + + public async Task> ScanUsedImages() + { + BlogContents contents = await essayScanService.ScanContents(); + List originalImages = (from content in contents.Posts.Concat(contents.Drafts) + from image in content.Images + where image.IsUsed + select image).ToList(); + + originalImages.Sort(); + + return originalImages; + } + + public async Task Compress(bool dryRun) + { + BlogContents contents = await essayScanService.ScanContents(); + + // 筛选需要压缩的图片 + // 即图片被博客使用且是jpeg/png格式 + List needCompressContents = (from content in contents + where content.Images.Any(i => i is { IsUsed: true } and { File.Extension: ".jpg" or ".jpeg" or ".png" }) + select content).ToList(); + + if (needCompressContents.Count == 0) + { + return; + } + + int uncompressedSize = 0; + int compressedSize = 0; + List compressedContent = new(needCompressContents.Count); + + foreach (BlogContent content in needCompressContents) + { + List uncompressedImages = (from image in content.Images + where image is { IsUsed: true } and { File.Extension: ".jpg" or ".jpeg" or ".png" } + select image).ToList(); + + uncompressedSize += uncompressedImages.Select(i => i.Size).Sum(); + + foreach (BlogImageInfo image in uncompressedImages) + { + logger.LogInformation("Uncompressed image: {} belonging to blog {}.", image.File.Name, + content.BlogName); + } + + CompressResult[] compressedImages = (await Task.WhenAll(from image in uncompressedImages + select Task.Run(async () => new CompressResult(image, await ConvertToWebp(image.Content))))).ToArray(); + + compressedSize += compressedImages.Select(i => i.CompressContent.Length).Sum(); + + // 直接在原有的图片列表上添加图片 + List images = content.Images.Concat(from r in compressedImages + select r.ImageInfo with + { + File = new FileInfo(r.ImageInfo.File.FullName.Split('.')[0] + ".webp"), + Content = r.CompressContent + }).ToList(); + // 修改文本 + string blogContent = compressedImages.Aggregate(content.Content, (c, r) => + { + string originalName = r.ImageInfo.File.Name; + string outputName = originalName.Split('.')[0] + ".webp"; + + return c.Replace(originalName, outputName); + }); + + compressedContent.Add(content with { Images = images, Content = blogContent }); + } + + logger.LogInformation("Compression ratio: {}%.", (double)compressedSize / uncompressedSize * 100.0); + + if (dryRun is false) + { + await Task.WhenAll(from content in compressedContent + select essayScanService.SaveBlogContent(content, content.IsDraft)); + } + } + + private static async Task ConvertToWebp(byte[] image) + { + using ImageJob job = new(); + BuildJobResult result = await job.Decode(MemorySource.Borrow(image)) + .EncodeToBytes(new WebPLossyEncoder(75)) + .Finish() + .InProcessAsync(); + + ArraySegment? array = result.First?.TryGetBytes(); + + if (array.HasValue) + { + return array.Value.ToArray(); + } + + throw new BlogFileException(); + } +} diff --git a/YaeBlog/Services/RendererService.cs b/YaeBlog/Services/RendererService.cs index dda44ee..6105694 100644 --- a/YaeBlog/Services/RendererService.cs +++ b/YaeBlog/Services/RendererService.cs @@ -41,14 +41,14 @@ public partial class RendererService( uint wordCount = GetWordCount(content); BlogEssay essay = new() { - Title = content.Metadata.Title ?? content.FileName, - FileName = content.FileName, + Title = content.Metadata.Title ?? content.BlogName, + FileName = content.BlogName, IsDraft = content.IsDraft, Description = GetDescription(content), WordCount = wordCount, ReadTime = CalculateReadTime(wordCount), PublishTime = content.Metadata.Date ?? DateTime.Now, - HtmlContent = content.FileContent + HtmlContent = content.Content }; if (content.Metadata.Tags is not null) @@ -156,17 +156,17 @@ public partial class RendererService( private string GetDescription(BlogContent content) { const string delimiter = ""; - int pos = content.FileContent.IndexOf(delimiter, StringComparison.Ordinal); + int pos = content.Content.IndexOf(delimiter, StringComparison.Ordinal); bool breakSentence = false; if (pos == -1) { // 自动截取前50个字符 - pos = content.FileContent.Length < 50 ? content.FileContent.Length : 50; + pos = content.Content.Length < 50 ? content.Content.Length : 50; breakSentence = true; } - string rawContent = content.FileContent[..pos]; + string rawContent = content.Content[..pos]; MatchCollection matches = DescriptionPattern.Matches(rawContent); StringBuilder builder = new(); @@ -182,18 +182,18 @@ public partial class RendererService( string description = builder.ToString(); - logger.LogDebug("Description of {} is {}.", content.FileName, + logger.LogDebug("Description of {} is {}.", content.BlogName, description); return description; } private uint GetWordCount(BlogContent content) { - int count = (from c in content.FileContent + int count = (from c in content.Content where char.IsLetterOrDigit(c) select c).Count(); - logger.LogDebug("Word count of {} is {}", content.FileName, + logger.LogDebug("Word count of {} is {}", content.BlogName, count); return (uint)count; } diff --git a/YaeBlog/YaeBlog.csproj b/YaeBlog/YaeBlog.csproj index 546dc43..133503a 100644 --- a/YaeBlog/YaeBlog.csproj +++ b/YaeBlog/YaeBlog.csproj @@ -1,6 +1,8 @@ + +