1
0
Fork 0

feat: P4-P8 한방 마무리 — 자체서명 MSIX, 영구저장, 단축키, capability 알림

P4 — 자체 서명 MSIX 자동화
- packaging/BuildAndSign.ps1: 인증서 생성→PFX export→MSIX 빌드→서명을
  한 스크립트로 일관 처리. 기존 인증서 있으면 재사용.
- Install-EverythingToJpeg.ps1: 기본 비밀번호 'EverythingToJpegDev' 자동
  사용 (사용자 5대 PC 본인 사용 한정).
- 검증: 53MB 서명된 MSIX 산출, signtool 정상 통과(DigiCert TimeStamp 박힘).

P6 — Past Results 영구 저장
- HistoryStorage.cs: %LocalAppData%\EverythingToJpeg\history.jsonl 에
  변환 결과 append. 시작 시 로드해서 _pastResults에 채움.
- 첫 실행에만 데모 시드, 이후엔 실데이터만 표시.
- Past Results의 Clear All은 영구 파일도 함께 삭제 (확인 다이얼로그 추가).

P7 — 정리 + 단축키
- 사용 안 하는 ConvertWindow.xaml/.cs 폐기 (코드 600줄 제거).
- KeyBinding 4개: Ctrl+O 파일 추가, Ctrl+Enter 변환, Esc 닫기, F5 새로고침.
- RelayCommand 헬퍼 클래스로 단축키 ICommand 패턴 구현.

P8 — Capability 자동 안내
- 시작 시 RequiresExternal Provider들의 가용성 비동기 체크.
- 외부 도구 미설치 형식이 있으면 사이드바 footer에
  "⚠ N개 형식이 외부 도구를 기다립니다 (Diagnose 참조)" 한 줄 표시.

README — Phase 1~8 전체 진척, 단축키 표 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yun Chan 2026-05-06 19:36:55 +09:00
parent 00d1eeff96
commit 784167ccff
8 changed files with 276 additions and 709 deletions

View file

@ -63,9 +63,25 @@ dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
| 단계 | 상태 | 내용 | | 단계 | 상태 | 내용 |
|---|---|---| |---|---|---|
| Phase 1 | ✅ | 레지스트리 컨텍스트 메뉴 (Win11 "추가 옵션 표시"), 핵심 변환(이미지·HEIC·RAW·PDF·DOCX), Fluent UI | | Phase 1 | ✅ | 레지스트리 컨텍스트 메뉴 (Win11 "추가 옵션 표시"), 핵심 변환, Fluent UI |
| Phase 2 | ✅ | C++ IExplorerCommand DLL, MSIX 패키징, 자체 서명 인증서, GitHub Releases 자동화 — `packaging/README.md` 참조 | | Phase 2 | ✅ | C++ IExplorerCommand DLL + MSIX Sparse Package |
| Phase 3 | ✅ | HTML(WebView2), HWP/HWPX(LibreOffice + H2Orestart) 실구현 | | 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 표시 |
## 두 가지 사용 방식 ## 두 가지 사용 방식

View file

@ -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로 변환… 이 메인 메뉴에 노출됨.'

View file

@ -8,7 +8,8 @@
param( param(
[Parameter(Mandatory)] [string]$PfxPath, [Parameter(Mandatory)] [string]$PfxPath,
[Parameter(Mandatory)] [string]$MsixPath, [Parameter(Mandatory)] [string]$MsixPath,
[securestring]$PfxPassword [securestring]$PfxPassword,
[string]$Password = 'EverythingToJpegDev'
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@ -17,7 +18,7 @@ if (-not (Test-Path $PfxPath)) { throw "PFX 파일을 찾을 수 없습니다: $
if (-not (Test-Path $MsixPath)) { throw "MSIX 파일을 찾을 수 없습니다: $MsixPath" } if (-not (Test-Path $MsixPath)) { throw "MSIX 파일을 찾을 수 없습니다: $MsixPath" }
if (-not $PfxPassword) { if (-not $PfxPassword) {
$PfxPassword = Read-Host -AsSecureString -Prompt 'PFX 비밀번호' $PfxPassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
} }
Write-Host '[1/3] 인증서를 LocalMachine\TrustedPeople에 임포트…' Write-Host '[1/3] 인증서를 LocalMachine\TrustedPeople에 임포트…'

View file

@ -1,323 +0,0 @@
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.ConvertWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="JPEG로 변환"
Width="720" Height="780"
MinWidth="560" MinHeight="560"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="None"
WindowCornerPreference="Round"
WindowStartupLocation="CenterOwner"
AllowDrop="True"
Drop="OnFilesDropped"
DragOver="OnDragOver"
TextOptions.TextFormattingMode="Display"
UseLayoutRounding="True">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Grid Background="{StaticResource FsBgBase}">
<Grid.RowDefinitions>
<RowDefinition Height="32"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 0: TitleBar (window controls only) -->
<ui:TitleBar Grid.Row="0" Title=""/>
<!-- 1: 헤더 -->
<Grid Grid.Row="1" Margin="32,8,32,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="JPEG로 변환"
FontFamily="{StaticResource FsFontSans}"
FontSize="20" FontWeight="SemiBold"
Foreground="{StaticResource FsTextPrimary}"/>
<TextBlock x:Name="FilesSummaryText" Margin="0,2,0,0"
Style="{StaticResource FsCaptionStyle}"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="+ 추가" Margin="0,0,8,0"
Style="{StaticResource FsSecondaryButtonStyle}"
Click="OnAddFilesClick"/>
<Button Content="비우기"
Style="{StaticResource FsSecondaryButtonStyle}"
Click="OnClearFilesClick"/>
</StackPanel>
</Grid>
<!-- 2: 옵션 카드 -->
<Border Grid.Row="2" Margin="32,0,32,16"
Background="{StaticResource FsBgPanel}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" CornerRadius="8" Padding="20">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 품질 -->
<StackPanel Grid.Column="0" Margin="0,0,12,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="JPEG 품질" Style="{StaticResource FsLabelStyle}"/>
<Border Grid.Column="1" CornerRadius="4"
Background="{StaticResource FsBgInput}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" Padding="6,1">
<TextBlock x:Name="QualityValueText"
FontFamily="{StaticResource FsFontMono}" FontSize="11"
Foreground="{StaticResource FsAccentBlue}"
Text="92"/>
</Border>
</Grid>
<Slider x:Name="QualitySlider" Margin="0,8,0,0"
Style="{StaticResource FsSliderStyle}"
Minimum="50" Maximum="100" Value="92"
ValueChanged="OnQualityChanged"/>
</StackPanel>
<!-- 출력 위치 -->
<StackPanel Grid.Column="1" Margin="6,0,6,0">
<TextBlock Text="출력 위치" Style="{StaticResource FsLabelStyle}"/>
<Border Margin="0,8,0,0"
Background="{StaticResource FsBgInput}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" CornerRadius="6" Padding="2">
<UniformGrid Rows="1" Columns="3">
<ToggleButton x:Name="OutputSubBtn" Content="하위"
Style="{StaticResource FsSegmentStyle}"
IsChecked="True"
Click="OnOutputSegmentClick" Tag="Sub"
ToolTip="원본 폴더 안 _jpeg 하위 폴더"/>
<ToggleButton x:Name="OutputSameBtn" Content="동일"
Style="{StaticResource FsSegmentStyle}"
Click="OnOutputSegmentClick" Tag="Same"
ToolTip="원본과 같은 폴더"/>
<ToggleButton x:Name="OutputCustomBtn" Content="지정"
Style="{StaticResource FsSegmentStyle}"
Click="OnOutputSegmentClick" Tag="Custom"
ToolTip="사용자 지정 폴더"/>
</UniformGrid>
</Border>
</StackPanel>
<!-- 이름 충돌 -->
<StackPanel Grid.Column="2" Margin="12,0,0,0">
<TextBlock Text="이름 충돌" Style="{StaticResource FsLabelStyle}"/>
<Border Margin="0,8,0,0"
Background="{StaticResource FsBgInput}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" CornerRadius="6" Padding="2">
<UniformGrid Rows="1" Columns="3">
<ToggleButton x:Name="ConflictRenameBtn" Content="번호"
Style="{StaticResource FsSegmentStyle}"
IsChecked="True"
Click="OnConflictSegmentClick" Tag="Rename"/>
<ToggleButton x:Name="ConflictReplaceBtn" Content="덮어쓰기"
Style="{StaticResource FsSegmentStyle}"
Click="OnConflictSegmentClick" Tag="Replace"/>
<ToggleButton x:Name="ConflictSkipBtn" Content="건너뛰기"
Style="{StaticResource FsSegmentStyle}"
Click="OnConflictSegmentClick" Tag="Skip"/>
</UniformGrid>
</Border>
</StackPanel>
</Grid>
<!-- 사용자 지정 폴더 -->
<Grid Margin="0,12,0,0" x:Name="CustomFolderRow" Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="CustomFolderTextBox"
Style="{StaticResource FsPathInputStyle}"/>
<Button Grid.Column="1" Margin="8,0,0,0" Width="40"
Content="…" Style="{StaticResource FsSecondaryButtonStyle}"
Click="OnBrowseClick"/>
</Grid>
<!-- 고급 옵션 -->
<Expander Margin="0,12,0,0" IsExpanded="False"
Foreground="{StaticResource FsTextSecondary}">
<Expander.Header>
<TextBlock Text="고급 옵션" Style="{StaticResource FsLabelStyle}"/>
</Expander.Header>
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,12,0">
<TextBlock Text="긴 변 최대 픽셀" Style="{StaticResource FsLabelStyle}"
ToolTip="비워 두면 원본 크기 유지"/>
<TextBox x:Name="MaxLongEdgeTextBox" Margin="0,8,0,0"
Style="{StaticResource FsPathInputStyle}"/>
</StackPanel>
<StackPanel Grid.Column="1" Margin="6,0,12,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="PDF 렌더 DPI" Style="{StaticResource FsLabelStyle}"/>
<TextBlock Grid.Column="1"
FontFamily="{StaticResource FsFontMono}" FontSize="11"
Foreground="{StaticResource FsAccentBlue}"
Text="{Binding ElementName=DpiSlider, Path=Value, StringFormat={}{0:0}}"/>
</Grid>
<Slider x:Name="DpiSlider" Margin="0,8,0,0"
Style="{StaticResource FsSliderStyle}"
Minimum="72" Maximum="450" Value="200"/>
</StackPanel>
<CheckBox Grid.Column="2" x:Name="FlattenCheckBox"
Content="투명을 흰색으로"
IsChecked="True" VerticalAlignment="Bottom"
Foreground="{StaticResource FsTextSecondary}"
FontFamily="{StaticResource FsFontSans}"
FontSize="12"/>
</Grid>
</Expander>
</StackPanel>
</Border>
<!-- 3: 파일 리스트 -->
<Border Grid.Row="3" Margin="32,0,32,0"
Background="{StaticResource FsBgPanel}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" CornerRadius="8">
<DockPanel>
<Border DockPanel.Dock="Top" Padding="20,14,20,10"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="0,0,0,1">
<TextBlock Text="대상 파일" Style="{StaticResource FsLabelStyle}"/>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="20,12,20,16">
<ItemsControl x:Name="FilesList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0,0,0,8" Padding="12,10" CornerRadius="6"
Background="{StaticResource FsBgSurface}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Width="32" Height="32" CornerRadius="4"
Background="{Binding FormatBrush}"
VerticalAlignment="Center">
<Grid>
<Image Source="{Binding Thumbnail}" Stretch="UniformToFill"/>
<TextBlock Text="{Binding FormatLabel}"
Foreground="White" FontSize="9"
FontWeight="SemiBold"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding ShowFormatLabel}"/>
</Grid>
</Border>
<StackPanel Grid.Column="1" Margin="14,0,8,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding FileName}"
FontFamily="{StaticResource FsFontSans}"
FontSize="13" FontWeight="Medium"
Foreground="{StaticResource FsTextPrimary}"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding SubText}"
FontSize="11" Margin="0,2,0,0"
Foreground="{StaticResource FsTextTertiary}"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding State}" FontSize="11" Margin="0,2,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground"
Value="{StaticResource FsTextTertiary}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsFailed}" Value="True">
<Setter Property="Foreground" Value="#F87171"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<Button Grid.Column="2"
Style="{StaticResource FsSecondaryButtonStyle}"
Background="Transparent"
BorderThickness="0"
Padding="6"
Click="OnRemoveEntry"
ToolTip="목록에서 제거">
<Path Width="14" Height="14" Stretch="Uniform"
Stroke="{StaticResource FsTextTertiary}"
StrokeThickness="2"
Data="M18,6 L6,18 M6,6 L18,18"/>
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<!-- 4: 액션바 -->
<Border Grid.Row="4" Padding="32,16,32,20" Margin="0,16,0,0"
BorderBrush="{StaticResource FsBorderSubtle}" BorderThickness="0,1,0,0"
Background="{StaticResource FsBgPanel}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel VerticalAlignment="Center">
<ProgressBar x:Name="OverallProgress" Height="3"
Background="{StaticResource FsBgInput}"
Foreground="{StaticResource FsAccentBlue}"
Minimum="0" Maximum="1"/>
<TextBlock x:Name="ProgressStatusText" Margin="0,8,0,0"
Style="{StaticResource FsCaptionStyle}"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="20,0,0,0">
<Button x:Name="CancelButton" Content="닫기" Click="OnCancelClick"
Margin="0,0,8,0" MinWidth="80"
Style="{StaticResource FsSecondaryButtonStyle}"/>
<Button x:Name="ConvertButton" Content="→ 변환 시작"
Click="OnConvertClick" MinWidth="120"
Style="{StaticResource FsPrimaryButtonStyle}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</ui:FluentWindow>

View file

@ -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<ConvertFileEntry> _entries = new();
private CancellationTokenSource? _cts;
private OutputLocation _outputMode = OutputLocation.SubfolderBesideSource;
private NameCollision _conflictRule = NameCollision.AppendNumber;
public ConvertWindow(ConversionEngine engine, IReadOnlyList<string> 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<string> paths)
{
var existing = new HashSet<string>(_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<string> ExpandPaths(IEnumerable<string> 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<double> 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<ConvertProgress>(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<ConvertResult> 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<ConvertResult> 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));
}

View file

@ -23,6 +23,13 @@
</ResourceDictionary> </ResourceDictionary>
</Window.Resources> </Window.Resources>
<Window.InputBindings>
<KeyBinding Key="O" Modifiers="Ctrl" Command="{Binding AddFilesCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<KeyBinding Key="Enter" Modifiers="Ctrl" Command="{Binding ProcessQueueCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<KeyBinding Key="Escape" Command="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<KeyBinding Key="F5" Command="{Binding RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
</Window.InputBindings>
<Grid Background="{StaticResource FsBgBase}"> <Grid Background="{StaticResource FsBgBase}">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="32"/> <RowDefinition Height="32"/>
@ -203,11 +210,16 @@
<!-- Sidebar footer --> <!-- Sidebar footer -->
<Border Grid.Row="2" BorderBrush="{StaticResource FsBorderSubtle}" <Border Grid.Row="2" BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="0,1,0,0" Padding="24"> BorderThickness="0,1,0,0" Padding="24">
<Button x:Name="ProcessQueueButton" <StackPanel>
Content="Idle — drop files to begin" <TextBlock x:Name="CapabilityStatusText" Margin="0,0,0,12"
Style="{StaticResource FsPrimaryButtonStyle}" Style="{StaticResource FsCaptionStyle}"
IsEnabled="False" TextWrapping="Wrap" Visibility="Collapsed"/>
Click="OnProcessQueueClick"/> <Button x:Name="ProcessQueueButton"
Content="Idle — drop files to begin"
Style="{StaticResource FsPrimaryButtonStyle}"
IsEnabled="False"
Click="OnProcessQueueClick"/>
</StackPanel>
</Border> </Border>
</Grid> </Grid>
</Border> </Border>

View file

@ -20,16 +20,27 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private NameCollision _conflictRule = NameCollision.AppendNumber; 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() : this(null) { }
public MainWindow(IReadOnlyList<string>? initialFiles) public MainWindow(IReadOnlyList<string>? 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(); InitializeComponent();
ActiveQueueList.ItemsSource = _activeQueue; ActiveQueueList.ItemsSource = _activeQueue;
PastResultsList.ItemsSource = _pastResults; PastResultsList.ItemsSource = _pastResults;
SeedDemoHistory(); LoadHistory();
UpdateBadges(); UpdateBadges();
UpdateProcessQueueButton(); UpdateProcessQueueButton();
@ -45,6 +56,47 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
UpdateActiveQueueVisibility(); UpdateActiveQueueVisibility();
ApplyAppDataStats(); ApplyAppDataStats();
_ = RefreshCapabilityStatusAsync();
}
private async Task RefreshCapabilityStatusAsync()
{
var engine = ((App)Application.Current).Engine;
var notReady = new List<string>();
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 ============== // ============== Tabs ==============
@ -517,6 +569,12 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
// ============== History ============== // ============== History ==============
private void AddToHistory(HistoryEntry entry) private void AddToHistory(HistoryEntry entry)
{
AddToHistoryGroups(entry);
HistoryStorage.Append(entry);
}
private void AddToHistoryGroups(HistoryEntry entry)
{ {
var label = FormatDateLabel(entry.Date); var label = FormatDateLabel(entry.Date);
var group = _pastResults.FirstOrDefault(g => g.DateTitle == label); 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)); 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() private void SeedDemoHistory()
{ {
var today = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today)); var today = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today));
@ -624,7 +697,14 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
} }
else if (TabPastBtn.IsChecked == true) 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(); _pastResults.Clear();
HistoryStorage.Clear();
} }
UpdateBadges(); UpdateBadges();
UpdateProcessQueueButton(); UpdateProcessQueueButton();
@ -789,3 +869,24 @@ internal static class FormatPalette
_ => (ext.ToUpperInvariant(), "FsFmtOther"), _ => (ext.ToUpperInvariant(), "FsFmtOther"),
}; };
} }
internal sealed class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public event EventHandler? CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
}

View file

@ -0,0 +1,68 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace EverythingToJpeg.Core;
public static class HistoryStorage
{
private static readonly string Dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"EverythingToJpeg");
private static readonly string FilePath = Path.Combine(Dir, "history.jsonl");
private static readonly JsonSerializerOptions JsonOptions = new()
{
Converters = { new JsonStringEnumConverter() },
WriteIndented = false,
};
public static IReadOnlyList<HistoryEntry> Load()
{
if (!File.Exists(FilePath)) return Array.Empty<HistoryEntry>();
var list = new List<HistoryEntry>();
try
{
foreach (var line in File.ReadAllLines(FilePath))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
var entry = JsonSerializer.Deserialize<HistoryEntry>(line, JsonOptions);
if (entry is not null) list.Add(entry);
}
catch
{
// 손상된 줄은 무시
}
}
}
catch
{
return Array.Empty<HistoryEntry>();
}
return list;
}
public static void Append(HistoryEntry entry)
{
try
{
Directory.CreateDirectory(Dir);
var json = JsonSerializer.Serialize(entry, JsonOptions);
File.AppendAllText(FilePath, json + Environment.NewLine);
}
catch
{
// 영구 저장 실패는 메모리 동작에 영향 없음
}
}
public static void Clear()
{
try { if (File.Exists(FilePath)) File.Delete(FilePath); } catch { }
}
public static string LocationHint => FilePath;
}