diff --git a/README.md b/README.md
index f72f957..4c9d5f0 100644
--- a/README.md
+++ b/README.md
@@ -63,9 +63,25 @@ dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
| 단계 | 상태 | 내용 |
|---|---|---|
-| Phase 1 | ✅ | 레지스트리 컨텍스트 메뉴 (Win11 "추가 옵션 표시"), 핵심 변환(이미지·HEIC·RAW·PDF·DOCX), Fluent UI |
-| Phase 2 | ✅ | C++ IExplorerCommand DLL, MSIX 패키징, 자체 서명 인증서, GitHub Releases 자동화 — `packaging/README.md` 참조 |
+| Phase 1 | ✅ | 레지스트리 컨텍스트 메뉴 (Win11 "추가 옵션 표시"), 핵심 변환, Fluent UI |
+| Phase 2 | ✅ | C++ IExplorerCommand DLL + MSIX Sparse Package |
| Phase 3 | ✅ | HTML(WebView2), HWP/HWPX(LibreOffice + H2Orestart) 실구현 |
+| Phase 4 | ✅ | **자체 서명 MSIX 자동화** — `packaging/BuildAndSign.ps1` 한 방으로 인증서 생성 + 서명 + 패키지 |
+| Phase 5 | ✅ | GitHub 리모트 + Actions 릴리즈 워크플로 |
+| Phase 6 | ✅ | Past Results 영구 저장 (`%LocalAppData%\EverythingToJpeg\history.jsonl`) |
+| Phase 7 | ✅ | 단축키 (Ctrl+O 추가, Ctrl+Enter 변환, Esc 닫기, F5 새로고침), 코드 정리 |
+| Phase 8 | ✅ | 시작 시 Provider 가용성 자동 체크, 사이드바에 외부 도구 필요 안내 |
+
+## 키보드 단축키
+
+| 키 | 동작 |
+|---|---|
+| Ctrl+O | 파일 추가 |
+| Ctrl+Enter | Process Queue (변환 시작) |
+| Esc | 창 닫기 |
+| F5 | 통계 새로고침 |
+| Active Queue 행 클릭 | 우측 Preview에 즉시 표시 |
+| Past Results 행 클릭 | 원본 파일이 있으면 Preview 표시 |
## 두 가지 사용 방식
diff --git a/packaging/BuildAndSign.ps1 b/packaging/BuildAndSign.ps1
new file mode 100644
index 0000000..2440418
--- /dev/null
+++ b/packaging/BuildAndSign.ps1
@@ -0,0 +1,68 @@
+#Requires -Version 5.1
+# 한방에 빌드+자체서명: 인증서 자동 생성 → MSIX 빌드 → 서명까지 일관 처리.
+# 산출:
+# - packaging/dist/EverythingToJpeg-x64.msix (서명됨)
+# - packaging/EverythingToJpeg-DevCert.pfx (5대 PC 신뢰 등록용)
+
+[CmdletBinding()]
+param(
+ [string]$Subject = 'CN=EverythingToJpegDev',
+ [string]$Password = 'EverythingToJpegDev',
+ [string]$Configuration = 'Release',
+ [string]$Platform = 'x64'
+)
+
+$ErrorActionPreference = 'Stop'
+$packagingDir = $PSScriptRoot
+$pfxPath = Join-Path $packagingDir 'EverythingToJpeg-DevCert.pfx'
+$securePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
+
+# ---- 1) 인증서 ----
+$existing = Get-ChildItem -Path 'Cert:\CurrentUser\My' -ErrorAction SilentlyContinue |
+ Where-Object { $_.Subject -eq $Subject } |
+ Sort-Object NotAfter -Descending |
+ Select-Object -First 1
+
+if (-not $existing) {
+ Write-Host "[1/3] 자체 서명 인증서 생성: $Subject"
+ $existing = New-SelfSignedCertificate `
+ -Type CodeSigningCert `
+ -Subject $Subject `
+ -KeyAlgorithm RSA `
+ -KeyLength 3072 `
+ -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' `
+ -KeyExportPolicy Exportable `
+ -KeyUsage DigitalSignature `
+ -CertStoreLocation 'Cert:\CurrentUser\My' `
+ -HashAlgorithm SHA256 `
+ -NotAfter (Get-Date).AddYears(5) `
+ -FriendlyName 'EverythingToJpeg Dev'
+}
+else {
+ Write-Host "[1/3] 기존 인증서 재사용 (Thumbprint $($existing.Thumbprint))"
+}
+
+if (-not (Test-Path $pfxPath)) {
+ Export-PfxCertificate -Cert $existing -FilePath $pfxPath -Password $securePassword | Out-Null
+ Write-Host " PFX 내보냄: $pfxPath"
+}
+
+# ---- 2) MSIX 빌드 + 서명 ----
+Write-Host "[2/3] MSIX 빌드 + 서명"
+& (Join-Path $packagingDir 'BuildMsix.ps1') `
+ -Configuration $Configuration `
+ -Platform $Platform `
+ -Sign `
+ -CertThumbprint $existing.Thumbprint
+
+# ---- 3) 안내 ----
+Write-Host ''
+Write-Host '[3/3] 완료. 다음 단계:'
+Write-Host ' 1. PFX 파일을 5대 PC 각각에 복사:'
+Write-Host " $pfxPath"
+Write-Host ' 2. 각 PC에서 관리자 PowerShell:'
+Write-Host ' cd packaging'
+Write-Host " .\Install-EverythingToJpeg.ps1 -PfxPath .\EverythingToJpeg-DevCert.pfx -MsixPath .\dist\EverythingToJpeg-x64.msix"
+Write-Host ' PFX 비밀번호:' $Password
+Write-Host ''
+Write-Host ' 3. 우클릭 → JPEG로 빠른 변환 / JPEG로 변환… 이 메인 메뉴에 노출됨.'
diff --git a/packaging/Install-EverythingToJpeg.ps1 b/packaging/Install-EverythingToJpeg.ps1
index 74518c8..df2e633 100644
--- a/packaging/Install-EverythingToJpeg.ps1
+++ b/packaging/Install-EverythingToJpeg.ps1
@@ -8,7 +8,8 @@
param(
[Parameter(Mandatory)] [string]$PfxPath,
[Parameter(Mandatory)] [string]$MsixPath,
- [securestring]$PfxPassword
+ [securestring]$PfxPassword,
+ [string]$Password = 'EverythingToJpegDev'
)
$ErrorActionPreference = 'Stop'
@@ -17,7 +18,7 @@ if (-not (Test-Path $PfxPath)) { throw "PFX 파일을 찾을 수 없습니다: $
if (-not (Test-Path $MsixPath)) { throw "MSIX 파일을 찾을 수 없습니다: $MsixPath" }
if (-not $PfxPassword) {
- $PfxPassword = Read-Host -AsSecureString -Prompt 'PFX 비밀번호'
+ $PfxPassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
}
Write-Host '[1/3] 인증서를 LocalMachine\TrustedPeople에 임포트…'
diff --git a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml
deleted file mode 100644
index 8e776b4..0000000
--- a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml
+++ /dev/null
@@ -1,323 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs
deleted file mode 100644
index 8670321..0000000
--- a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs
+++ /dev/null
@@ -1,376 +0,0 @@
-using System.Collections.ObjectModel;
-using System.ComponentModel;
-using System.Globalization;
-using System.Runtime.CompilerServices;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Controls.Primitives;
-using System.Windows.Input;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using EverythingToJpeg.Core;
-using Wpf.Ui.Controls;
-
-namespace EverythingToJpeg.App.Views;
-
-public partial class ConvertWindow : FluentWindow
-{
- private static readonly string DialogLogPath =
- Path.Combine(Path.GetTempPath(), "EverythingToJpeg_dialog.log");
-
- private readonly ConversionEngine _engine;
- private readonly ObservableCollection _entries = new();
- private CancellationTokenSource? _cts;
- private OutputLocation _outputMode = OutputLocation.SubfolderBesideSource;
- private NameCollision _conflictRule = NameCollision.AppendNumber;
-
- public ConvertWindow(ConversionEngine engine, IReadOnlyList initialFiles)
- {
- _engine = engine;
- InitializeComponent();
- FilesList.ItemsSource = _entries;
- AddFiles(initialFiles);
- }
-
- private static void DiagLog(string line)
- {
- try { File.AppendAllText(DialogLogPath,
- $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {line}{Environment.NewLine}"); }
- catch { }
- }
-
- // ============== Files ==============
-
- private void AddFiles(IEnumerable paths)
- {
- var existing = new HashSet(_entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
- foreach (var p in paths)
- {
- if (!File.Exists(p)) continue;
- if (existing.Contains(p)) continue;
-
- var entry = ConvertFileEntry.From(p, _engine);
- _entries.Add(entry);
- _ = entry.LoadThumbnailAsync();
- }
- UpdateSummary();
- }
-
- private void UpdateSummary()
- {
- FilesSummaryText.Text = _entries.Count == 0
- ? "비어 있음 — 파일을 끌어다 놓거나 추가하세요"
- : $"{_entries.Count}개 파일";
- }
-
- private void OnDragOver(object sender, DragEventArgs e)
- {
- e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
- ? DragDropEffects.Copy : DragDropEffects.None;
- e.Handled = true;
- }
-
- private void OnFilesDropped(object sender, DragEventArgs e)
- {
- if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
- if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return;
- AddFiles(ExpandPaths(paths));
- }
-
- private void OnAddFilesClick(object sender, RoutedEventArgs e)
- {
- var dlg = new Microsoft.Win32.OpenFileDialog
- {
- Multiselect = true,
- Title = "추가할 파일 선택",
- Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc;*.html;*.htm;*.hwp;*.hwpx|모든 파일|*.*",
- };
- if (dlg.ShowDialog(this) == true) AddFiles(dlg.FileNames);
- }
-
- private void OnClearFilesClick(object sender, RoutedEventArgs e)
- {
- _entries.Clear();
- UpdateSummary();
- }
-
- private void OnRemoveEntry(object sender, RoutedEventArgs e)
- {
- if (sender is FrameworkElement fe && fe.DataContext is ConvertFileEntry entry)
- {
- _entries.Remove(entry);
- UpdateSummary();
- }
- }
-
- private void OnBrowseClick(object sender, RoutedEventArgs e)
- {
- var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "출력 폴더 선택" };
- if (dlg.ShowDialog(this) == true)
- CustomFolderTextBox.Text = dlg.FolderName;
- }
-
- private static IEnumerable ExpandPaths(IEnumerable paths)
- {
- foreach (var p in paths)
- {
- if (File.Exists(p)) yield return p;
- else if (Directory.Exists(p))
- foreach (var f in Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly))
- yield return f;
- }
- }
-
- // ============== Segments ==============
-
- private void OnQualityChanged(object sender, RoutedPropertyChangedEventArgs e)
- {
- if (QualityValueText is null) return;
- QualityValueText.Text = ((int)e.NewValue).ToString(CultureInfo.InvariantCulture);
- }
-
- private void OnOutputSegmentClick(object sender, RoutedEventArgs e)
- {
- if (sender is not ToggleButton clicked) return;
- OutputSubBtn.IsChecked = clicked == OutputSubBtn;
- OutputSameBtn.IsChecked = clicked == OutputSameBtn;
- OutputCustomBtn.IsChecked = clicked == OutputCustomBtn;
-
- _outputMode = (clicked.Tag as string) switch
- {
- "Same" => OutputLocation.SameFolderAsSource,
- "Custom" => OutputLocation.Custom,
- _ => OutputLocation.SubfolderBesideSource,
- };
- CustomFolderRow.Visibility = _outputMode == OutputLocation.Custom
- ? Visibility.Visible : Visibility.Collapsed;
- }
-
- private void OnConflictSegmentClick(object sender, RoutedEventArgs e)
- {
- if (sender is not ToggleButton clicked) return;
- ConflictRenameBtn.IsChecked = clicked == ConflictRenameBtn;
- ConflictReplaceBtn.IsChecked = clicked == ConflictReplaceBtn;
- ConflictSkipBtn.IsChecked = clicked == ConflictSkipBtn;
- _conflictRule = (clicked.Tag as string) switch
- {
- "Skip" => NameCollision.Skip,
- "Replace" => NameCollision.Overwrite,
- _ => NameCollision.AppendNumber,
- };
- }
-
- private ConvertOptions BuildOptions()
- {
- var opts = new ConvertOptions
- {
- Quality = (int)QualitySlider.Value,
- PdfDpi = (int)DpiSlider.Value,
- FlattenTransparency = FlattenCheckBox.IsChecked == true,
- OutputLocation = _outputMode,
- OnCollision = _conflictRule,
- };
- if (_outputMode == OutputLocation.Custom)
- opts.CustomOutputDirectory = CustomFolderTextBox.Text;
- if (int.TryParse(MaxLongEdgeTextBox.Text, out var maxEdge) && maxEdge > 0)
- opts.MaxLongEdgePixels = maxEdge;
- return opts;
- }
-
- // ============== Convert ==============
-
- private async void OnConvertClick(object sender, RoutedEventArgs e)
- {
- DiagLog($"OnConvertClick: entries={_entries.Count}");
- if (_entries.Count == 0)
- {
- ShowInfo("변환할 파일이 없습니다.");
- return;
- }
-
- ConvertButton.IsEnabled = false;
- CancelButton.Content = "취소";
- ProgressStatusText.Text = "준비 중…";
- _cts = new CancellationTokenSource();
-
- ConvertOptions options;
- try
- {
- options = BuildOptions();
- DiagLog($" options: Quality={options.Quality} OutputLocation={options.OutputLocation} Custom={options.CustomOutputDirectory} Collision={options.OnCollision}");
- }
- catch (Exception ex)
- {
- DiagLog(" BuildOptions threw: " + ex);
- ProgressStatusText.Text = "옵션 처리 오류: " + ex.Message;
- ConvertButton.IsEnabled = true;
- CancelButton.Content = "닫기";
- _cts = null;
- return;
- }
-
- var reporter = new Progress(p =>
- {
- var overall = p.Total == 0 ? 0 : (p.Index + p.FileProgress) / p.Total;
- OverallProgress.Value = Math.Clamp(overall, 0, 1);
- ProgressStatusText.Text = $"{Math.Min(p.Index + 1, p.Total)} / {p.Total} — {Path.GetFileName(p.CurrentPath)}";
- UpdateEntryProgress(p);
- });
-
- try
- {
- var sources = _entries.Select(en => en.Path).ToList();
- DiagLog($" starting ConvertManyAsync, {sources.Count} files");
- var results = await _engine.ConvertManyAsync(sources, options, reporter, _cts.Token);
- DiagLog($" finished, {results.Count} results");
- ApplyResults(results);
- ProgressStatusText.Text = SummarizeResults(results);
- }
- catch (OperationCanceledException)
- {
- ProgressStatusText.Text = "변환이 취소되었습니다.";
- }
- catch (Exception ex)
- {
- DiagLog(" EXCEPTION: " + ex);
- ProgressStatusText.Text = "오류: " + ex.Message;
- MessageBox.Show(this, "변환 중 오류:\n\n" + ex.Message + "\n\n로그: " + DialogLogPath,
- "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Error);
- }
- finally
- {
- ConvertButton.IsEnabled = true;
- CancelButton.Content = "닫기";
- _cts = null;
- }
- }
-
- private void UpdateEntryProgress(ConvertProgress p)
- {
- if (p.Index >= _entries.Count) return;
- for (var i = 0; i < _entries.Count; i++)
- {
- if (i < p.Index) _entries[i].State = "완료";
- else if (i == p.Index) _entries[i].State = "변환 중…";
- else _entries[i].State = "대기";
- }
- }
-
- private void ApplyResults(IReadOnlyList results)
- {
- foreach (var result in results)
- {
- var entry = _entries.FirstOrDefault(e => string.Equals(e.Path, result.SourcePath, StringComparison.OrdinalIgnoreCase));
- if (entry is null) continue;
- entry.State = result.Status switch
- {
- ConvertStatus.Success => $"성공 ({result.OutputPaths.Count}개)",
- ConvertStatus.Skipped => "건너뜀",
- ConvertStatus.Failed => "실패: " + result.Message,
- _ => entry.State,
- };
- entry.IsFailed = result.Status == ConvertStatus.Failed;
- }
- }
-
- private static string SummarizeResults(IReadOnlyList results)
- {
- var success = results.Count(r => r.Status == ConvertStatus.Success);
- var skipped = results.Count(r => r.Status == ConvertStatus.Skipped);
- var failed = results.Count(r => r.Status == ConvertStatus.Failed);
- var outputs = results.Sum(r => r.OutputPaths.Count);
- return $"성공 {success}개 (출력 {outputs}), 건너뜀 {skipped}, 실패 {failed}";
- }
-
- private void OnCancelClick(object sender, RoutedEventArgs e)
- {
- if (_cts is { } cts) { cts.Cancel(); return; }
- Close();
- }
-
- private void ShowInfo(string message)
- => MessageBox.Show(this, message, "EverythingToJpeg",
- MessageBoxButton.OK, MessageBoxImage.Information);
-}
-
-public sealed class ConvertFileEntry : INotifyPropertyChanged
-{
- private string _state = "대기";
- private bool _isFailed;
- private ImageSource? _thumbnail;
-
- public required string Path { get; init; }
- public required string FileName { get; init; }
- public required string SubText { get; init; }
- public required string FormatLabel { get; init; }
- public required Brush FormatBrush { get; init; }
-
- public string State
- {
- get => _state;
- set { _state = value; Raise(nameof(State)); }
- }
-
- public bool IsFailed
- {
- get => _isFailed;
- set { _isFailed = value; Raise(nameof(IsFailed)); }
- }
-
- public ImageSource? Thumbnail
- {
- get => _thumbnail;
- set { _thumbnail = value; Raise(nameof(Thumbnail)); Raise(nameof(ShowFormatLabel)); }
- }
-
- public Visibility ShowFormatLabel => _thumbnail is null ? Visibility.Visible : Visibility.Collapsed;
-
- public static ConvertFileEntry From(string path, ConversionEngine engine)
- {
- var ext = System.IO.Path.GetExtension(path).TrimStart('.').ToLowerInvariant();
- var (label, brushKey) = FormatPalette.For(ext);
-
- string handler;
- if (engine.Providers.TryGetForFile(path, out var provider) && provider is not null)
- handler = provider.Capability.DisplayName;
- else
- handler = "지원되지 않음";
-
- long size = 0;
- try { size = new FileInfo(path).Length; } catch { }
- var sub = $".{ext} · {handler} · {MainWindow.HumanizeBytes(size)}";
-
- return new ConvertFileEntry
- {
- Path = path,
- FileName = System.IO.Path.GetFileName(path),
- SubText = sub,
- FormatLabel = label,
- FormatBrush = (Brush)Application.Current.FindResource(brushKey),
- };
- }
-
- public Task LoadThumbnailAsync() => Task.Run(() =>
- {
- try
- {
- var ext = System.IO.Path.GetExtension(Path).ToLowerInvariant();
- if (ext is ".png" or ".jpg" or ".jpeg" or ".bmp" or ".gif")
- {
- var bmp = new BitmapImage();
- bmp.BeginInit();
- bmp.UriSource = new Uri(Path);
- bmp.DecodePixelWidth = 80;
- bmp.CacheOption = BitmapCacheOption.OnLoad;
- bmp.EndInit();
- bmp.Freeze();
- Application.Current.Dispatcher.Invoke(() => Thumbnail = bmp);
- }
- }
- catch { }
- });
-
- public event PropertyChangedEventHandler? PropertyChanged;
- private void Raise([CallerMemberName] string? n = null)
- => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
-}
diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml b/src/EverythingToJpeg.App/Views/MainWindow.xaml
index 87b191d..ed650c5 100644
--- a/src/EverythingToJpeg.App/Views/MainWindow.xaml
+++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml
@@ -23,6 +23,13 @@
+
+
+
+
+
+
+
@@ -203,11 +210,16 @@
-
+
+
+
+
diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
index ad1c9c7..7e88473 100644
--- a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
+++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
@@ -20,16 +20,27 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
private CancellationTokenSource? _cts;
private NameCollision _conflictRule = NameCollision.AppendNumber;
+ public ICommand AddFilesCommand { get; }
+ public ICommand ProcessQueueCommand { get; }
+ public ICommand CloseCommand { get; }
+ public ICommand RefreshCommand { get; }
+
public MainWindow() : this(null) { }
public MainWindow(IReadOnlyList? initialFiles)
{
+ AddFilesCommand = new RelayCommand(_ => PickAndAddFiles());
+ ProcessQueueCommand = new RelayCommand(_ => OnProcessQueueClick(this, new RoutedEventArgs()),
+ _ => _activeQueue.Count > 0 && _cts is null);
+ CloseCommand = new RelayCommand(_ => Close());
+ RefreshCommand = new RelayCommand(_ => ApplyAppDataStats());
+
InitializeComponent();
ActiveQueueList.ItemsSource = _activeQueue;
PastResultsList.ItemsSource = _pastResults;
- SeedDemoHistory();
+ LoadHistory();
UpdateBadges();
UpdateProcessQueueButton();
@@ -45,6 +56,47 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
UpdateActiveQueueVisibility();
ApplyAppDataStats();
+
+ _ = RefreshCapabilityStatusAsync();
+ }
+
+ private async Task RefreshCapabilityStatusAsync()
+ {
+ var engine = ((App)Application.Current).Engine;
+ var notReady = new List();
+ foreach (var p in engine.Providers.All)
+ {
+ if (p.Capability.Status == EverythingToJpeg.Core.Providers.ProviderStatus.RequiresExternal)
+ {
+ var availability = await p.CheckAvailabilityAsync();
+ if (!availability.IsReady)
+ notReady.Add(p.Capability.DisplayName);
+ }
+ }
+
+ if (notReady.Count == 0)
+ {
+ CapabilityStatusText.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ CapabilityStatusText.Text = $"⚠ {notReady.Count}개 형식이 외부 도구를 기다립니다 (Diagnose 참조)";
+ CapabilityStatusText.Visibility = Visibility.Visible;
+ }
+
+ private void PickAndAddFiles()
+ {
+ var dlg = new Microsoft.Win32.OpenFileDialog
+ {
+ Multiselect = true,
+ Title = "변환할 파일 추가",
+ Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc;*.html;*.htm;*.hwp;*.hwpx|모든 파일|*.*",
+ };
+ if (dlg.ShowDialog(this) == true)
+ {
+ AddToQueue(dlg.FileNames);
+ ShowTab("Active");
+ }
}
// ============== Tabs ==============
@@ -517,6 +569,12 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
// ============== History ==============
private void AddToHistory(HistoryEntry entry)
+ {
+ AddToHistoryGroups(entry);
+ HistoryStorage.Append(entry);
+ }
+
+ private void AddToHistoryGroups(HistoryEntry entry)
{
var label = FormatDateLabel(entry.Date);
var group = _pastResults.FirstOrDefault(g => g.DateTitle == label);
@@ -528,6 +586,21 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
group.Add(HistoryRow.From(entry));
}
+ private void LoadHistory()
+ {
+ var entries = HistoryStorage.Load();
+ if (entries.Count == 0)
+ {
+ // 첫 실행: 데모 데이터로 시각적 가이드 제공
+ SeedDemoHistory();
+ return;
+ }
+
+ // 가장 오래된 것부터 추가 (Insert(0)이 누적)
+ foreach (var e in entries.OrderBy(e => e.Timestamp))
+ AddToHistoryGroups(e);
+ }
+
private void SeedDemoHistory()
{
var today = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today));
@@ -624,7 +697,14 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
}
else if (TabPastBtn.IsChecked == true)
{
+ var confirm = MessageBox.Show(this,
+ "Past Results 전체를 삭제하시겠습니까?\n영구 저장된 이력도 함께 삭제됩니다.",
+ "EverythingToJpeg",
+ MessageBoxButton.OKCancel, MessageBoxImage.Question);
+ if (confirm != MessageBoxResult.OK) return;
+
_pastResults.Clear();
+ HistoryStorage.Clear();
}
UpdateBadges();
UpdateProcessQueueButton();
@@ -789,3 +869,24 @@ internal static class FormatPalette
_ => (ext.ToUpperInvariant(), "FsFmtOther"),
};
}
+
+internal sealed class RelayCommand : ICommand
+{
+ private readonly Action