diff --git a/packaging/Package.appxmanifest b/packaging/Package.appxmanifest index 9cb3be1..9b9d074 100644 --- a/packaging/Package.appxmanifest +++ b/packaging/Package.appxmanifest @@ -1,6 +1,6 @@  - + Everything2Everything YunChan diff --git a/src/Everything2Everything.App/Everything2Everything.App.csproj b/src/Everything2Everything.App/Everything2Everything.App.csproj index 4d141e9..984ecea 100644 --- a/src/Everything2Everything.App/Everything2Everything.App.csproj +++ b/src/Everything2Everything.App/Everything2Everything.App.csproj @@ -1,7 +1,7 @@ - 1.0.21 + 1.0.22 WinExe net9.0-windows10.0.19041.0 enable @@ -35,6 +35,29 @@ + + + + PreserveNewest + PreserveNewest + + + Everything2Everything.Shell.dll + PreserveNewest + PreserveNewest + + + AppxManifest.xml + PreserveNewest + PreserveNewest + + + Assets\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + PreserveNewest + + + @@ -44,3 +67,4 @@ + diff --git a/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs b/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs index 0b0820d..76da67f 100644 --- a/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs +++ b/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs @@ -10,6 +10,69 @@ public static class ContextMenuRegistrar private const string MainLabel = "Everything2Everything으로 변환"; private const string SubmenuKeyPrefix = "Everything2Everything.SubMenu."; + public const string ClassicContextMenuOverrideKey = @"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32"; + + public sealed class ContextMenuVerbPlan + { + public string Extension { get; init; } = ""; + public string RootKeyPath { get; init; } = ""; + public string RootLabel { get; init; } = ""; + public string IconPath { get; init; } = ""; + public bool UsesExtendedSubCommands { get; init; } = true; // 의도적 true로 RED 유도 + public string? SubCommandsValue { get; init; } = null; + public IReadOnlyList Items { get; init; } = Array.Empty(); + } + + public sealed class ContextMenuItemPlan + { + public string SubKeyPath { get; init; } = ""; + public string VerbName { get; init; } = ""; + public string Label { get; init; } = ""; + public string IconPath { get; init; } = ""; + public string Command { get; init; } = ""; + } + + public static ContextMenuVerbPlan BuildVerbPlan(ConversionEngine engine, string ext, string exePath) + { + var availableOutputs = GetAvailableOutputs(engine, ext); + var icon = exePath + ",0"; + var rootKeyPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{MainVerb}"; + + var items = new List(); + foreach (var (outExt, outLabel, sortPrefix) in availableOutputs) + { + var subVerbName = $"{sortPrefix}_{outExt.TrimStart('.')}"; + var cliExt = outExt.TrimStart('.'); + items.Add(new ContextMenuItemPlan + { + SubKeyPath = $@"{rootKeyPath}\shell\{subVerbName}", + VerbName = subVerbName, + Label = outLabel, + IconPath = icon, + Command = $"\"{exePath}\" to {cliExt} \"%1\"" + }); + } + + items.Add(new ContextMenuItemPlan + { + SubKeyPath = $@"{rootKeyPath}\shell\98_dialog", + VerbName = "98_dialog", + Label = "변환… (옵션 선택)", + IconPath = icon, + Command = $"\"{exePath}\" dialog \"%1\"" + }); + + return new ContextMenuVerbPlan + { + Extension = ext, + RootKeyPath = rootKeyPath, + RootLabel = MainLabel, + IconPath = icon, + UsesExtendedSubCommands = false, // Shift 불필요: 마우스 일반 우클릭으로 즉시 펼쳐짐 + SubCommandsValue = "", + Items = items + }; + } private static readonly (string Ext, string Label, string SortPrefix)[] PopularOutputs = { @@ -44,16 +107,16 @@ public static class ContextMenuRegistrar public static void Register(ConversionEngine engine) { var exe = GetAppExecutablePath(); - var icon = exe + ",0"; foreach (var ext in CollectInputExtensions(engine)) { - var availableOutputs = GetAvailableOutputs(engine, ext); - if (availableOutputs.Count == 0) continue; + var plan = BuildVerbPlan(engine, ext, exe); + if (plan.Items.Count == 0) continue; - WriteCascade(ext, exe, icon, availableOutputs); + WriteCascade(plan, ext); } + TryRegisterWindows11SparsePackage(); NotifyShell(); } @@ -66,9 +129,44 @@ public static class ContextMenuRegistrar DeleteVerb(ext, "Everything2Everything.Dialog"); DeleteSubmenuTree(ext); } + + TryUnregisterWindows11SparsePackage(); NotifyShell(); } + public static bool IsClassicContextMenuEnabled() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(ClassicContextMenuOverrideKey); + return key != null; + } + catch + { + return false; + } + } + + public static void SetClassicContextMenuEnabled(bool enable) + { + try + { + if (enable) + { + using var key = Registry.CurrentUser.CreateSubKey(ClassicContextMenuOverrideKey, writable: true); + key?.SetValue(null, "", RegistryValueKind.String); + } + else + { + Registry.CurrentUser.DeleteSubKeyTree(@"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}", throwOnMissingSubKey: false); + } + NotifyShell(); + } + catch + { + } + } + private static IEnumerable CollectInputExtensions(ConversionEngine engine) { return engine.Providers.Implemented @@ -79,28 +177,25 @@ public static class ContextMenuRegistrar .Distinct(); } - private static void WriteCascade( - string ext, - string exe, - string icon, - IReadOnlyList<(string Ext, string Label, string SortPrefix)> availableOutputs) + private static void WriteCascade(ContextMenuVerbPlan plan, string ext) { - var submenuKeyName = SubmenuKeyPrefix + ext.TrimStart('.'); - - var verbPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{MainVerb}"; - using (var verbKey = Registry.CurrentUser.CreateSubKey(verbPath, writable: true) - ?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {verbPath}")) + // 1. 루트 키 등록 (Shift 없는 일반 우클릭에서 즉시 서브메뉴 노출) + using (var verbKey = Registry.CurrentUser.CreateSubKey(plan.RootKeyPath, writable: true) + ?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {plan.RootKeyPath}")) { - verbKey.SetValue(null, MainLabel, RegistryValueKind.String); - verbKey.SetValue("MUIVerb", MainLabel, RegistryValueKind.String); - verbKey.SetValue("Icon", icon, RegistryValueKind.String); - verbKey.SetValue("SubCommands", "", RegistryValueKind.String); - verbKey.SetValue("ExtendedSubCommandsKey", submenuKeyName, RegistryValueKind.String); + verbKey.SetValue(null, plan.RootLabel, RegistryValueKind.String); + verbKey.SetValue("MUIVerb", plan.RootLabel, RegistryValueKind.String); + verbKey.SetValue("Icon", plan.IconPath, RegistryValueKind.String); + verbKey.SetValue("SubCommands", plan.SubCommandsValue ?? "", RegistryValueKind.String); + + // 구버전에서 Shift 키를 강제하던 ExtendedSubCommandsKey 제거 + try { verbKey.DeleteValue("ExtendedSubCommandsKey", throwOnMissingValue: false); } catch { } try { verbKey.DeleteSubKeyTree("command", throwOnMissingSubKey: false); } catch { } } - var submenuShellPath = $@"Software\Classes\{submenuKeyName}\shell"; - using (var existing = Registry.CurrentUser.OpenSubKey(submenuShellPath, writable: true)) + // 2. 루트 직하위 shell 키 정리 및 서브메뉴 항목 등록 + var shellRootPath = $@"{plan.RootKeyPath}\shell"; + using (var existing = Registry.CurrentUser.OpenSubKey(shellRootPath, writable: true)) { if (existing is not null) { @@ -111,31 +206,22 @@ public static class ContextMenuRegistrar } } - foreach (var (outExt, outLabel, sortPrefix) in availableOutputs) + foreach (var item in plan.Items) { - var subVerbName = $"{sortPrefix}_{outExt.TrimStart('.')}"; - var cliExt = outExt.TrimStart('.'); - WriteSubmenuItem(submenuKeyName, subVerbName, outLabel, icon, - $"\"{exe}\" to {cliExt} \"%1\""); + using var key = Registry.CurrentUser.CreateSubKey(item.SubKeyPath, writable: true) + ?? throw new InvalidOperationException($"서브메뉴 키 생성 실패: {item.SubKeyPath}"); + + key.SetValue(null, item.Label, RegistryValueKind.String); + key.SetValue("MUIVerb", item.Label, RegistryValueKind.String); + key.SetValue("Icon", item.IconPath, RegistryValueKind.String); + + using var commandKey = key.CreateSubKey("command", writable: true) + ?? throw new InvalidOperationException("command 하위 키 생성 실패"); + commandKey.SetValue(null, item.Command, RegistryValueKind.String); } - WriteSubmenuItem(submenuKeyName, "98_dialog", "변환… (옵션 선택)", icon, - $"\"{exe}\" dialog \"%1\""); - } - - private static void WriteSubmenuItem(string submenuKeyName, string verbName, string label, string icon, string command) - { - var path = $@"Software\Classes\{submenuKeyName}\shell\{verbName}"; - using var key = Registry.CurrentUser.CreateSubKey(path, writable: true) - ?? throw new InvalidOperationException($"서브메뉴 키 생성 실패: {path}"); - - key.SetValue(null, label, RegistryValueKind.String); - key.SetValue("MUIVerb", label, RegistryValueKind.String); - key.SetValue("Icon", icon, RegistryValueKind.String); - - using var commandKey = key.CreateSubKey("command", writable: true) - ?? throw new InvalidOperationException("command 하위 키 생성 실패"); - commandKey.SetValue(null, command, RegistryValueKind.String); + // 3. 구버전 독립 SubMenu 키 정리 (이전 버전 찌꺼기 제거) + DeleteSubmenuTree(ext); } private static void DeleteVerb(string ext, string verb) @@ -172,6 +258,70 @@ public static class ContextMenuRegistrar + Path.DirectorySeparatorChar + "Everything2Everything.exe"; } + private static void TryRegisterWindows11SparsePackage() + { + try + { + if (Environment.OSVersion.Version.Build < 22000) return; + + var manifestPath = FindAppxManifestPath(); + if (string.IsNullOrEmpty(manifestPath) || !File.Exists(manifestPath)) return; + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -NonInteractive -WindowStyle Hidden -Command \"Add-AppxPackage -Register '{manifestPath}'\"", + CreateNoWindow = true, + UseShellExecute = false + }; + using var proc = System.Diagnostics.Process.Start(psi); + proc?.WaitForExit(5000); + } + catch + { + // Sparse Package 등록 실패(서명/정책 등) 시에도 레지스트리 캐스케이드는 정상이므로 무시 + } + } + + private static void TryUnregisterWindows11SparsePackage() + { + try + { + if (Environment.OSVersion.Version.Build < 22000) return; + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-NoProfile -NonInteractive -WindowStyle Hidden -Command \"Get-AppxPackage -Name '*Everything2Everything*' | Remove-AppxPackage\"", + CreateNoWindow = true, + UseShellExecute = false + }; + using var proc = System.Diagnostics.Process.Start(psi); + proc?.WaitForExit(5000); + } + catch + { + } + } + + public static string? FindAppxManifestPath() + { + var baseDir = AppContext.BaseDirectory; + var candidates = new[] + { + Path.Combine(baseDir, "AppxManifest.xml"), + Path.Combine(baseDir, "Package.appxmanifest"), + Path.Combine(baseDir, "..", "packaging", "Package.appxmanifest"), + Path.Combine(baseDir, "..", "..", "packaging", "Package.appxmanifest") + }; + + foreach (var candidate in candidates) + { + if (File.Exists(candidate)) return Path.GetFullPath(candidate); + } + return null; + } + private static void NotifyShell() { try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); } diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs index 0805fde..f9c3468 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml.cs +++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs @@ -1213,7 +1213,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC { ContextMenuRegistrar.Register(_engine); MessageBox.Show(this, - "컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".", + "컨텍스트 메뉴를 성공적으로 등록했습니다.\n파일 우클릭 시 \"Everything2Everything으로 변환\" 메뉴가 즉시 표시됩니다.", "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information); } catch (Exception ex) diff --git a/src/Everything2Everything.Core/Converters/DocumentProvider.cs b/src/Everything2Everything.Core/Converters/DocumentProvider.cs index 0be40b6..26c001f 100644 --- a/src/Everything2Everything.Core/Converters/DocumentProvider.cs +++ b/src/Everything2Everything.Core/Converters/DocumentProvider.cs @@ -17,17 +17,22 @@ namespace Everything2Everything.Core.Converters; // txt ↔ md → trivial copy public sealed class DocumentProvider : IConverterProvider { - private static readonly string[] Inputs = - { ".html", ".htm", ".hwp", ".hwpx", ".docx", ".doc", ".md", ".markdown", ".txt" }; - - private static readonly string[] Outputs = { ".html", ".docx", ".md", ".txt" }; + private static readonly string[] OfficeInputs = { ".docx", ".doc", ".hwp", ".hwpx" }; + private static readonly string[] OfficeOutputs = { ".html", ".docx", ".md", ".txt" }; + private static readonly string[] TextInputsToDocx = { ".html", ".htm", ".md", ".markdown", ".txt" }; public ProviderCapability Capability { get; } = new( Id: "document", - DisplayName: "문서 텍스트 변환 (HTML/HWP/DOCX/MD/TXT)", - SupportedConversions: ProviderCapability.PairsFromMatrix(Inputs, Outputs), + DisplayName: "문서 텍스트 변환 (DOCX/HWP/오피스)", + SupportedConversions: ProviderCapability.PairsFromMatrix(OfficeInputs, OfficeOutputs) + .Concat(ProviderCapability.PairsFromMatrix(TextInputsToDocx, new[] { ".docx" })) + .Concat(new[] + { + new ConversionPair(".html", ".txt", LossClass.Recode), + new ConversionPair(".htm", ".txt", LossClass.Recode), + }).ToList(), Status: ProviderStatus.RequiresExternal, - Summary: "HTML·HWP·DOCX·Markdown·TXT 사이의 양방향 텍스트 변환 (LibreOffice + Markdig + ReverseMarkdown).", + Summary: "DOCX·HWP 문서를 HTML/DOCX/MD/TXT로 변환하거나 텍스트를 DOCX로 저장합니다 (LibreOffice 엔진).", ExternalDependencies: new[] { new ExternalDependency( diff --git a/src/Everything2Everything.Core/Converters/HtmlProvider.cs b/src/Everything2Everything.Core/Converters/HtmlProvider.cs index 9db4759..a5ebf50 100644 --- a/src/Everything2Everything.Core/Converters/HtmlProvider.cs +++ b/src/Everything2Everything.Core/Converters/HtmlProvider.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Threading; using System.Windows; +using System.Windows.Interop; using System.Windows.Threading; using Everything2Everything.Core.Providers; using ImageMagick; @@ -165,7 +166,25 @@ public sealed class HtmlProvider : IConverterProvider try { var dispatcher = Dispatcher.CurrentDispatcher; - _ = RunCaptureOnDispatcher(capture, dispatcher, sourcePath, options, progress, cancellationToken, tcs); + SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(dispatcher)); + + dispatcher.InvokeAsync(async () => + { + try + { + var bytes = await RunCaptureOnDispatcher(capture, sourcePath, options, progress, cancellationToken); + tcs.TrySetResult(bytes); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + finally + { + dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + } + }); + Dispatcher.Run(); } catch (Exception ex) @@ -181,16 +200,15 @@ public sealed class HtmlProvider : IConverterProvider return tcs.Task; } - private static async Task RunCaptureOnDispatcher( + private static async Task RunCaptureOnDispatcher( Func?, Task> capture, - Dispatcher dispatcher, string sourcePath, ConvertOptions options, IProgress? progress, - CancellationToken cancellationToken, - TaskCompletionSource tcs) + CancellationToken cancellationToken) { CoreWebView2Controller? controller = null; + HwndSource? hwndSource = null; try { var userDataFolder = Path.Combine( @@ -198,15 +216,23 @@ public sealed class HtmlProvider : IConverterProvider "Everything2Everything", "WebView2"); Directory.CreateDirectory(userDataFolder); - var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder).ConfigureAwait(true); + var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder); progress?.Report(0.15); - controller = await env.CreateCoreWebView2ControllerAsync(new IntPtr(-3)).ConfigureAwait(true); - int width = options.HtmlRender.ViewportWidth > 0 ? options.HtmlRender.ViewportWidth : 1280; int height = options.HtmlRender.ViewportHeight ?? 720; + + var parameters = new HwndSourceParameters("WebView2HiddenHost") + { + WindowStyle = 0x800000, + Width = width, + Height = height, + }; + hwndSource = new HwndSource(parameters); + + controller = await env.CreateCoreWebView2ControllerAsync(hwndSource.Handle); controller.Bounds = new System.Drawing.Rectangle(0, 0, width, height); - controller.IsVisible = false; + controller.IsVisible = true; var web = controller.CoreWebView2; progress?.Report(0.3); @@ -217,36 +243,31 @@ public sealed class HtmlProvider : IConverterProvider { web.NavigationCompleted -= navHandler!; if (e.IsSuccess) navTcs.TrySetResult(true); - else navTcs.TrySetException(new InvalidOperationException( - $"내비게이션 실패: {e.WebErrorStatus}")); + else navTcs.TrySetException(new InvalidOperationException($"내비게이션 실패: {e.WebErrorStatus}")); }; web.NavigationCompleted += navHandler; - var fileUri = new Uri(sourcePath).AbsoluteUri; + var fileUri = new Uri(Path.GetFullPath(sourcePath)).AbsoluteUri; web.Navigate(fileUri); using (cancellationToken.Register(() => navTcs.TrySetCanceled())) { - await navTcs.Task.ConfigureAwait(true); + await navTcs.Task; } progress?.Report(0.5); if (options.HtmlRender.WaitMilliseconds > 0) - await Task.Delay(options.HtmlRender.WaitMilliseconds, cancellationToken).ConfigureAwait(true); + await Task.Delay(options.HtmlRender.WaitMilliseconds, cancellationToken); progress?.Report(0.65); - var bytes = await capture(web, progress).ConfigureAwait(true); - tcs.TrySetResult(bytes); - } - catch (Exception ex) - { - tcs.TrySetException(ex); + var bytes = await capture(web, progress); + return bytes; } finally { try { controller?.Close(); } catch { } - dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + try { hwndSource?.Dispose(); } catch { } } } diff --git a/src/Everything2Everything.Core/Converters/MarkdownProvider.cs b/src/Everything2Everything.Core/Converters/MarkdownProvider.cs new file mode 100644 index 0000000..3af441f --- /dev/null +++ b/src/Everything2Everything.Core/Converters/MarkdownProvider.cs @@ -0,0 +1,117 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Everything2Everything.Core.Providers; +using Markdig; + +namespace Everything2Everything.Core.Converters; + +public sealed class MarkdownProvider : IConverterProvider +{ + private static readonly string[] MdInputs = { ".html", ".htm", ".md", ".markdown", ".txt" }; + + public ProviderCapability Capability { get; } = new( + Id: "markdown", + DisplayName: "마크다운 / HTML 텍스트", + SupportedConversions: new[] + { + new ConversionPair(".html", ".md", LossClass.Recode), + new ConversionPair(".htm", ".md", LossClass.Recode), + new ConversionPair(".md", ".html", LossClass.Recode), + new ConversionPair(".markdown", ".html", LossClass.Recode), + new ConversionPair(".md", ".txt", LossClass.Container), + new ConversionPair(".txt", ".md", LossClass.Container), + new ConversionPair(".txt", ".html", LossClass.Recode), + }, + Status: ProviderStatus.Available, + Summary: "HTML·Markdown·TXT 사이의 순수 .NET 양방향 변환 (Markdig + ReverseMarkdown).", + ExternalDependencies: Array.Empty(), + RoadmapNote: null); + + public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + => Task.FromResult(ProviderAvailability.Ready); + + public async Task ConvertAsync( + string sourcePath, + string outputDirectory, + string outputExtension, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var inExt = Path.GetExtension(sourcePath).ToLowerInvariant(); + var outExt = ConversionPair.Normalize(outputExtension); + var baseName = Path.GetFileNameWithoutExtension(sourcePath); + var outPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision); + + if (OutputPathHelper.ShouldSkip(outPath, options.OnCollision)) + return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다."); + + progress?.Report(0.1); + + try + { + if (inExt is ".html" or ".htm" && outExt == ".md") + { + await HtmlToMdAsync(sourcePath, outPath, cancellationToken).ConfigureAwait(false); + } + else if (inExt is ".md" or ".markdown" && outExt == ".html") + { + await MdToHtmlAsync(sourcePath, outPath, cancellationToken).ConfigureAwait(false); + } + else if (inExt == ".txt" && outExt == ".html") + { + await TxtToHtmlAsync(sourcePath, outPath, cancellationToken).ConfigureAwait(false); + } + else if ((inExt is ".md" or ".markdown" && outExt == ".txt") || (inExt == ".txt" && outExt == ".md")) + { + await Task.Run(() => File.Copy(sourcePath, outPath, overwrite: true), cancellationToken).ConfigureAwait(false); + } + else + { + return ConvertResult.Fail(sourcePath, $"지원하지 않는 변환: {inExt} → {outExt}"); + } + + progress?.Report(1.0); + return ConvertResult.Ok(sourcePath, new[] { outPath }); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return ConvertResult.Fail(sourcePath, $"변환 실패: {ex.Message}", ex); + } + } + + private static async Task MdToHtmlAsync(string mdPath, string htmlPath, CancellationToken ct) + { + var md = await File.ReadAllTextAsync(mdPath, Encoding.UTF8, ct).ConfigureAwait(false); + var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); + var body = Markdown.ToHtml(md, pipeline); + var fullHtml = $"{Path.GetFileNameWithoutExtension(mdPath)}{body}"; + await File.WriteAllTextAsync(htmlPath, fullHtml, Encoding.UTF8, ct).ConfigureAwait(false); + } + + private static async Task HtmlToMdAsync(string htmlPath, string mdPath, CancellationToken ct) + { + var html = await File.ReadAllTextAsync(htmlPath, Encoding.UTF8, ct).ConfigureAwait(false); + var converter = new ReverseMarkdown.Converter(new ReverseMarkdown.Config + { + UnknownTags = ReverseMarkdown.Config.UnknownTagsOption.PassThrough, + GithubFlavored = true, + RemoveComments = true, + SmartHrefHandling = true, + }); + var md = converter.Convert(html); + await File.WriteAllTextAsync(mdPath, md, Encoding.UTF8, ct).ConfigureAwait(false); + } + + private static async Task TxtToHtmlAsync(string txtPath, string htmlPath, CancellationToken ct) + { + var txt = await File.ReadAllTextAsync(txtPath, Encoding.UTF8, ct).ConfigureAwait(false); + var encoded = System.Net.WebUtility.HtmlEncode(txt).Replace("\n", "
\n"); + var fullHtml = $"{Path.GetFileNameWithoutExtension(txtPath)}
{encoded}
"; + await File.WriteAllTextAsync(htmlPath, fullHtml, Encoding.UTF8, ct).ConfigureAwait(false); + } +} diff --git a/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs b/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs index 136a53a..ca8c9ee 100644 --- a/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs +++ b/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs @@ -56,4 +56,44 @@ public class ContextMenuRegistrarTests Assert.DoesNotContain(outputs, o => o.Ext == ".gif"); Assert.DoesNotContain(outputs, o => o.Ext == ".mp4"); } + + [Fact] + public void BuildVerbPlan_Png_DoesNotUseExtendedSubCommands_ForNormalRightClick() + { + // Windows 10/11에서 Shift 키 없이 일반 마우스 우클릭만으로 컨텍스트 메뉴가 바로 노출되어야 한다. + // ExtendedSubCommandsKey가 사용되면 Shift를 누를 때만 서브메뉴가 나오는 심각한 버그가 발생한다. + var engine = CreateEngine(); + var plan = ContextMenuRegistrar.BuildVerbPlan(engine, ".png", @"C:\Apps\E2E\Everything2Everything.exe"); + + Assert.False(plan.UsesExtendedSubCommands, "일반 우클릭에서 바로 노출되려면 UsesExtendedSubCommands가 반드시 false여야 합니다."); + Assert.Equal("", plan.SubCommandsValue); + } + + [Fact] + public void BuildVerbPlan_Png_PlacesSubVerbsUnderRootShellKey() + { + // Windows 7+ 표준 정적 캐스케이드 서브메뉴는 루트 동사 키 직하위의 \shell\ 키에 배치되어야 한다. + var engine = CreateEngine(); + var plan = ContextMenuRegistrar.BuildVerbPlan(engine, ".png", @"C:\Apps\E2E\Everything2Everything.exe"); + + Assert.NotEmpty(plan.Items); + var expectedPrefix = @"Software\Classes\SystemFileAssociations\.png\shell\Everything2Everything\shell\"; + foreach (var item in plan.Items) + { + Assert.StartsWith(expectedPrefix, item.SubKeyPath); + } + + // 대화상자 선택 메뉴(98_dialog)가 포함되어 있는지 검증 + var dialogItem = plan.Items.FirstOrDefault(i => i.VerbName.EndsWith("dialog")); + Assert.NotNull(dialogItem); + Assert.Contains("dialog", dialogItem.Command); + } + + [Fact] + public void ClassicContextMenuOverrideKey_Matches_Windows11_Specification() + { + // Windows 11에서 모던 메뉴 대신 항상 1차 클래식 메뉴를 복원하는 레지스트리 키 검증 + Assert.Equal(@"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32", + ContextMenuRegistrar.ClassicContextMenuOverrideKey); + } } diff --git a/src/Everything2Everything.Tests/DependencyInjectionTests.cs b/src/Everything2Everything.Tests/DependencyInjectionTests.cs index 0ff2f38..ee13078 100644 --- a/src/Everything2Everything.Tests/DependencyInjectionTests.cs +++ b/src/Everything2Everything.Tests/DependencyInjectionTests.cs @@ -22,7 +22,8 @@ public class DependencyInjectionTests using var sp = services.BuildServiceProvider(); var providers = sp.GetServices().ToList(); - Assert.Equal(14, providers.Count); // 하드코딩 14개와 동일 — Scrutor 누락/초과 방어 + Assert.Equal(15, providers.Count); // MarkdownProvider 추가로 총 15개 Provider + Assert.NotNull(sp.GetRequiredService()); Assert.NotNull(sp.GetRequiredService()); Assert.NotNull(sp.GetRequiredService()); @@ -42,6 +43,7 @@ public class DependencyInjectionTests Assert.NotNull(sp.GetRequiredService()); // ← PdfProvider 주입 Assert.NotNull(sp.GetRequiredService()); // ← PdfProvider 주입 Assert.NotNull(sp.GetRequiredService()); // ← ISettingsStore 주입 + Assert.NotNull(sp.GetRequiredService()); // AsSelfWithInterfaces: 구체 타입과 인터페이스가 동일 싱글턴 인스턴스를 공유. var magickAsSelf = sp.GetRequiredService(); @@ -79,7 +81,7 @@ public class DependencyInjectionTests { // 파사드 하위호환: DI 내부전환 후에도 CreateDefault가 동일하게 엔진/그래프를 구성. var engine = Everything2EverythingBootstrap.CreateDefault(); - Assert.Equal(14, engine.Providers.All.Count); + Assert.Equal(15, engine.Providers.All.Count); Assert.NotNull(engine.Providers.Graph.FindBestPath(".png", ".jpg")); Assert.NotNull(engine.Providers.Graph.FindBestPath(".svg", ".jpg", maxHops: 3)); } diff --git a/src/Everything2Everything.Tests/HtmlEndToEndAllOutputsTests.cs b/src/Everything2Everything.Tests/HtmlEndToEndAllOutputsTests.cs new file mode 100644 index 0000000..4d27682 --- /dev/null +++ b/src/Everything2Everything.Tests/HtmlEndToEndAllOutputsTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Everything2Everything.Core; +using Everything2Everything.Core.Converters; +using Everything2Everything.Core.Providers; +using Xunit; +using Xunit.Abstractions; + +namespace Everything2Everything.Tests; + +public class HtmlEndToEndAllOutputsTests +{ + private readonly ITestOutputHelper _output; + + public HtmlEndToEndAllOutputsTests(ITestOutputHelper output) + { + _output = output; + } + + private static string TempDir() + { + var d = Path.Combine(Path.GetTempPath(), "e2e_html_all_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(d); + return d; + } + + [Theory] + [InlineData(".pdf")] + [InlineData(".png")] + [InlineData(".jpg")] + [InlineData(".webp")] + [InlineData(".avif")] + [InlineData(".bmp")] + [InlineData(".tiff")] + [InlineData(".gif")] + public async Task Html_To_ImagesAndPdf_Via_Engine_Succeeds(string targetExt) + { + var dir = TempDir(); + var html = Path.Combine(dir, "sample.html"); + await File.WriteAllTextAsync(html, "

Test Page

Paragraph content

"); + + var engine = Everything2EverythingBootstrap.CreateDefault(); + var opt = new ConvertOptions + { + OutputLocation = OutputLocation.Custom, + CustomOutputDirectory = dir + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var result = await engine.ConvertOneAsync(html, targetExt, opt, null, cts.Token); + + _output.WriteLine($"Convert .html -> {targetExt}: Status={result.Status}, Msg={result.Message}"); + Assert.Equal(ConvertStatus.Success, result.Status); + Assert.NotEmpty(result.OutputPaths); + Assert.True(File.Exists(result.OutputPaths[0])); + Assert.True(new FileInfo(result.OutputPaths[0]).Length > 0); + } + + [Fact] + public async Task Html_To_Markdown_Via_Engine_TestsAvailability() + { + var dir = TempDir(); + var html = Path.Combine(dir, "sample.html"); + await File.WriteAllTextAsync(html, "

Markdown Title

Bold text

"); + + var engine = Everything2EverythingBootstrap.CreateDefault(); + var opt = new ConvertOptions + { + OutputLocation = OutputLocation.Custom, + CustomOutputDirectory = dir + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var result = await engine.ConvertOneAsync(html, ".md", opt, null, cts.Token); + + _output.WriteLine($"Convert .html -> .md: Status={result.Status}, Msg={result.Message}"); + } +} diff --git a/src/Everything2Everything.Tests/HtmlProviderTests.cs b/src/Everything2Everything.Tests/HtmlProviderTests.cs new file mode 100644 index 0000000..bdc152c --- /dev/null +++ b/src/Everything2Everything.Tests/HtmlProviderTests.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Everything2Everything.Core; +using Everything2Everything.Core.Converters; +using Everything2Everything.Core.Providers; +using Xunit; + +namespace Everything2Everything.Tests; + +public class HtmlProviderTests +{ + private static string TempDir() + { + var d = Path.Combine(Path.GetTempPath(), "e2e_html_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(d); + return d; + } + + [Fact] + public async Task HtmlProvider_ConvertAsync_HtmlToPdf_Succeeds() + { + var dir = TempDir(); + var html = Path.Combine(dir, "test.html"); + await File.WriteAllTextAsync(html, "

Hello HTML Test

Testing HTML to PDF

"); + + var provider = new HtmlProvider(); + var availability = await provider.CheckAvailabilityAsync(); + if (!availability.IsReady) return; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var result = await provider.ConvertAsync(html, dir, ".pdf", new ConvertOptions(), null, cts.Token); + + Assert.Equal(ConvertStatus.Success, result.Status); + Assert.NotEmpty(result.OutputPaths); + Assert.True(File.Exists(result.OutputPaths[0])); + Assert.True(new FileInfo(result.OutputPaths[0]).Length > 0); + } + + [Fact] + public async Task HtmlProvider_ConvertAsync_HtmlToPng_Succeeds() + { + var dir = TempDir(); + var html = Path.Combine(dir, "test.html"); + await File.WriteAllTextAsync(html, "

Hello HTML Test

Testing HTML to PNG

"); + + var provider = new HtmlProvider(); + var availability = await provider.CheckAvailabilityAsync(); + if (!availability.IsReady) return; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var result = await provider.ConvertAsync(html, dir, ".png", new ConvertOptions(), null, cts.Token); + + Assert.Equal(ConvertStatus.Success, result.Status); + Assert.NotEmpty(result.OutputPaths); + Assert.True(File.Exists(result.OutputPaths[0])); + Assert.True(new FileInfo(result.OutputPaths[0]).Length > 0); + } +} diff --git a/src/Everything2Everything.Tests/MarkdownProviderTests.cs b/src/Everything2Everything.Tests/MarkdownProviderTests.cs new file mode 100644 index 0000000..aebded2 --- /dev/null +++ b/src/Everything2Everything.Tests/MarkdownProviderTests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Everything2Everything.Core; +using Everything2Everything.Core.Converters; +using Everything2Everything.Core.Providers; +using Xunit; + +namespace Everything2Everything.Tests; + +public class MarkdownProviderTests +{ + private static string TempDir() + { + var d = Path.Combine(Path.GetTempPath(), "e2e_md_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(d); + return d; + } + + [Fact] + public async Task MarkdownProvider_HtmlToMd_ConvertsProperly() + { + var dir = TempDir(); + var html = Path.Combine(dir, "input.html"); + await File.WriteAllTextAsync(html, "

Title Here

Paragraph with bold text

"); + + var provider = new MarkdownProvider(); + var res = await provider.ConvertAsync(html, dir, ".md", new ConvertOptions(), null, CancellationToken.None); + + Assert.Equal(ConvertStatus.Success, res.Status); + var content = await File.ReadAllTextAsync(res.OutputPaths[0]); + Assert.Contains("# Title Here", content); + Assert.Contains("**bold**", content); + } + + [Fact] + public async Task MarkdownProvider_MdToHtml_ConvertsProperly() + { + var dir = TempDir(); + var md = Path.Combine(dir, "input.md"); + await File.WriteAllTextAsync(md, "# Hello Markdown\n\nThis is a *test*."); + + var provider = new MarkdownProvider(); + var res = await provider.ConvertAsync(md, dir, ".html", new ConvertOptions(), null, CancellationToken.None); + + Assert.Equal(ConvertStatus.Success, res.Status); + var content = await File.ReadAllTextAsync(res.OutputPaths[0]); + Assert.Contains("Hello Markdown", content); + Assert.Contains("test", content); + } + + [Fact] + public async Task MarkdownProvider_Availability_IsAlwaysReady() + { + var provider = new MarkdownProvider(); + var avail = await provider.CheckAvailabilityAsync(); + Assert.True(avail.IsReady); + Assert.Equal(ProviderStatus.Available, provider.Capability.Status); + } + + [Fact] + public async Task Engine_HtmlToMd_RoutesToMarkdownProvider() + { + var dir = TempDir(); + var html = Path.Combine(dir, "input.html"); + await File.WriteAllTextAsync(html, "

Section

Content

"); + + var engine = Everything2EverythingBootstrap.CreateDefault(); + var path = engine.Providers.Graph.FindBestPath(".html", ".md"); + Assert.NotNull(path); + Assert.Single(path); + Assert.IsType(path![0].Provider); + + var opt = new ConvertOptions { OutputLocation = OutputLocation.Custom, CustomOutputDirectory = dir }; + var res = await engine.ConvertOneAsync(html, ".md", opt); + Assert.Equal(ConvertStatus.Success, res.Status); + } +} diff --git a/src/Everything2Everything.Tests/MatrixIntegrityTests.cs b/src/Everything2Everything.Tests/MatrixIntegrityTests.cs new file mode 100644 index 0000000..3bb944e --- /dev/null +++ b/src/Everything2Everything.Tests/MatrixIntegrityTests.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Everything2Everything.Core; +using Everything2Everything.Core.Providers; +using Xunit; +using Xunit.Abstractions; + +namespace Everything2Everything.Tests; + +public class MatrixIntegrityTests +{ + private readonly ITestOutputHelper _output; + + public MatrixIntegrityTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void Audit_AllRegisteredProviders_AndPrintMatrix() + { + var engine = Everything2EverythingBootstrap.CreateDefault(); + var providers = engine.Providers.All.ToList(); + + _output.WriteLine($"=== Total Registered Providers: {providers.Count} ==="); + foreach (var p in providers) + { + var cap = p.Capability; + _output.WriteLine($"Provider [{cap.Id}] {cap.DisplayName} | Status: {cap.Status} | Pairs: {cap.SupportedConversions.Count}"); + var missingDeps = cap.ExternalDependencies.Where(d => d.IsRequired).Select(d => d.Name).ToList(); + if (missingDeps.Any()) + { + _output.WriteLine($" Required deps: {string.Join(", ", missingDeps)}"); + } + } + Assert.NotEmpty(providers); + } + + [Fact] + public void Audit_HtmlReachability_InGraph() + { + var engine = Everything2EverythingBootstrap.CreateDefault(); + var outputs = engine.Providers.OutputsForInput(".html"); + _output.WriteLine($"HTML Reachable Outputs ({outputs.Count}): {string.Join(", ", outputs)}"); + + Assert.Contains(".pdf", outputs); + Assert.Contains(".png", outputs); + Assert.Contains(".jpg", outputs); + Assert.Contains(".webp", outputs); + Assert.Contains(".md", outputs); + } +}