Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50072124a9 | ||
|
|
a4c64bafb9 | ||
|
|
7e848a1a3f | ||
|
|
4e5285e27c |
13 changed files with 711 additions and 76 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com">
|
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com">
|
||||||
<Identity Name="Everything2Everything.YunChan" Publisher="CN=Everything2EverythingDev" Version="1.0.21.0" ProcessorArchitecture="x64" />
|
<Identity Name="Everything2Everything.YunChan" Publisher="CN=Everything2EverythingDev" Version="1.0.22.0" ProcessorArchitecture="x64" />
|
||||||
<Properties>
|
<Properties>
|
||||||
<DisplayName>Everything2Everything</DisplayName>
|
<DisplayName>Everything2Everything</DisplayName>
|
||||||
<PublisherDisplayName>YunChan</PublisherDisplayName>
|
<PublisherDisplayName>YunChan</PublisherDisplayName>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.0.21</Version>
|
<Version>1.0.22</Version>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
@ -35,6 +35,29 @@
|
||||||
<Resource Include="Assets\**\*.png" />
|
<Resource Include="Assets\**\*.png" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Windows 11 모던 1차 컨텍스트 메뉴용 Shell 확장 DLL 및 매니페스트 번들 -->
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\Everything2Everything.Shell\x64\Release\Everything2Everything.Shell.dll" Condition="Exists('..\Everything2Everything.Shell\x64\Release\Everything2Everything.Shell.dll')">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</None>
|
||||||
|
<None Include="..\..\packaging\Layout\Everything2Everything.Shell.dll" Condition="!Exists('..\Everything2Everything.Shell\x64\Release\Everything2Everything.Shell.dll') and Exists('..\..\packaging\Layout\Everything2Everything.Shell.dll')">
|
||||||
|
<Link>Everything2Everything.Shell.dll</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</None>
|
||||||
|
<None Include="..\..\packaging\Package.appxmanifest" Condition="Exists('..\..\packaging\Package.appxmanifest')">
|
||||||
|
<Link>AppxManifest.xml</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</None>
|
||||||
|
<None Include="..\..\packaging\Assets\**\*" Condition="Exists('..\..\packaging\Assets')">
|
||||||
|
<Link>Assets\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,3 +67,4 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,69 @@ public static class ContextMenuRegistrar
|
||||||
private const string MainLabel = "Everything2Everything으로 변환";
|
private const string MainLabel = "Everything2Everything으로 변환";
|
||||||
|
|
||||||
private const string SubmenuKeyPrefix = "Everything2Everything.SubMenu.";
|
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<ContextMenuItemPlan> Items { get; init; } = Array.Empty<ContextMenuItemPlan>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ContextMenuItemPlan>();
|
||||||
|
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 =
|
private static readonly (string Ext, string Label, string SortPrefix)[] PopularOutputs =
|
||||||
{
|
{
|
||||||
|
|
@ -44,16 +107,16 @@ public static class ContextMenuRegistrar
|
||||||
public static void Register(ConversionEngine engine)
|
public static void Register(ConversionEngine engine)
|
||||||
{
|
{
|
||||||
var exe = GetAppExecutablePath();
|
var exe = GetAppExecutablePath();
|
||||||
var icon = exe + ",0";
|
|
||||||
|
|
||||||
foreach (var ext in CollectInputExtensions(engine))
|
foreach (var ext in CollectInputExtensions(engine))
|
||||||
{
|
{
|
||||||
var availableOutputs = GetAvailableOutputs(engine, ext);
|
var plan = BuildVerbPlan(engine, ext, exe);
|
||||||
if (availableOutputs.Count == 0) continue;
|
if (plan.Items.Count == 0) continue;
|
||||||
|
|
||||||
WriteCascade(ext, exe, icon, availableOutputs);
|
WriteCascade(plan, ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TryRegisterWindows11SparsePackage();
|
||||||
NotifyShell();
|
NotifyShell();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,9 +129,44 @@ public static class ContextMenuRegistrar
|
||||||
DeleteVerb(ext, "Everything2Everything.Dialog");
|
DeleteVerb(ext, "Everything2Everything.Dialog");
|
||||||
DeleteSubmenuTree(ext);
|
DeleteSubmenuTree(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TryUnregisterWindows11SparsePackage();
|
||||||
NotifyShell();
|
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<string> CollectInputExtensions(ConversionEngine engine)
|
private static IEnumerable<string> CollectInputExtensions(ConversionEngine engine)
|
||||||
{
|
{
|
||||||
return engine.Providers.Implemented
|
return engine.Providers.Implemented
|
||||||
|
|
@ -79,28 +177,25 @@ public static class ContextMenuRegistrar
|
||||||
.Distinct();
|
.Distinct();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WriteCascade(
|
private static void WriteCascade(ContextMenuVerbPlan plan, string ext)
|
||||||
string ext,
|
|
||||||
string exe,
|
|
||||||
string icon,
|
|
||||||
IReadOnlyList<(string Ext, string Label, string SortPrefix)> availableOutputs)
|
|
||||||
{
|
{
|
||||||
var submenuKeyName = SubmenuKeyPrefix + ext.TrimStart('.');
|
// 1. 루트 키 등록 (Shift 없는 일반 우클릭에서 즉시 서브메뉴 노출)
|
||||||
|
using (var verbKey = Registry.CurrentUser.CreateSubKey(plan.RootKeyPath, writable: true)
|
||||||
var verbPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{MainVerb}";
|
?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {plan.RootKeyPath}"))
|
||||||
using (var verbKey = Registry.CurrentUser.CreateSubKey(verbPath, writable: true)
|
|
||||||
?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {verbPath}"))
|
|
||||||
{
|
{
|
||||||
verbKey.SetValue(null, MainLabel, RegistryValueKind.String);
|
verbKey.SetValue(null, plan.RootLabel, RegistryValueKind.String);
|
||||||
verbKey.SetValue("MUIVerb", MainLabel, RegistryValueKind.String);
|
verbKey.SetValue("MUIVerb", plan.RootLabel, RegistryValueKind.String);
|
||||||
verbKey.SetValue("Icon", icon, RegistryValueKind.String);
|
verbKey.SetValue("Icon", plan.IconPath, RegistryValueKind.String);
|
||||||
verbKey.SetValue("SubCommands", "", RegistryValueKind.String);
|
verbKey.SetValue("SubCommands", plan.SubCommandsValue ?? "", RegistryValueKind.String);
|
||||||
verbKey.SetValue("ExtendedSubCommandsKey", submenuKeyName, RegistryValueKind.String);
|
|
||||||
|
// 구버전에서 Shift 키를 강제하던 ExtendedSubCommandsKey 제거
|
||||||
|
try { verbKey.DeleteValue("ExtendedSubCommandsKey", throwOnMissingValue: false); } catch { }
|
||||||
try { verbKey.DeleteSubKeyTree("command", throwOnMissingSubKey: false); } catch { }
|
try { verbKey.DeleteSubKeyTree("command", throwOnMissingSubKey: false); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
var submenuShellPath = $@"Software\Classes\{submenuKeyName}\shell";
|
// 2. 루트 직하위 shell 키 정리 및 서브메뉴 항목 등록
|
||||||
using (var existing = Registry.CurrentUser.OpenSubKey(submenuShellPath, writable: true))
|
var shellRootPath = $@"{plan.RootKeyPath}\shell";
|
||||||
|
using (var existing = Registry.CurrentUser.OpenSubKey(shellRootPath, writable: true))
|
||||||
{
|
{
|
||||||
if (existing is not null)
|
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('.')}";
|
using var key = Registry.CurrentUser.CreateSubKey(item.SubKeyPath, writable: true)
|
||||||
var cliExt = outExt.TrimStart('.');
|
?? throw new InvalidOperationException($"서브메뉴 키 생성 실패: {item.SubKeyPath}");
|
||||||
WriteSubmenuItem(submenuKeyName, subVerbName, outLabel, icon,
|
|
||||||
$"\"{exe}\" to {cliExt} \"%1\"");
|
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,
|
// 3. 구버전 독립 SubMenu 키 정리 (이전 버전 찌꺼기 제거)
|
||||||
$"\"{exe}\" dialog \"%1\"");
|
DeleteSubmenuTree(ext);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void DeleteVerb(string ext, string verb)
|
private static void DeleteVerb(string ext, string verb)
|
||||||
|
|
@ -172,6 +258,70 @@ public static class ContextMenuRegistrar
|
||||||
+ Path.DirectorySeparatorChar + "Everything2Everything.exe";
|
+ 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()
|
private static void NotifyShell()
|
||||||
{
|
{
|
||||||
try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); }
|
try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); }
|
||||||
|
|
|
||||||
|
|
@ -1213,7 +1213,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC
|
||||||
{
|
{
|
||||||
ContextMenuRegistrar.Register(_engine);
|
ContextMenuRegistrar.Register(_engine);
|
||||||
MessageBox.Show(this,
|
MessageBox.Show(this,
|
||||||
"컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".",
|
"컨텍스트 메뉴를 성공적으로 등록했습니다.\n파일 우클릭 시 \"Everything2Everything으로 변환\" 메뉴가 즉시 표시됩니다.",
|
||||||
"Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information);
|
"Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,22 @@ namespace Everything2Everything.Core.Converters;
|
||||||
// txt ↔ md → trivial copy
|
// txt ↔ md → trivial copy
|
||||||
public sealed class DocumentProvider : IConverterProvider
|
public sealed class DocumentProvider : IConverterProvider
|
||||||
{
|
{
|
||||||
private static readonly string[] Inputs =
|
private static readonly string[] OfficeInputs = { ".docx", ".doc", ".hwp", ".hwpx" };
|
||||||
{ ".html", ".htm", ".hwp", ".hwpx", ".docx", ".doc", ".md", ".markdown", ".txt" };
|
private static readonly string[] OfficeOutputs = { ".html", ".docx", ".md", ".txt" };
|
||||||
|
private static readonly string[] TextInputsToDocx = { ".html", ".htm", ".md", ".markdown", ".txt" };
|
||||||
private static readonly string[] Outputs = { ".html", ".docx", ".md", ".txt" };
|
|
||||||
|
|
||||||
public ProviderCapability Capability { get; } = new(
|
public ProviderCapability Capability { get; } = new(
|
||||||
Id: "document",
|
Id: "document",
|
||||||
DisplayName: "문서 텍스트 변환 (HTML/HWP/DOCX/MD/TXT)",
|
DisplayName: "문서 텍스트 변환 (DOCX/HWP/오피스)",
|
||||||
SupportedConversions: ProviderCapability.PairsFromMatrix(Inputs, Outputs),
|
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,
|
Status: ProviderStatus.RequiresExternal,
|
||||||
Summary: "HTML·HWP·DOCX·Markdown·TXT 사이의 양방향 텍스트 변환 (LibreOffice + Markdig + ReverseMarkdown).",
|
Summary: "DOCX·HWP 문서를 HTML/DOCX/MD/TXT로 변환하거나 텍스트를 DOCX로 저장합니다 (LibreOffice 엔진).",
|
||||||
ExternalDependencies: new[]
|
ExternalDependencies: new[]
|
||||||
{
|
{
|
||||||
new ExternalDependency(
|
new ExternalDependency(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using Everything2Everything.Core.Providers;
|
using Everything2Everything.Core.Providers;
|
||||||
using ImageMagick;
|
using ImageMagick;
|
||||||
|
|
@ -165,7 +166,25 @@ public sealed class HtmlProvider : IConverterProvider
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var dispatcher = Dispatcher.CurrentDispatcher;
|
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();
|
Dispatcher.Run();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -181,16 +200,15 @@ public sealed class HtmlProvider : IConverterProvider
|
||||||
return tcs.Task;
|
return tcs.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task RunCaptureOnDispatcher(
|
private static async Task<byte[]> RunCaptureOnDispatcher(
|
||||||
Func<CoreWebView2, IProgress<double>?, Task<byte[]>> capture,
|
Func<CoreWebView2, IProgress<double>?, Task<byte[]>> capture,
|
||||||
Dispatcher dispatcher,
|
|
||||||
string sourcePath,
|
string sourcePath,
|
||||||
ConvertOptions options,
|
ConvertOptions options,
|
||||||
IProgress<double>? progress,
|
IProgress<double>? progress,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken)
|
||||||
TaskCompletionSource<byte[]> tcs)
|
|
||||||
{
|
{
|
||||||
CoreWebView2Controller? controller = null;
|
CoreWebView2Controller? controller = null;
|
||||||
|
HwndSource? hwndSource = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userDataFolder = Path.Combine(
|
var userDataFolder = Path.Combine(
|
||||||
|
|
@ -198,15 +216,23 @@ public sealed class HtmlProvider : IConverterProvider
|
||||||
"Everything2Everything", "WebView2");
|
"Everything2Everything", "WebView2");
|
||||||
Directory.CreateDirectory(userDataFolder);
|
Directory.CreateDirectory(userDataFolder);
|
||||||
|
|
||||||
var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder).ConfigureAwait(true);
|
var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder);
|
||||||
progress?.Report(0.15);
|
progress?.Report(0.15);
|
||||||
|
|
||||||
controller = await env.CreateCoreWebView2ControllerAsync(new IntPtr(-3)).ConfigureAwait(true);
|
|
||||||
|
|
||||||
int width = options.HtmlRender.ViewportWidth > 0 ? options.HtmlRender.ViewportWidth : 1280;
|
int width = options.HtmlRender.ViewportWidth > 0 ? options.HtmlRender.ViewportWidth : 1280;
|
||||||
int height = options.HtmlRender.ViewportHeight ?? 720;
|
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.Bounds = new System.Drawing.Rectangle(0, 0, width, height);
|
||||||
controller.IsVisible = false;
|
controller.IsVisible = true;
|
||||||
|
|
||||||
var web = controller.CoreWebView2;
|
var web = controller.CoreWebView2;
|
||||||
progress?.Report(0.3);
|
progress?.Report(0.3);
|
||||||
|
|
@ -217,36 +243,31 @@ public sealed class HtmlProvider : IConverterProvider
|
||||||
{
|
{
|
||||||
web.NavigationCompleted -= navHandler!;
|
web.NavigationCompleted -= navHandler!;
|
||||||
if (e.IsSuccess) navTcs.TrySetResult(true);
|
if (e.IsSuccess) navTcs.TrySetResult(true);
|
||||||
else navTcs.TrySetException(new InvalidOperationException(
|
else navTcs.TrySetException(new InvalidOperationException($"내비게이션 실패: {e.WebErrorStatus}"));
|
||||||
$"내비게이션 실패: {e.WebErrorStatus}"));
|
|
||||||
};
|
};
|
||||||
web.NavigationCompleted += navHandler;
|
web.NavigationCompleted += navHandler;
|
||||||
|
|
||||||
var fileUri = new Uri(sourcePath).AbsoluteUri;
|
var fileUri = new Uri(Path.GetFullPath(sourcePath)).AbsoluteUri;
|
||||||
web.Navigate(fileUri);
|
web.Navigate(fileUri);
|
||||||
|
|
||||||
using (cancellationToken.Register(() => navTcs.TrySetCanceled()))
|
using (cancellationToken.Register(() => navTcs.TrySetCanceled()))
|
||||||
{
|
{
|
||||||
await navTcs.Task.ConfigureAwait(true);
|
await navTcs.Task;
|
||||||
}
|
}
|
||||||
progress?.Report(0.5);
|
progress?.Report(0.5);
|
||||||
|
|
||||||
if (options.HtmlRender.WaitMilliseconds > 0)
|
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);
|
progress?.Report(0.65);
|
||||||
|
|
||||||
var bytes = await capture(web, progress).ConfigureAwait(true);
|
var bytes = await capture(web, progress);
|
||||||
tcs.TrySetResult(bytes);
|
return bytes;
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
tcs.TrySetException(ex);
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
try { controller?.Close(); } catch { }
|
try { controller?.Close(); } catch { }
|
||||||
dispatcher.BeginInvokeShutdown(DispatcherPriority.Background);
|
try { hwndSource?.Dispose(); } catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
117
src/Everything2Everything.Core/Converters/MarkdownProvider.cs
Normal file
117
src/Everything2Everything.Core/Converters/MarkdownProvider.cs
Normal file
|
|
@ -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<ExternalDependency>(),
|
||||||
|
RoadmapNote: null);
|
||||||
|
|
||||||
|
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(ProviderAvailability.Ready);
|
||||||
|
|
||||||
|
public async Task<ConvertResult> ConvertAsync(
|
||||||
|
string sourcePath,
|
||||||
|
string outputDirectory,
|
||||||
|
string outputExtension,
|
||||||
|
ConvertOptions options,
|
||||||
|
IProgress<double>? 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 = $"<!DOCTYPE html><html lang=\"ko\"><head><meta charset=\"utf-8\"><title>{Path.GetFileNameWithoutExtension(mdPath)}</title></head><body>{body}</body></html>";
|
||||||
|
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", "<br/>\n");
|
||||||
|
var fullHtml = $"<!DOCTYPE html><html lang=\"ko\"><head><meta charset=\"utf-8\"><title>{Path.GetFileNameWithoutExtension(txtPath)}</title></head><body><pre style=\"font-family: 'Segoe UI', sans-serif; white-space: pre-wrap;\">{encoded}</pre></body></html>";
|
||||||
|
await File.WriteAllTextAsync(htmlPath, fullHtml, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,4 +56,44 @@ public class ContextMenuRegistrarTests
|
||||||
Assert.DoesNotContain(outputs, o => o.Ext == ".gif");
|
Assert.DoesNotContain(outputs, o => o.Ext == ".gif");
|
||||||
Assert.DoesNotContain(outputs, o => o.Ext == ".mp4");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ public class DependencyInjectionTests
|
||||||
using var sp = services.BuildServiceProvider();
|
using var sp = services.BuildServiceProvider();
|
||||||
|
|
||||||
var providers = sp.GetServices<IConverterProvider>().ToList();
|
var providers = sp.GetServices<IConverterProvider>().ToList();
|
||||||
Assert.Equal(14, providers.Count); // 하드코딩 14개와 동일 — Scrutor 누락/초과 방어
|
Assert.Equal(15, providers.Count); // MarkdownProvider 추가로 총 15개 Provider
|
||||||
|
Assert.NotNull(sp.GetRequiredService<MarkdownProvider>());
|
||||||
|
|
||||||
Assert.NotNull(sp.GetRequiredService<ProviderRegistry>());
|
Assert.NotNull(sp.GetRequiredService<ProviderRegistry>());
|
||||||
Assert.NotNull(sp.GetRequiredService<ConversionEngine>());
|
Assert.NotNull(sp.GetRequiredService<ConversionEngine>());
|
||||||
|
|
@ -42,6 +43,7 @@ public class DependencyInjectionTests
|
||||||
Assert.NotNull(sp.GetRequiredService<OcrProvider>()); // ← PdfProvider 주입
|
Assert.NotNull(sp.GetRequiredService<OcrProvider>()); // ← PdfProvider 주입
|
||||||
Assert.NotNull(sp.GetRequiredService<HwpxProvider>()); // ← PdfProvider 주입
|
Assert.NotNull(sp.GetRequiredService<HwpxProvider>()); // ← PdfProvider 주입
|
||||||
Assert.NotNull(sp.GetRequiredService<LlmProvider>()); // ← ISettingsStore 주입
|
Assert.NotNull(sp.GetRequiredService<LlmProvider>()); // ← ISettingsStore 주입
|
||||||
|
Assert.NotNull(sp.GetRequiredService<MarkdownProvider>());
|
||||||
|
|
||||||
// AsSelfWithInterfaces: 구체 타입과 인터페이스가 동일 싱글턴 인스턴스를 공유.
|
// AsSelfWithInterfaces: 구체 타입과 인터페이스가 동일 싱글턴 인스턴스를 공유.
|
||||||
var magickAsSelf = sp.GetRequiredService<MagickProvider>();
|
var magickAsSelf = sp.GetRequiredService<MagickProvider>();
|
||||||
|
|
@ -79,7 +81,7 @@ public class DependencyInjectionTests
|
||||||
{
|
{
|
||||||
// 파사드 하위호환: DI 내부전환 후에도 CreateDefault가 동일하게 엔진/그래프를 구성.
|
// 파사드 하위호환: DI 내부전환 후에도 CreateDefault가 동일하게 엔진/그래프를 구성.
|
||||||
var engine = Everything2EverythingBootstrap.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(".png", ".jpg"));
|
||||||
Assert.NotNull(engine.Providers.Graph.FindBestPath(".svg", ".jpg", maxHops: 3));
|
Assert.NotNull(engine.Providers.Graph.FindBestPath(".svg", ".jpg", maxHops: 3));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, "<html><body><h1 style='color:red;'>Test Page</h1><p>Paragraph content</p></body></html>");
|
||||||
|
|
||||||
|
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, "<html><body><h1>Markdown Title</h1><p>Bold <b>text</b></p></body></html>");
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/Everything2Everything.Tests/HtmlProviderTests.cs
Normal file
60
src/Everything2Everything.Tests/HtmlProviderTests.cs
Normal file
|
|
@ -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, "<html><body><h1>Hello HTML Test</h1><p>Testing HTML to PDF</p></body></html>");
|
||||||
|
|
||||||
|
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, "<html><body><h1>Hello HTML Test</h1><p>Testing HTML to PNG</p></body></html>");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/Everything2Everything.Tests/MarkdownProviderTests.cs
Normal file
79
src/Everything2Everything.Tests/MarkdownProviderTests.cs
Normal file
|
|
@ -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, "<h1>Title Here</h1><p>Paragraph with <strong>bold</strong> text</p>");
|
||||||
|
|
||||||
|
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</h1>", content);
|
||||||
|
Assert.Contains("<em>test</em>", 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, "<h2>Section</h2><p>Content</p>");
|
||||||
|
|
||||||
|
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||||
|
var path = engine.Providers.Graph.FindBestPath(".html", ".md");
|
||||||
|
Assert.NotNull(path);
|
||||||
|
Assert.Single(path);
|
||||||
|
Assert.IsType<MarkdownProvider>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Everything2Everything.Tests/MatrixIntegrityTests.cs
Normal file
56
src/Everything2Everything.Tests/MatrixIntegrityTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue