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:
parent
00d1eeff96
commit
784167ccff
8 changed files with 276 additions and 709 deletions
20
README.md
20
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 표시 |
|
||||
|
||||
## 두 가지 사용 방식
|
||||
|
||||
|
|
|
|||
68
packaging/BuildAndSign.ps1
Normal file
68
packaging/BuildAndSign.ps1
Normal 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로 변환… 이 메인 메뉴에 노출됨.'
|
||||
|
|
@ -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에 임포트…'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -23,6 +23,13 @@
|
|||
</ResourceDictionary>
|
||||
</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.RowDefinitions>
|
||||
<RowDefinition Height="32"/>
|
||||
|
|
@ -203,11 +210,16 @@
|
|||
<!-- Sidebar footer -->
|
||||
<Border Grid.Row="2" BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="0,1,0,0" Padding="24">
|
||||
<Button x:Name="ProcessQueueButton"
|
||||
Content="Idle — drop files to begin"
|
||||
Style="{StaticResource FsPrimaryButtonStyle}"
|
||||
IsEnabled="False"
|
||||
Click="OnProcessQueueClick"/>
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="CapabilityStatusText" Margin="0,0,0,12"
|
||||
Style="{StaticResource FsCaptionStyle}"
|
||||
TextWrapping="Wrap" Visibility="Collapsed"/>
|
||||
<Button x:Name="ProcessQueueButton"
|
||||
Content="Idle — drop files to begin"
|
||||
Style="{StaticResource FsPrimaryButtonStyle}"
|
||||
IsEnabled="False"
|
||||
Click="OnProcessQueueClick"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
|
||||
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<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 ==============
|
||||
|
|
@ -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<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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
src/EverythingToJpeg.Core/HistoryStorage.cs
Normal file
68
src/EverythingToJpeg.Core/HistoryStorage.cs
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue