diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..899fe66
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,43 @@
+name: Build
+
+on:
+ push:
+ branches: [master, main]
+ pull_request:
+ branches: [master, main]
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Setup MSBuild
+ uses: microsoft/setup-msbuild@v2
+
+ - name: Restore + Build solution
+ shell: pwsh
+ run: |
+ dotnet build EverythingToJpeg.slnx -c Release --nologo
+
+ - name: Build MSIX
+ shell: pwsh
+ run: |
+ ./packaging/GenerateAssets.ps1
+ ./packaging/BuildMsix.ps1 -Configuration Release -Platform x64
+
+ - name: Upload MSIX artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: EverythingToJpeg-x64-msix-${{ github.sha }}
+ path: packaging/dist/EverythingToJpeg-x64.msix
+ retention-days: 14
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..3066cb1
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,98 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: '릴리즈 태그 (예: v0.1.0)'
+ required: true
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Setup MSBuild
+ uses: microsoft/setup-msbuild@v2
+
+ - name: Generate placeholder logos
+ shell: pwsh
+ run: ./packaging/GenerateAssets.ps1
+
+ - name: Decode PFX (if secret provided)
+ id: pfx
+ shell: pwsh
+ env:
+ PFX_BASE64: ${{ secrets.PFX_BASE64 }}
+ run: |
+ if ($env:PFX_BASE64) {
+ $bytes = [Convert]::FromBase64String($env:PFX_BASE64)
+ $path = Join-Path $env:RUNNER_TEMP 'cert.pfx'
+ [IO.File]::WriteAllBytes($path, $bytes)
+ "pfx_path=$path" >> $env:GITHUB_OUTPUT
+ Write-Host '✅ PFX 디코딩 완료 — 서명 모드로 빌드'
+ } else {
+ Write-Host 'ℹ PFX_BASE64 secret 없음 — unsigned 모드로 빌드'
+ }
+
+ - name: Build MSIX (signed if PFX, else unsigned)
+ shell: pwsh
+ env:
+ PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }}
+ run: |
+ $pfx = '${{ steps.pfx.outputs.pfx_path }}'
+ if ($pfx) {
+ $sec = ConvertTo-SecureString -String $env:PFX_PASSWORD -AsPlainText -Force
+ ./packaging/BuildMsix.ps1 -Configuration Release -Platform x64 -Sign -PfxPath $pfx -PfxPassword $sec
+ } else {
+ ./packaging/BuildMsix.ps1 -Configuration Release -Platform x64
+ }
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: EverythingToJpeg-x64-msix
+ path: packaging/dist/EverythingToJpeg-x64.msix
+
+ - name: Create GitHub Release
+ if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.event.inputs.tag || github.ref_name }}
+ files: packaging/dist/EverythingToJpeg-x64.msix
+ generate_release_notes: true
+ body: |
+ ## EverythingToJpeg
+
+ 모든 파일을 우클릭 한 번으로 JPEG 로 변환합니다.
+
+ ### 설치 방법
+ 1. 아래 `EverythingToJpeg-x64.msix` 다운로드.
+ 2. 관리자 PowerShell:
+ ```powershell
+ # 자체 서명 인증서를 신뢰 저장소에 등록 (PFX 별도 보유 필요)
+ Import-PfxCertificate -CertStoreLocation Cert:\LocalMachine\TrustedPeople `
+ -FilePath .\EverythingToJpeg-DevCert.pfx `
+ -Password (ConvertTo-SecureString 'EverythingToJpegDev' -AsPlainText -Force)
+
+ # MSIX 사이드로드
+ Add-AppxPackage -Path .\EverythingToJpeg-x64.msix -ForceApplicationShutdown
+ ```
+ 3. PNG/HEIC/PDF 등 파일 우클릭 → "JPEG로 빠른 변환" 또는 "JPEG로 변환…"
+
+ > 이 패키지는 자체 서명입니다. 첫 PC 1회만 인증서 등록이 필요합니다.
+
+ ### 변경사항
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5221139
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,52 @@
+# build outputs
+bin/
+obj/
+publish/
+publish-self/
+
+# IDE
+.vs/
+.vscode/
+*.user
+*.suo
+
+# OS
+Thumbs.db
+.DS_Store
+
+# logs
+*.log
+
+# rider
+.idea/
+
+# Phase 2 packaging artifacts
+*.pfx
+*.msix
+*.msixbundle
+*.appx
+*.appxbundle
+packaging/Layout/
+packaging/dist/
+
+# C++ project intermediate
+src/EverythingToJpeg.Shell/x64/
+src/EverythingToJpeg.Shell/Win32/
+src/EverythingToJpeg.Shell/Debug/
+src/EverythingToJpeg.Shell/Release/
+src/EverythingToJpeg.Shell/.vs/
+src/EverythingToJpeg.Shell/Everythi*/
+*.tlog
+*.obj
+*.pch
+*.iobj
+*.ipdb
+*.recipe
+*.lastbuildstate
+
+# .NET artifacts dir from BuildMsix.ps1
+artifacts/
+
+# NuGet packages restore + nuget.exe cache
+packages/
+tools/
diff --git a/EverythingToJpeg.slnx b/EverythingToJpeg.slnx
new file mode 100644
index 0000000..b50fab5
--- /dev/null
+++ b/EverythingToJpeg.slnx
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4c9d5f0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,130 @@
+# EverythingToJpeg
+
+Windows 우클릭 컨텍스트 메뉴에서 한 방에 JPEG로. PNG · GIF · BMP · TIFF · WebP · AVIF · HEIC · RAW · PSD · PDF · DOCX 를 지원합니다.
+
+- **빠른 변환** — 다이얼로그 없이 원본 폴더의 `<원본명>_jpeg/` 하위에 즉시 저장
+- **변환…** — 옵션 다이얼로그(품질, 출력 위치, 이름 충돌, 크기 제한, PDF DPI)
+- 진행 상황 + 썸네일 + 드래그 & 드롭 (메인 창)
+
+## 빠른 시작
+
+### 1) 빌드
+
+```powershell
+# 솔루션 빌드
+dotnet build EverythingToJpeg.slnx -c Release
+
+# 단일 폴더 publish (framework-dependent, .NET 9 Desktop Runtime 필요)
+dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
+ -c Release -r win-x64 --self-contained false -o publish
+
+# .NET 런타임 동봉 (단일 사용자 배포가 편함)
+dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
+ -c Release -r win-x64 --self-contained true `
+ -p:PublishSingleFile=false -o publish-self
+```
+
+산출물: `publish\EverythingToJpeg.exe`
+
+### 2) 컨텍스트 메뉴 등록
+
+`EverythingToJpeg.exe`를 한 번 실행 → "컨텍스트 메뉴 등록" 클릭. 또는 CLI:
+
+```powershell
+.\EverythingToJpeg.exe register
+.\EverythingToJpeg.exe unregister
+```
+
+> Windows 11 메인 우클릭 메뉴가 아니라 "추가 옵션 표시(Shift+우클릭)" 메뉴에 노출됩니다. 메인 메뉴 노출은 Phase 2에서 IExplorerCommand + MSIX 로 추가 예정.
+
+### 3) 사용
+
+- 파일 우클릭 → "추가 옵션 표시" → **JPEG로 빠른 변환** 또는 **JPEG로 변환…**
+- 또는 메인 창에 파일/폴더를 끌어다 놓기
+
+## 지원 현황
+
+| 형식 | 상태 | 참고 |
+|---|---|---|
+| PNG · BMP · JPEG · WebP · AVIF · PSD · TIFF · GIF · RAW(NEF/CR2/CR3/ARW/DNG/RAF/ORF/RW2/SRW/PEF) | ✅ 준비됨 | Magick.NET 14.x |
+| HEIC · HEIF | ✅ 준비됨 | PhotoSauce + libheif 디코드 |
+| PDF | ✅ 준비됨 | PDFtoImage(PDFium) |
+| HTML · HTM | ✅ 준비됨 | WebView2 헤드리스 + CDP `Page.captureScreenshot` 풀페이지 |
+| DOCX · DOC | ⚙ 외부 도구 필요 | Microsoft Word 또는 LibreOffice 자동 감지 |
+| HWP · HWPX | ⚙ 외부 도구 필요 | LibreOffice + [H2Orestart](https://github.com/ebandal/H2Orestart) 확장 |
+
+## 기술 스택
+
+- **.NET 9 + WPF**, Windows 10.0.19041.0+
+- UI: **WPF-UI 4.3** (Win11 Fluent 2 — Mica 백드롭, Segoe UI Variable 타입 램프)
+- 변환 엔진: Magick.NET, PDFtoImage, PhotoSauce.MagicScaler + Libheif
+
+## 로드맵
+
+| 단계 | 상태 | 내용 |
+|---|---|---|
+| 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 표시 |
+
+## 두 가지 사용 방식
+
+### A) Portable EXE — 가장 가벼움 (Phase 1)
+- `dotnet publish` 산출물 그대로 사용
+- 우클릭 → **추가 옵션 표시** → "JPEG로 빠른 변환" / "JPEG로 변환…"
+- 인증서·서명 불필요
+
+### B) MSIX 패키지 — Win11 메인 메뉴 노출 (Phase 2)
+- `packaging/BuildMsix.ps1` 로 MSIX 빌드
+- 자체 서명 인증서를 `LocalMachine\TrustedPeople`에 임포트 후 사이드로드
+- 우클릭 → 바로 메인 메뉴에 항목 노출
+- 자세한 절차는 [packaging/README.md](packaging/README.md)
+
+## 프로젝트 구조
+
+```
+everythingToJpeg/
+├── EverythingToJpeg.slnx
+└── src/
+ ├── EverythingToJpeg.Core/ — 변환 엔진, Provider 추상화
+ │ ├── Providers/ — IConverterProvider + Capability 메타데이터
+ │ ├── Converters/ — Magick / Heic / Pdf / Docx / Html / Hwpx
+ │ ├── ConversionEngine.cs
+ │ └── EverythingToJpegBootstrap.cs
+ └── EverythingToJpeg.App/ — WPF + CLI 통합 진입점
+ ├── App.xaml(.cs) — CLI 라우터
+ ├── Cli/CliRouter.cs — verb: quick / dialog / register / diagnose
+ ├── Shell/ContextMenuRegistrar.cs — HKCU 레지스트리 등록
+ └── Views/ — Fluent UI 화면
+```
+
+## Provider 전략 (확장 포인트)
+
+새 형식을 지원하려면 `IConverterProvider`를 구현하고 `EverythingToJpegBootstrap.CreateDefault()`에 등록합니다. `ProviderCapability`에 다음을 명시하세요:
+
+- `Status` — `Available` / `Preview` / `RequiresExternal` / `ComingSoon` / `Disabled`
+- `Extensions` — 자동 라우팅 + 컨텍스트 메뉴 등록 키
+- `ExternalDependencies` — UI에 자동 노출되는 외부 도구
+- `RoadmapNote` — 사용자에게 보여줄 향후 계획
+
+`ComingSoon` 상태는 메인 창의 "지원 형식" 섹션에 자동 노출되지만 컨텍스트 메뉴 등록에서는 자동 제외됩니다.
+
+## 라이선스
+
+MIT (예정).
diff --git a/packaging/Assets/Square150x150Logo.png b/packaging/Assets/Square150x150Logo.png
new file mode 100644
index 0000000..bd402c6
Binary files /dev/null and b/packaging/Assets/Square150x150Logo.png differ
diff --git a/packaging/Assets/Square44x44Logo.png b/packaging/Assets/Square44x44Logo.png
new file mode 100644
index 0000000..997ce99
Binary files /dev/null and b/packaging/Assets/Square44x44Logo.png differ
diff --git a/packaging/Assets/StoreLogo.png b/packaging/Assets/StoreLogo.png
new file mode 100644
index 0000000..a052a96
Binary files /dev/null and b/packaging/Assets/StoreLogo.png differ
diff --git a/packaging/Assets/Wide310x150Logo.png b/packaging/Assets/Wide310x150Logo.png
new file mode 100644
index 0000000..10bdd46
Binary files /dev/null and b/packaging/Assets/Wide310x150Logo.png differ
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/BuildMsix.ps1 b/packaging/BuildMsix.ps1
new file mode 100644
index 0000000..14dd699
--- /dev/null
+++ b/packaging/BuildMsix.ps1
@@ -0,0 +1,155 @@
+#Requires -Version 5.1
+# MSIX 빌드 파이프라인.
+# 1) .NET App publish (framework-dependent)
+# 2) C++ Shell DLL 빌드
+# 3) 패키지 Layout 디렉토리 구성
+# 4) makeappx pack
+# 5) (선택) signtool sign
+
+[CmdletBinding()]
+param(
+ [string]$Configuration = 'Release',
+ [string]$Platform = 'x64',
+ [switch]$Sign,
+ [string]$PfxPath,
+ [securestring]$PfxPassword,
+ [string]$CertThumbprint,
+ [switch]$SelfContained
+)
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$packagingDir = $PSScriptRoot
+$layoutDir = Join-Path $packagingDir 'Layout'
+$distDir = Join-Path $packagingDir 'dist'
+$manifestPath = Join-Path $packagingDir 'Package.appxmanifest'
+$assetsSrc = Join-Path $packagingDir 'Assets'
+
+$appProj = Join-Path $repoRoot 'src\EverythingToJpeg.App\EverythingToJpeg.App.csproj'
+$shellProj = Join-Path $repoRoot 'src\EverythingToJpeg.Shell\EverythingToJpeg.Shell.vcxproj'
+
+function Find-WindowsSdkTool {
+ param([string]$ToolName)
+ $sdkRoots = @(
+ "${env:ProgramFiles(x86)}\Windows Kits\10\bin",
+ "$env:ProgramFiles\Windows Kits\10\bin"
+ ) | Where-Object { Test-Path $_ }
+ foreach ($root in $sdkRoots) {
+ $versions = Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } |
+ Sort-Object Name -Descending
+ foreach ($v in $versions) {
+ $candidate = Join-Path $v.FullName "x64\$ToolName"
+ if (Test-Path $candidate) { return [string]$candidate }
+ }
+ }
+ return $null
+}
+
+$makeappx = Find-WindowsSdkTool 'makeappx.exe'
+if (-not $makeappx) { throw 'makeappx.exe를 찾지 못했습니다. Windows 10 SDK가 필요합니다.' }
+Write-Host "makeappx: $makeappx"
+
+if ($Sign) {
+ $signtool = Find-WindowsSdkTool 'signtool.exe'
+ if (-not $signtool) { throw 'signtool.exe를 찾지 못했습니다.' }
+ Write-Host "signtool: $signtool"
+}
+
+$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
+if (-not (Test-Path $vswhere)) { throw 'vswhere.exe를 찾지 못했습니다.' }
+$msbuild = (& $vswhere -latest -find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1)
+if (-not $msbuild) { throw 'MSBuild를 찾지 못했습니다.' }
+Write-Host "msbuild: $msbuild"
+
+# ---- 1) .NET App publish ----
+Write-Host ''
+Write-Host '[1/5] .NET App publish'
+$publishOut = Join-Path $repoRoot ('artifacts\publish\app-' + $Platform.ToLower())
+if (Test-Path $publishOut) { Remove-Item $publishOut -Recurse -Force }
+$rid = if ($Platform -eq 'ARM64') { 'win-arm64' } else { 'win-x64' }
+$selfFlag = if ($SelfContained) { 'true' } else { 'false' }
+& dotnet publish $appProj -c $Configuration -r $rid --self-contained $selfFlag -o $publishOut | Out-Host
+if ($LASTEXITCODE -ne 0) { throw 'dotnet publish 실패' }
+
+# ---- 2) C++ Shell DLL ----
+Write-Host ''
+Write-Host '[2/5] C++ Shell DLL 빌드'
+
+# nuget.exe 자동 다운로드 (없으면)
+$nuget = Get-Command nuget.exe -ErrorAction SilentlyContinue
+if (-not $nuget) {
+ $nugetExe = Join-Path $repoRoot 'tools\nuget.exe'
+ if (-not (Test-Path $nugetExe)) {
+ New-Item -ItemType Directory -Path (Split-Path $nugetExe) -Force | Out-Null
+ Write-Host ' nuget.exe 다운로드 중…'
+ Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetExe
+ }
+ $nugetCmd = $nugetExe
+} else {
+ $nugetCmd = $nuget.Source
+}
+
+$packagesDir = Join-Path $repoRoot 'packages'
+& $nugetCmd restore (Join-Path $repoRoot 'src\EverythingToJpeg.Shell\packages.config') -PackagesDirectory $packagesDir | Out-Host
+if ($LASTEXITCODE -ne 0) { throw 'NuGet restore 실패' }
+
+& $msbuild $shellProj /p:Configuration=$Configuration /p:Platform=$Platform /m /v:minimal | Out-Host
+if ($LASTEXITCODE -ne 0) { throw 'Shell build 실패' }
+$shellDll = Join-Path $repoRoot ("src\EverythingToJpeg.Shell\$Platform\$Configuration\EverythingToJpeg.Shell.dll")
+if (-not (Test-Path $shellDll)) { throw "Shell DLL 산출물 없음: $shellDll" }
+
+# ---- 3) Layout 디렉토리 ----
+Write-Host ''
+Write-Host '[3/5] Layout 디렉토리 구성'
+if (Test-Path $layoutDir) { Remove-Item $layoutDir -Recurse -Force }
+New-Item -ItemType Directory -Path $layoutDir | Out-Null
+
+Copy-Item -Path (Join-Path $publishOut '*') -Destination $layoutDir -Recurse -Force
+Copy-Item -Path $shellDll -Destination $layoutDir -Force
+
+$layoutAssets = Join-Path $layoutDir 'Assets'
+New-Item -ItemType Directory -Path $layoutAssets -Force | Out-Null
+Copy-Item -Path (Join-Path $assetsSrc '*') -Destination $layoutAssets -Force
+
+Copy-Item -Path $manifestPath -Destination (Join-Path $layoutDir 'AppxManifest.xml') -Force
+
+# ---- 4) makeappx pack ----
+Write-Host ''
+Write-Host '[4/5] makeappx pack'
+if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir | Out-Null }
+$msixPath = Join-Path $distDir ("EverythingToJpeg-$($Platform.ToLower()).msix")
+if (Test-Path $msixPath) { Remove-Item $msixPath -Force }
+& $makeappx pack /d $layoutDir /p $msixPath /o | Out-Host
+if ($LASTEXITCODE -ne 0) { throw 'makeappx pack 실패' }
+Write-Host "✅ MSIX 산출: $msixPath"
+
+# ---- 5) (선택) sign ----
+if ($Sign) {
+ Write-Host ''
+ Write-Host '[5/5] signtool sign'
+ if ($CertThumbprint) {
+ & $signtool sign /fd SHA256 /sha1 $CertThumbprint /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host
+ } elseif ($PfxPath) {
+ if (-not $PfxPassword) {
+ $PfxPassword = Read-Host -AsSecureString -Prompt 'PFX 비밀번호'
+ }
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($PfxPassword)
+ try {
+ $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
+ & $signtool sign /fd SHA256 /a /f $PfxPath /p $plain /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host
+ } finally {
+ [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
+ }
+ } else {
+ throw '서명을 하려면 -CertThumbprint 또는 -PfxPath 가 필요합니다.'
+ }
+ if ($LASTEXITCODE -ne 0) { throw 'signtool sign 실패' }
+ Write-Host '✅ 서명 완료'
+} else {
+ Write-Host ''
+ Write-Host '[5/5] 서명 건너뜀 (-Sign 미지정). 사이드로드 시 인증서 필요.'
+}
+
+Write-Host ''
+Write-Host "최종 산출물: $msixPath"
diff --git a/packaging/CreateDevCert.ps1 b/packaging/CreateDevCert.ps1
new file mode 100644
index 0000000..9889cc2
--- /dev/null
+++ b/packaging/CreateDevCert.ps1
@@ -0,0 +1,44 @@
+#Requires -Version 5.1
+# 자체 서명 코드 사이닝 인증서 생성 + PFX export.
+# Subject가 Package.appxmanifest 의 와 정확히 일치해야 한다.
+
+[CmdletBinding()]
+param(
+ [string]$Subject = 'CN=EverythingToJpegDev',
+ [string]$OutputPfx = (Join-Path $PSScriptRoot 'EverythingToJpeg-DevCert.pfx'),
+ [securestring]$Password
+)
+
+$ErrorActionPreference = 'Stop'
+
+if (-not $Password) {
+ Write-Host '인증서 PFX 보호용 비밀번호를 입력하세요. (5대 PC에 설치할 때 필요합니다)'
+ $Password = Read-Host -AsSecureString -Prompt '비밀번호'
+}
+
+Write-Host "Creating self-signed code-signing certificate: $Subject"
+$cert = 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'
+
+Write-Host "Thumbprint: $($cert.Thumbprint)"
+Write-Host "Exporting PFX: $OutputPfx"
+Export-PfxCertificate -Cert $cert -FilePath $OutputPfx -Password $Password | Out-Null
+
+Write-Host ''
+Write-Host '--- 다음 단계 ---'
+Write-Host " 1. 이 PFX 파일을 5대 PC 각각에 복사"
+Write-Host " 2. 각 PC에서 관리자 PowerShell로:"
+Write-Host ' Import-PfxCertificate -CertStoreLocation "Cert:\LocalMachine\TrustedPeople" -FilePath <경로>.pfx -Password (Read-Host -AsSecureString)'
+Write-Host ' 3. MSIX 빌드 시 BuildMsix.ps1 -CertThumbprint ' + $cert.Thumbprint
+Write-Host ''
+Write-Host "PFX는 비밀이므로 절대 git에 커밋하지 마세요. (.gitignore에 *.pfx 추가됨)"
diff --git a/packaging/GenerateAssets.ps1 b/packaging/GenerateAssets.ps1
new file mode 100644
index 0000000..6813a8f
--- /dev/null
+++ b/packaging/GenerateAssets.ps1
@@ -0,0 +1,56 @@
+#Requires -Version 5.1
+# packaging/Assets/ 의 placeholder 아이콘들을 생성한다.
+# 추후 진짜 로고로 교체.
+
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Drawing
+
+$assetsDir = Join-Path $PSScriptRoot 'Assets'
+if (-not (Test-Path $assetsDir)) { New-Item -ItemType Directory -Path $assetsDir | Out-Null }
+
+function New-LogoPng {
+ param(
+ [int]$Width,
+ [int]$Height,
+ [string]$Path,
+ [string]$Label = ''
+ )
+ $bmp = New-Object System.Drawing.Bitmap($Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
+ $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
+ $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
+
+ $rect = New-Object System.Drawing.Rectangle(0, 0, $Width, $Height)
+ $brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
+ $rect,
+ [System.Drawing.Color]::FromArgb(0xFF, 0x3B, 0x82, 0xF6),
+ [System.Drawing.Color]::FromArgb(0xFF, 0x1E, 0x40, 0xAF),
+ [System.Drawing.Drawing2D.LinearGradientMode]::Diagonal)
+ $g.FillRectangle($brush, $rect)
+
+ if ($Label) {
+ $fontSize = [Math]::Max(8, [Math]::Min($Width, $Height) / 5)
+ $font = New-Object System.Drawing.Font('Segoe UI', $fontSize, [System.Drawing.FontStyle]::Bold)
+ $textBrush = [System.Drawing.Brushes]::White
+ $sf = New-Object System.Drawing.StringFormat
+ $sf.Alignment = [System.Drawing.StringAlignment]::Center
+ $sf.LineAlignment = [System.Drawing.StringAlignment]::Center
+ $rectF = New-Object System.Drawing.RectangleF(0, 0, [float]$Width, [float]$Height)
+ $g.DrawString($Label, $font, $textBrush, $rectF, $sf)
+ $font.Dispose()
+ $sf.Dispose()
+ }
+
+ $g.Dispose()
+ $bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
+ $bmp.Dispose()
+ $brush.Dispose()
+ Write-Host " [+] $Path ($Width x $Height)"
+}
+
+Write-Host 'Generating placeholder logos…'
+New-LogoPng -Width 50 -Height 50 -Path (Join-Path $assetsDir 'StoreLogo.png') -Label 'E2J'
+New-LogoPng -Width 44 -Height 44 -Path (Join-Path $assetsDir 'Square44x44Logo.png') -Label 'E2J'
+New-LogoPng -Width 150 -Height 150 -Path (Join-Path $assetsDir 'Square150x150Logo.png')-Label 'E2J'
+New-LogoPng -Width 310 -Height 150 -Path (Join-Path $assetsDir 'Wide310x150Logo.png') -Label 'EverythingToJpeg'
+Write-Host 'Done.'
diff --git a/packaging/Install-EverythingToJpeg.ps1 b/packaging/Install-EverythingToJpeg.ps1
new file mode 100644
index 0000000..df2e633
--- /dev/null
+++ b/packaging/Install-EverythingToJpeg.ps1
@@ -0,0 +1,42 @@
+#Requires -Version 5.1
+#Requires -RunAsAdministrator
+# 5대 PC에서 MSIX 사이드로드 설치 — 1회 셋업 스크립트.
+# 사용법:
+# PowerShell (관리자) > .\Install-EverythingToJpeg.ps1 -PfxPath .\EverythingToJpeg-DevCert.pfx -MsixPath .\EverythingToJpeg.msix
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)] [string]$PfxPath,
+ [Parameter(Mandatory)] [string]$MsixPath,
+ [securestring]$PfxPassword,
+ [string]$Password = 'EverythingToJpegDev'
+)
+
+$ErrorActionPreference = 'Stop'
+
+if (-not (Test-Path $PfxPath)) { throw "PFX 파일을 찾을 수 없습니다: $PfxPath" }
+if (-not (Test-Path $MsixPath)) { throw "MSIX 파일을 찾을 수 없습니다: $MsixPath" }
+
+if (-not $PfxPassword) {
+ $PfxPassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
+}
+
+Write-Host '[1/3] 인증서를 LocalMachine\TrustedPeople에 임포트…'
+$importResult = Import-PfxCertificate `
+ -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' `
+ -FilePath $PfxPath `
+ -Password $PfxPassword
+Write-Host " Thumbprint: $($importResult.Thumbprint)"
+
+Write-Host '[2/3] 인증서를 LocalMachine\Root에도 임포트 (체인 신뢰)…'
+Import-PfxCertificate `
+ -CertStoreLocation 'Cert:\LocalMachine\Root' `
+ -FilePath $PfxPath `
+ -Password $PfxPassword | Out-Null
+
+Write-Host '[3/3] MSIX 패키지 설치…'
+Add-AppxPackage -Path $MsixPath -ForceApplicationShutdown
+
+Write-Host ''
+Write-Host '✅ 설치 완료. Win11 메인 우클릭 메뉴에 "JPEG로 빠른 변환" / "JPEG로 변환…" 항목이 보일 겁니다.'
+Write-Host ' (탐색기 재시작이 필요할 수 있음: 작업 관리자 → "Windows 탐색기" 다시 시작)'
diff --git a/packaging/Package.appxmanifest b/packaging/Package.appxmanifest
new file mode 100644
index 0000000..f79706a
--- /dev/null
+++ b/packaging/Package.appxmanifest
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+ EverythingToJpeg
+ YunChan
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packaging/README.md b/packaging/README.md
new file mode 100644
index 0000000..92b9e26
--- /dev/null
+++ b/packaging/README.md
@@ -0,0 +1,87 @@
+# Phase 2 — MSIX 패키징
+
+Win11 메인 우클릭 메뉴에 "JPEG로 빠른 변환" / "JPEG로 변환…"을 띄우는 정공법.
+
+## 구성
+
+```
+packaging/
+├── Package.appxmanifest — IExplorerCommand 등록 (com:Class + desktop4:FileExplorerContextMenus)
+├── Assets/ — 앱 아이콘 (placeholder, GenerateAssets.ps1로 생성)
+├── GenerateAssets.ps1 — placeholder PNG 일괄 생성
+├── CreateDevCert.ps1 — 자체 서명 코드사이닝 인증서 생성 + PFX export
+├── BuildMsix.ps1 — .NET publish + C++ DLL 빌드 + makeappx + (선택) signtool
+└── Install-EverythingToJpeg.ps1 — 5대 PC 1회 설치 스크립트
+```
+
+C++ Shell DLL은 `src/EverythingToJpeg.Shell/` 에 있고 `BuildMsix.ps1` 안에서 자동 빌드됩니다.
+
+## 1회: 자체 서명 인증서 만들기
+
+```powershell
+cd packaging
+.\CreateDevCert.ps1
+# Subject 기본값: CN=EverythingToJpegDev (Package.appxmanifest의 Publisher와 일치)
+# 비밀번호 입력 → EverythingToJpeg-DevCert.pfx 생성
+```
+
+출력된 Thumbprint를 `BuildMsix.ps1 -CertThumbprint <값>` 으로 사용하거나, PFX 파일을 5대 PC에 복사해서 설치 시 사용합니다.
+
+## 빌드
+
+### 미서명 (Phase 1 그대로 사용 가능, 메인 메뉴 노출은 안 됨)
+```powershell
+.\BuildMsix.ps1
+# 산출: packaging/dist/EverythingToJpeg-x64.msix
+```
+
+### 서명
+```powershell
+# 방법 1: PFX 사용
+.\BuildMsix.ps1 -Sign -PfxPath .\EverythingToJpeg-DevCert.pfx
+
+# 방법 2: 인증서 저장소의 Thumbprint
+.\BuildMsix.ps1 -Sign -CertThumbprint AABBCCDD...
+```
+
+## 5대 PC 설치 (관리자 PowerShell)
+
+```powershell
+.\Install-EverythingToJpeg.ps1 `
+ -PfxPath .\EverythingToJpeg-DevCert.pfx `
+ -MsixPath .\EverythingToJpeg-x64.msix
+```
+
+스크립트가 자동으로:
+1. PFX를 `LocalMachine\TrustedPeople` 에 임포트
+2. PFX를 `LocalMachine\Root` 에도 임포트 (체인 신뢰)
+3. `Add-AppxPackage` 로 MSIX 사이드로드
+
+설치 후 PNG/JPG/HEIC/PDF/DOCX 등을 우클릭하면 **메인 메뉴에 직접** "JPEG로 빠른 변환" / "JPEG로 변환…"이 보입니다.
+
+## 미서명 사이드로드 (Phase 2 임시 사용)
+
+자체 서명 만들기조차 귀찮을 때:
+```powershell
+# 개발자 모드 켜기: 설정 → 개인 정보 및 보안 → 개발자용 → 켜기
+Add-AppxPackage -AllowUnsigned -Path .\EverythingToJpeg-x64.msix
+```
+> Win11 24H2부터 `-AllowUnsigned` 지원. 이전 버전은 자체 서명 권장.
+
+## CI/CD
+
+`.github/workflows/release.yml` — 태그 푸시(`v1.0.0` 등) 시 자동:
+1. .NET / MSBuild 셋업
+2. `BuildMsix.ps1` 실행 (미서명)
+3. GitHub Release 생성 + MSIX 첨부
+
+서명까지 자동화하려면 GitHub Secrets에 `PFX_BASE64`, `PFX_PASSWORD`를 등록하고 워크플로에 단계 추가 (별도 보안 검토 후).
+
+## 트러블슈팅
+
+| 증상 | 원인 / 해결 |
+|---|---|
+| `Add-AppxPackage`: "신뢰할 수 없는 인증서" | PFX를 `LocalMachine\TrustedPeople`에 임포트했는지 확인 (Install 스크립트 자동 수행) |
+| 메뉴가 안 뜸 | 탐색기 재시작: 작업관리자 → "Windows 탐색기" 다시 시작 |
+| Publisher 불일치 오류 | `Package.appxmanifest`의 `Publisher=` 와 인증서 `Subject` 가 정확히 일치해야 함 |
+| `App identity required` | MSIX 패키지로 설치된 경우에만 IExplorerCommand 작동. portable EXE는 Phase 1 레지스트리 방식 사용 |
diff --git a/src/EverythingToJpeg.App/App.xaml b/src/EverythingToJpeg.App/App.xaml
new file mode 100644
index 0000000..7516c02
--- /dev/null
+++ b/src/EverythingToJpeg.App/App.xaml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+ 4
+ 8
+ 16
+ 24
+ 32
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EverythingToJpeg.App/App.xaml.cs b/src/EverythingToJpeg.App/App.xaml.cs
new file mode 100644
index 0000000..6ed2c32
--- /dev/null
+++ b/src/EverythingToJpeg.App/App.xaml.cs
@@ -0,0 +1,155 @@
+using System.Windows;
+using EverythingToJpeg.App.Cli;
+using EverythingToJpeg.App.Views;
+using EverythingToJpeg.Core;
+
+namespace EverythingToJpeg.App;
+
+public partial class App : Application
+{
+ public ConversionEngine Engine { get; } = EverythingToJpegBootstrap.CreateDefault();
+
+ protected override async void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ WireGlobalExceptionLogging();
+
+ var parsed = CliRouter.Parse(e.Args);
+
+ switch (parsed.Mode)
+ {
+ case CliRouter.Mode.Help:
+ ConsoleHelper.WriteLine(CliRouter.HelpText());
+ Environment.Exit(0);
+ return;
+
+ case CliRouter.Mode.Register:
+ Environment.Exit(CliRouter.RunRegister(register: true));
+ return;
+
+ case CliRouter.Mode.Unregister:
+ Environment.Exit(CliRouter.RunRegister(register: false));
+ return;
+
+ case CliRouter.Mode.Diagnose:
+ ShowDiagnoseWindow();
+ return;
+
+ case CliRouter.Mode.Quick:
+ if (parsed.Files.Count == 0)
+ {
+ MessageBox.Show("변환할 파일이 없습니다.", "EverythingToJpeg",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ Shutdown(1);
+ return;
+ }
+ await RunQuickAsync(parsed.Files);
+ return;
+
+ case CliRouter.Mode.Dialog:
+ ShowConvertDialog(parsed.Files);
+ return;
+
+ case CliRouter.Mode.ShowMain:
+ default:
+ ShowMainWindow();
+ return;
+ }
+ }
+
+ private void ShowMainWindow()
+ {
+ var window = new MainWindow();
+ MainWindow = window;
+ window.Show();
+ }
+
+ private void ShowConvertDialog(IReadOnlyList files)
+ {
+ var window = new Views.MainWindow(files);
+ MainWindow = window;
+ window.Show();
+ }
+
+ private void ShowDiagnoseWindow()
+ {
+ var window = new DiagnoseWindow(Engine);
+ MainWindow = window;
+ window.Show();
+ }
+
+ private async Task RunQuickAsync(IReadOnlyList files)
+ {
+ var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_quick.log");
+ var log = new System.Text.StringBuilder();
+ log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start, {files.Count} file(s)");
+ foreach (var f in files) log.AppendLine($" src: {f}");
+
+ var progress = new QuickProgressWindow(files.Count);
+ progress.Show();
+
+ try
+ {
+ var options = ConvertOptions.Quick();
+ var reporter = new Progress(p => progress.Report(p));
+ var results = await Engine.ConvertManyAsync(files, options, reporter);
+
+ foreach (var r in results)
+ {
+ log.AppendLine($" [{r.Status}] {Path.GetFileName(r.SourcePath)} → {r.OutputPaths.Count} output(s)");
+ if (r.Message is { Length: > 0 }) log.AppendLine($" msg: {r.Message}");
+ if (r.Error is not null) log.AppendLine($" err: {r.Error}");
+ foreach (var o in r.OutputPaths) log.AppendLine($" out: {o}");
+ }
+
+ progress.Finish(results);
+ }
+ catch (Exception ex)
+ {
+ log.AppendLine($" EXCEPTION {ex.GetType().Name}: {ex.Message}");
+ log.AppendLine(ex.ToString());
+ try { progress.Close(); } catch { }
+ MessageBox.Show($"변환 중 오류: {ex.Message}\n\n로그: {logPath}", "EverythingToJpeg",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ finally
+ {
+ try { File.WriteAllText(logPath, log.ToString()); } catch { }
+ }
+ }
+
+ private static void WireGlobalExceptionLogging()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_unhandled.log");
+
+ void Append(string source, Exception? ex)
+ {
+ try
+ {
+ File.AppendAllText(path,
+ $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {source}\n{ex}\n\n");
+ }
+ catch { }
+ }
+
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ Append("AppDomain.UnhandledException", e.ExceptionObject as Exception);
+
+ Current.DispatcherUnhandledException += (_, e) =>
+ {
+ Append("Application.DispatcherUnhandledException", e.Exception);
+ MessageBox.Show(
+ "예기치 못한 오류:\n\n" + e.Exception.Message + "\n\n로그: " + path,
+ "EverythingToJpeg",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ e.Handled = true;
+ };
+
+ System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (_, e) =>
+ {
+ Append("TaskScheduler.UnobservedTaskException", e.Exception);
+ e.SetObserved();
+ };
+ }
+}
diff --git a/src/EverythingToJpeg.App/AssemblyInfo.cs b/src/EverythingToJpeg.App/AssemblyInfo.cs
new file mode 100644
index 0000000..cc29e7f
--- /dev/null
+++ b/src/EverythingToJpeg.App/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly:ThemeInfo(
+ ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
+ //(used if a resource is not found in the page,
+ // or application resource dictionaries)
+ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
+ //(used if a resource is not found in the page,
+ // app, or any theme specific resource dictionaries)
+)]
diff --git a/src/EverythingToJpeg.App/Cli/CliRouter.cs b/src/EverythingToJpeg.App/Cli/CliRouter.cs
new file mode 100644
index 0000000..613be16
--- /dev/null
+++ b/src/EverythingToJpeg.App/Cli/CliRouter.cs
@@ -0,0 +1,120 @@
+using EverythingToJpeg.App.Shell;
+using EverythingToJpeg.Core;
+
+namespace EverythingToJpeg.App.Cli;
+
+internal static class CliRouter
+{
+ public enum Mode
+ {
+ ShowMain,
+ Quick,
+ Dialog,
+ Register,
+ Unregister,
+ Diagnose,
+ Help,
+ }
+
+ public sealed record ParsedArgs(Mode Mode, IReadOnlyList Files);
+
+ public static ParsedArgs Parse(string[] args)
+ {
+ if (args is null || args.Length == 0)
+ return new ParsedArgs(Mode.ShowMain, Array.Empty());
+
+ var verb = args[0].Trim().ToLowerInvariant();
+ var rest = args.Skip(1).Where(a => !string.IsNullOrWhiteSpace(a)).ToList();
+
+ return verb switch
+ {
+ "quick" => new ParsedArgs(Mode.Quick, ExpandFiles(rest)),
+ "dialog" => new ParsedArgs(Mode.Dialog, ExpandFiles(rest)),
+ "register" => new ParsedArgs(Mode.Register, Array.Empty()),
+ "unregister" => new ParsedArgs(Mode.Unregister, Array.Empty()),
+ "diagnose" or "doctor" => new ParsedArgs(Mode.Diagnose, Array.Empty()),
+ "help" or "--help" or "-h" or "/?" => new ParsedArgs(Mode.Help, Array.Empty()),
+ _ when File.Exists(args[0]) => new ParsedArgs(Mode.Dialog, ExpandFiles(args)),
+ _ => new ParsedArgs(Mode.ShowMain, Array.Empty()),
+ };
+ }
+
+ private static IReadOnlyList ExpandFiles(IEnumerable raw)
+ {
+ var list = new List();
+ foreach (var arg in raw)
+ {
+ if (string.IsNullOrWhiteSpace(arg)) continue;
+ try
+ {
+ if (File.Exists(arg)) { list.Add(Path.GetFullPath(arg)); continue; }
+ if (Directory.Exists(arg))
+ {
+ foreach (var f in Directory.EnumerateFiles(arg, "*", SearchOption.TopDirectoryOnly))
+ list.Add(Path.GetFullPath(f));
+ }
+ }
+ catch { }
+ }
+ return list;
+ }
+
+ public static string HelpText()
+ {
+ var engine = EverythingToJpegBootstrap.CreateDefault();
+ var supported = string.Join(", ",
+ engine.Providers.Implemented.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e));
+ var coming = string.Join(", ",
+ engine.Providers.ComingSoon.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e));
+
+ return $"""
+ EverythingToJpeg — 모든 것을 JPEG로
+
+ 사용:
+ EverythingToJpeg.exe quick <파일들...> 빠른 변환 (다이얼로그 없이 즉시)
+ EverythingToJpeg.exe dialog <파일들...> 상세 옵션 다이얼로그 표시
+ EverythingToJpeg.exe register 컨텍스트 메뉴 등록 (현재 사용자)
+ EverythingToJpeg.exe unregister 컨텍스트 메뉴 해제
+ EverythingToJpeg.exe diagnose 지원 형식·외부 도구 진단
+ EverythingToJpeg.exe 메인 창 표시
+
+ 지원 (지금): {supported}
+ 지원 예정 : {coming}
+ """;
+ }
+
+ public static int RunRegister(bool register)
+ {
+ var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_register.log");
+ var log = new System.Text.StringBuilder();
+ log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] RunRegister start, register={register}");
+ try
+ {
+ log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Creating engine...");
+ var engine = EverythingToJpegBootstrap.CreateDefault();
+ log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Engine OK. Implemented providers: {string.Join(",", engine.Providers.Implemented.Select(p => p.Capability.Id))}");
+
+ if (register)
+ {
+ log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Calling Register...");
+ ContextMenuRegistrar.Register(engine);
+ }
+ else
+ {
+ ContextMenuRegistrar.Unregister(engine);
+ }
+ log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] OK");
+ File.WriteAllText(logPath, log.ToString());
+ Console.Out.WriteLine($"등록 완료. 로그: {logPath}");
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] EXCEPTION {ex.GetType().Name}: {ex.Message}");
+ log.AppendLine(ex.ToString());
+ try { File.WriteAllText(logPath, log.ToString()); } catch { }
+ Console.Error.WriteLine(ex.Message);
+ return 1;
+ }
+ }
+}
diff --git a/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs b/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs
new file mode 100644
index 0000000..345f187
--- /dev/null
+++ b/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs
@@ -0,0 +1,24 @@
+using System.Runtime.InteropServices;
+
+namespace EverythingToJpeg.App.Cli;
+
+internal static class ConsoleHelper
+{
+ private static bool _attached;
+
+ public static void WriteLine(string text)
+ {
+ EnsureAttached();
+ Console.Out.WriteLine(text);
+ Console.Out.Flush();
+ }
+
+ private static void EnsureAttached()
+ {
+ if (_attached) return;
+ _attached = AttachConsole(-1);
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool AttachConsole(int dwProcessId);
+}
diff --git a/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj b/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj
new file mode 100644
index 0000000..b68e912
--- /dev/null
+++ b/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj
@@ -0,0 +1,29 @@
+
+
+
+ WinExe
+ net9.0-windows10.0.19041.0
+ enable
+ enable
+ true
+ EverythingToJpeg
+ EverythingToJpeg.App
+ app.manifest
+ 10.0.17763.0
+ 10.0.17763.0
+ $(NoWarn);NU1901;NU1902;NU1903;NU1904
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EverythingToJpeg.App/GlobalUsings.cs b/src/EverythingToJpeg.App/GlobalUsings.cs
new file mode 100644
index 0000000..8ba9936
--- /dev/null
+++ b/src/EverythingToJpeg.App/GlobalUsings.cs
@@ -0,0 +1,5 @@
+global using MessageBox = System.Windows.MessageBox;
+global using MessageBoxButton = System.Windows.MessageBoxButton;
+global using MessageBoxImage = System.Windows.MessageBoxImage;
+global using MessageBoxResult = System.Windows.MessageBoxResult;
+global using TextBlock = System.Windows.Controls.TextBlock;
diff --git a/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs b/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs
new file mode 100644
index 0000000..d752263
--- /dev/null
+++ b/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs
@@ -0,0 +1,96 @@
+using EverythingToJpeg.Core;
+using EverythingToJpeg.Core.Providers;
+using Microsoft.Win32;
+
+namespace EverythingToJpeg.App.Shell;
+
+internal static class ContextMenuRegistrar
+{
+ private const string QuickVerb = "EverythingToJpeg.Quick";
+ private const string DialogVerb = "EverythingToJpeg.Dialog";
+
+ private const string QuickLabel = "JPEG로 빠른 변환";
+ private const string DialogLabel = "JPEG로 변환…";
+
+ public static void Register(ConversionEngine engine)
+ {
+ var exe = GetAppExecutablePath();
+ var icon = exe + ",0";
+
+ foreach (var ext in CollectExtensions(engine))
+ {
+ WriteVerb(ext, QuickVerb, QuickLabel, icon, $"\"{exe}\" quick \"%1\"");
+ WriteVerb(ext, DialogVerb, DialogLabel, icon, $"\"{exe}\" dialog \"%1\"");
+ }
+
+ NotifyShell();
+ }
+
+ public static void Unregister(ConversionEngine engine)
+ {
+ foreach (var ext in CollectExtensions(engine))
+ {
+ DeleteVerb(ext, QuickVerb);
+ DeleteVerb(ext, DialogVerb);
+ }
+ NotifyShell();
+ }
+
+ private static IEnumerable CollectExtensions(ConversionEngine engine)
+ {
+ return engine.Providers.Implemented
+ .Where(p => p.Capability.CanRegisterContextMenu)
+ .SelectMany(p => p.Capability.Extensions)
+ .Select(e => e.StartsWith('.') ? e : "." + e)
+ .Select(e => e.ToLowerInvariant())
+ .Distinct();
+ }
+
+ private static void WriteVerb(string ext, string verb, string label, string icon, string command)
+ {
+ var keyPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{verb}";
+ using var verbKey = Registry.CurrentUser.CreateSubKey(keyPath, writable: true)
+ ?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {keyPath}");
+
+ verbKey.SetValue(null, label, RegistryValueKind.String);
+ verbKey.SetValue("Icon", icon, RegistryValueKind.String);
+ verbKey.SetValue("MUIVerb", label, RegistryValueKind.String);
+
+ using var commandKey = verbKey.CreateSubKey("command", writable: true)
+ ?? throw new InvalidOperationException("command 하위 키 생성 실패");
+ commandKey.SetValue(null, command, RegistryValueKind.String);
+ }
+
+ private static void DeleteVerb(string ext, string verb)
+ {
+ var parentPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell";
+ try
+ {
+ using var parent = Registry.CurrentUser.OpenSubKey(parentPath, writable: true);
+ parent?.DeleteSubKeyTree(verb, throwOnMissingSubKey: false);
+ }
+ catch
+ {
+ }
+ }
+
+ private static string GetAppExecutablePath()
+ {
+ var exe = Environment.ProcessPath;
+ if (!string.IsNullOrEmpty(exe) && File.Exists(exe)) return exe;
+ return AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)
+ + Path.DirectorySeparatorChar + "EverythingToJpeg.exe";
+ }
+
+ private static void NotifyShell()
+ {
+ try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); }
+ catch { }
+ }
+
+ private static class NativeMethods
+ {
+ [System.Runtime.InteropServices.DllImport("shell32.dll")]
+ public static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2);
+ }
+}
diff --git a/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml
new file mode 100644
index 0000000..d5b95ec
--- /dev/null
+++ b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs
new file mode 100644
index 0000000..7e6e2e6
--- /dev/null
+++ b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs
@@ -0,0 +1,118 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using EverythingToJpeg.Core;
+using EverythingToJpeg.Core.Providers;
+using Wpf.Ui.Controls;
+
+namespace EverythingToJpeg.App.Views;
+
+public partial class DiagnoseWindow : FluentWindow
+{
+ private readonly ConversionEngine _engine;
+
+ public DiagnoseWindow(ConversionEngine engine)
+ {
+ _engine = engine;
+ InitializeComponent();
+ Loaded += async (_, _) => await PopulateAsync();
+ }
+
+ private async Task PopulateAsync()
+ {
+ ItemsPanel.Children.Clear();
+
+ ItemsPanel.Children.Add(BuildSection("환경", new[]
+ {
+ ("OS", Environment.OSVersion.VersionString),
+ (".NET", Environment.Version.ToString()),
+ ("실행 경로", Environment.ProcessPath ?? AppContext.BaseDirectory),
+ }));
+
+ foreach (var provider in _engine.Providers.All)
+ {
+ var availability = await provider.CheckAvailabilityAsync();
+ ItemsPanel.Children.Add(BuildProviderCard(provider, availability));
+ }
+ }
+
+ private static UIElement BuildSection(string title, IEnumerable<(string Key, string Value)> items)
+ {
+ var card = new CardControl { Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(0, 0, 0, 12) };
+ var stack = new StackPanel();
+ stack.Children.Add(new TextBlock
+ {
+ Text = title,
+ FontSize = 14,
+ FontWeight = FontWeights.SemiBold,
+ Margin = new Thickness(0, 0, 0, 8),
+ });
+ foreach (var (k, v) in items)
+ {
+ var row = new Grid { Margin = new Thickness(0, 2, 0, 2) };
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ var keyText = new TextBlock { Text = k, FontSize = 12, Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush") };
+ var valueText = new TextBlock { Text = v, FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis };
+ Grid.SetColumn(valueText, 1);
+ row.Children.Add(keyText);
+ row.Children.Add(valueText);
+ stack.Children.Add(row);
+ }
+ card.Content = stack;
+ return card;
+ }
+
+ private UIElement BuildProviderCard(IConverterProvider provider, ProviderAvailability availability)
+ {
+ var card = new CardControl { Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(0, 0, 0, 12) };
+ var stack = new StackPanel();
+ var header = new StackPanel { Orientation = Orientation.Horizontal };
+ header.Children.Add(new TextBlock
+ {
+ Text = provider.Capability.DisplayName,
+ FontSize = 14,
+ FontWeight = FontWeights.SemiBold,
+ });
+ var (badgeText, brushKey) = (provider.Capability.Status, availability.IsReady) switch
+ {
+ (ProviderStatus.ComingSoon, _) => ("개발 중", "BadgeMutedBrush"),
+ (ProviderStatus.Disabled, _) => ("비활성", "BadgeMutedBrush"),
+ (_, true) => ("준비됨", "BadgeReadyBrush"),
+ _ => ("점검 필요", "BadgeWarnBrush"),
+ };
+ header.Children.Add(new Border
+ {
+ Background = (Brush)Application.Current.FindResource(brushKey),
+ CornerRadius = new CornerRadius(10),
+ Padding = new Thickness(8, 2, 8, 2),
+ Margin = new Thickness(8, 0, 0, 0),
+ Child = new TextBlock { Text = badgeText, FontSize = 11, Foreground = Brushes.White },
+ });
+ stack.Children.Add(header);
+
+ stack.Children.Add(new TextBlock
+ {
+ Text = $"확장자: {string.Join(", ", provider.Capability.Extensions)}",
+ FontSize = 11,
+ Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"),
+ Margin = new Thickness(0, 4, 0, 0),
+ });
+ if (!availability.IsReady && !string.IsNullOrEmpty(availability.Reason))
+ {
+ stack.Children.Add(new TextBlock
+ {
+ Text = availability.Reason,
+ FontSize = 11,
+ Foreground = (Brush)Application.Current.FindResource("BadgeWarnBrush"),
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(0, 4, 0, 0),
+ });
+ }
+ card.Content = stack;
+ return card;
+ }
+
+ private async void OnRefreshClick(object sender, RoutedEventArgs e) => await PopulateAsync();
+ private void OnCloseClick(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml b/src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml
new file mode 100644
index 0000000..ef512f6
--- /dev/null
+++ b/src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Inter, Segoe UI Variable Text, Segoe UI
+ JetBrains Mono, Cascadia Mono, Consolas
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml b/src/EverythingToJpeg.App/Views/MainWindow.xaml
new file mode 100644
index 0000000..ed650c5
--- /dev/null
+++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml
@@ -0,0 +1,671 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ FormatShift
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
new file mode 100644
index 0000000..7e88473
--- /dev/null
+++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
@@ -0,0 +1,892 @@
+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.App.Shell;
+using EverythingToJpeg.Core;
+
+namespace EverythingToJpeg.App.Views;
+
+public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
+{
+ private readonly ObservableCollection _activeQueue = new();
+ private readonly ObservableCollection _pastResults = new();
+ 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;
+
+ LoadHistory();
+ UpdateBadges();
+ UpdateProcessQueueButton();
+
+ if (initialFiles is { Count: > 0 })
+ {
+ AddToQueue(initialFiles);
+ ShowTab("Active");
+ }
+ else
+ {
+ ShowTab("Past");
+ }
+
+ 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 ==============
+
+ private void OnTabClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleButton tb && tb.Tag is string tag)
+ {
+ ShowTab(tag);
+ }
+ }
+
+ private void ShowTab(string tag)
+ {
+ TabActiveBtn.IsChecked = tag == "Active";
+ TabPastBtn.IsChecked = tag == "Past";
+
+ ActiveQueueView.Visibility = tag == "Active" ? Visibility.Visible : Visibility.Collapsed;
+ PastResultsView.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ // ============== Drag & Drop ==============
+
+ private void OnDragOver(object sender, DragEventArgs e)
+ {
+ e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
+ ? DragDropEffects.Copy : DragDropEffects.None;
+ DropHintOverlay.Visibility = e.Effects == DragDropEffects.Copy
+ ? Visibility.Visible : Visibility.Collapsed;
+ e.Handled = true;
+ }
+
+ private void OnDragLeave(object sender, DragEventArgs e)
+ {
+ DropHintOverlay.Visibility = Visibility.Collapsed;
+ }
+
+ private void OnFilesDropped(object sender, DragEventArgs e)
+ {
+ DropHintOverlay.Visibility = Visibility.Collapsed;
+ if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
+ if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return;
+
+ AddToQueue(ExpandPaths(paths));
+ ShowTab("Active");
+ }
+
+ 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;
+ }
+ }
+ }
+
+ private void AddToQueue(IEnumerable paths)
+ {
+ var wasEmpty = _activeQueue.Count == 0;
+ var existing = new HashSet(_activeQueue.Select(q => q.SourcePath), StringComparer.OrdinalIgnoreCase);
+ foreach (var path in paths)
+ {
+ if (!File.Exists(path) || existing.Contains(path)) continue;
+ _activeQueue.Add(QueueItem.FromPath(path));
+ }
+ UpdateBadges();
+ UpdateProcessQueueButton();
+ UpdateActiveQueueVisibility();
+
+ if (wasEmpty && _activeQueue.Count > 0 && _selectedPreviewItem is null)
+ {
+ _selectedPreviewItem = _activeQueue[0];
+ _ = LoadPreviewAsync(_selectedPreviewItem);
+ }
+ }
+
+ private void OnRemoveQueueItem(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement fe && fe.Tag is QueueItem item)
+ {
+ _activeQueue.Remove(item);
+ UpdateBadges();
+ UpdateProcessQueueButton();
+ UpdateActiveQueueVisibility();
+ }
+ }
+
+ private void UpdateActiveQueueVisibility()
+ {
+ var hasItems = _activeQueue.Count > 0;
+ DropZoneEmpty.Visibility = hasItems ? Visibility.Collapsed : Visibility.Visible;
+ ActiveQueueScroll.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private void UpdateBadges()
+ {
+ TabActiveBadge.Text = _activeQueue.Count.ToString(CultureInfo.InvariantCulture);
+ var count = _pastResults.Sum(g => g.Entries.Count);
+ TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture);
+ }
+
+ private void UpdateProcessQueueButton()
+ {
+ var count = _activeQueue.Count;
+ if (_cts is not null)
+ {
+ ProcessQueueButton.Content = $"Processing… ({count} files)";
+ ProcessQueueButton.IsEnabled = false;
+ }
+ else if (count == 0)
+ {
+ ProcessQueueButton.Content = "Idle — drop files to begin";
+ ProcessQueueButton.IsEnabled = false;
+ }
+ else
+ {
+ ProcessQueueButton.Content = $"Process Queue ({count})";
+ ProcessQueueButton.IsEnabled = true;
+ }
+ }
+
+ // ============== Sidebar inputs ==============
+
+ private void OnQualityChanged(object sender, RoutedPropertyChangedEventArgs e)
+ {
+ if (QualityValueText is null) return;
+ QualityValueText.Text = $"{(int)e.NewValue}%";
+ }
+
+ private void OnConflictSegmentClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is not ToggleButton clicked) return;
+ ConflictSkipBtn.IsChecked = clicked == ConflictSkipBtn;
+ ConflictRenameBtn.IsChecked = clicked == ConflictRenameBtn;
+ ConflictReplaceBtn.IsChecked = clicked == ConflictReplaceBtn;
+ _conflictRule = (clicked.Tag as string) switch
+ {
+ "Skip" => NameCollision.Skip,
+ "Replace" => NameCollision.Overwrite,
+ _ => NameCollision.AppendNumber,
+ };
+ }
+
+ private void OnPickOutputFolderClick(object sender, RoutedEventArgs e)
+ {
+ var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "출력 폴더 선택" };
+ if (dlg.ShowDialog(this) == true)
+ OutputPathTextBox.Text = dlg.FolderName;
+ }
+
+ private ConvertOptions BuildOptions()
+ {
+ var opts = new ConvertOptions
+ {
+ Quality = (int)QualitySlider.Value,
+ OnCollision = _conflictRule,
+ };
+
+ var custom = OutputPathTextBox.Text?.Trim();
+ if (!string.IsNullOrEmpty(custom))
+ {
+ opts.OutputLocation = OutputLocation.Custom;
+ opts.CustomOutputDirectory = custom;
+ }
+ else
+ {
+ opts.OutputLocation = OutputLocation.SubfolderBesideSource;
+ }
+
+ return opts;
+ }
+
+ // ============== Process queue ==============
+
+ // ============== Preview ==============
+
+ private QueueItem? _selectedPreviewItem;
+ private string? _selectedPreviewPath;
+ private CancellationTokenSource? _previewCts;
+
+ private async void OnQueueRowClick(object sender, MouseButtonEventArgs e)
+ {
+ if (e.OriginalSource is DependencyObject src && IsInsideButton(src)) return;
+ if (sender is not FrameworkElement fe || fe.Tag is not QueueItem item) return;
+
+ _selectedPreviewItem = item;
+ await LoadPreviewAsync(item);
+ e.Handled = true;
+ }
+
+ private async void OnPastRowClick(object sender, MouseButtonEventArgs e)
+ {
+ if (e.OriginalSource is DependencyObject src && IsInsideButton(src)) return;
+ if (sender is not FrameworkElement fe || fe.Tag is not HistoryRow row) return;
+
+ if (row.SourcePath == "")
+ {
+ SetPreviewMeta(row.FileName, row.SourcePath, row.FormatLabel, row.SizeText);
+ ShowPreviewReason("샘플 데모 항목입니다. 원본 파일이 없으므로 미리보기를 만들 수 없습니다.");
+ _selectedPreviewPath = null;
+ }
+ else if (!File.Exists(row.SourcePath))
+ {
+ SetPreviewMeta(row.FileName, row.SourcePath, row.FormatLabel, row.SizeText);
+ ShowPreviewReason("원본 파일을 찾을 수 없습니다. 파일이 이동·삭제되었을 수 있습니다.");
+ _selectedPreviewPath = null;
+ }
+ else
+ {
+ _selectedPreviewItem = null;
+ _selectedPreviewPath = row.SourcePath;
+ await LoadPreviewByPathAsync(row.SourcePath, row.FileName, row.FormatLabel, row.SizeText);
+ }
+ e.Handled = true;
+ }
+
+ private static bool IsInsideButton(DependencyObject? node)
+ {
+ while (node is not null)
+ {
+ if (node is Button) return true;
+ node = System.Windows.Media.VisualTreeHelper.GetParent(node)
+ ?? (node is FrameworkElement fe ? fe.Parent : null);
+ }
+ return false;
+ }
+
+ private Task LoadPreviewAsync(QueueItem item)
+ {
+ _selectedPreviewPath = item.SourcePath;
+ return LoadPreviewByPathAsync(item.SourcePath, item.FileName, item.FormatLabel, item.SizeText);
+ }
+
+ private async Task LoadPreviewByPathAsync(string sourcePath, string fileName, string formatLabel, string sizeText)
+ {
+ _previewCts?.Cancel();
+ _previewCts = new CancellationTokenSource();
+ var token = _previewCts.Token;
+
+ SetPreviewMeta(fileName, sourcePath, formatLabel, sizeText);
+ ShowPreviewLoading();
+
+ try
+ {
+ var result = await PreviewService.CreateAsync(sourcePath, 720, token);
+ if (token.IsCancellationRequested) return;
+
+ PreviewLoading.Visibility = Visibility.Collapsed;
+
+ if (result.Image is not null)
+ {
+ PreviewImage.Source = result.Image;
+ PreviewImage.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ PreviewReasonText.Text = result.Reason ?? "미리보기를 생성하지 못했습니다.";
+ PreviewReason.Visibility = Visibility.Visible;
+ }
+
+ PreviewDimText.Text = result.Dimensions ?? "—";
+ PreviewPageText.Text = result.PageCount?.ToString() ?? "—";
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ PreviewLoading.Visibility = Visibility.Collapsed;
+ ShowPreviewReason("미리보기 오류: " + ex.Message);
+ }
+ }
+
+ private void SetPreviewMeta(string fileName, string filePath, string formatLabel, string sizeText)
+ {
+ PreviewFileName.Text = fileName;
+ PreviewFilePath.Text = filePath;
+ PreviewFormatText.Text = formatLabel;
+ PreviewSizeText.Text = sizeText;
+ PreviewDimText.Text = "—";
+ PreviewPageText.Text = "—";
+ }
+
+ private void ShowPreviewLoading()
+ {
+ PreviewEmpty.Visibility = Visibility.Collapsed;
+ PreviewImage.Visibility = Visibility.Collapsed;
+ PreviewReason.Visibility = Visibility.Collapsed;
+ PreviewLoading.Visibility = Visibility.Visible;
+ }
+
+ private void ShowPreviewReason(string reason)
+ {
+ PreviewEmpty.Visibility = Visibility.Collapsed;
+ PreviewImage.Visibility = Visibility.Collapsed;
+ PreviewLoading.Visibility = Visibility.Collapsed;
+ PreviewReasonText.Text = reason;
+ PreviewReason.Visibility = Visibility.Visible;
+ }
+
+ private void OnPreviewOpenFolder(object sender, RoutedEventArgs e)
+ {
+ var path = _selectedPreviewPath ?? _selectedPreviewItem?.SourcePath;
+ if (string.IsNullOrEmpty(path) || !File.Exists(path)) return;
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "explorer.exe",
+ Arguments = $"/select,\"{path}\"",
+ UseShellExecute = true,
+ });
+ }
+ catch { }
+ }
+
+ // ============== Export Log ==============
+
+ private void OnExportLogClick(object sender, RoutedEventArgs e)
+ {
+ if (_pastResults.Count == 0)
+ {
+ MessageBox.Show(this, "저장할 이력이 없습니다.", "EverythingToJpeg",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var dlg = new Microsoft.Win32.SaveFileDialog
+ {
+ Title = "Export Log",
+ FileName = $"EverythingToJpeg-log-{DateTime.Now:yyyyMMdd-HHmmss}.csv",
+ DefaultExt = ".csv",
+ Filter = "CSV (*.csv)|*.csv|JSON (*.json)|*.json",
+ };
+ if (dlg.ShowDialog(this) != true) return;
+
+ try
+ {
+ if (dlg.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
+ ExportJson(dlg.FileName);
+ else
+ ExportCsv(dlg.FileName);
+
+ MessageBox.Show(this, "저장되었습니다:\n" + dlg.FileName, "EverythingToJpeg",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, "저장 중 오류: " + ex.Message, "EverythingToJpeg",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private void ExportCsv(string path)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("Date,Format,FileName,SourcePath,Size,Savings,Meta");
+ foreach (var group in _pastResults)
+ foreach (var row in group.Entries)
+ sb.AppendLine(string.Join(",",
+ EscapeCsv(group.DateTitle),
+ EscapeCsv(row.FormatLabel),
+ EscapeCsv(row.FileName),
+ EscapeCsv(row.SourcePath),
+ EscapeCsv(row.SizeText),
+ EscapeCsv(row.SavingsText),
+ EscapeCsv(row.MetaLine)));
+ File.WriteAllText(path, sb.ToString(), System.Text.Encoding.UTF8);
+ }
+
+ private void ExportJson(string path)
+ {
+ var data = _pastResults.Select(g => new
+ {
+ date = g.DateTitle,
+ sessionSavings = HumanizeBytes(g.SessionSavingsBytes),
+ entries = g.Entries.Select(r => new
+ {
+ format = r.FormatLabel,
+ fileName = r.FileName,
+ sourcePath = r.SourcePath,
+ size = r.SizeText,
+ savings = r.SavingsText,
+ meta = r.MetaLine,
+ }),
+ });
+ File.WriteAllText(path,
+ System.Text.Json.JsonSerializer.Serialize(data,
+ new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
+ System.Text.Encoding.UTF8);
+ }
+
+ private static string EscapeCsv(string? s)
+ {
+ s ??= "";
+ if (s.Contains('"') || s.Contains(',') || s.Contains('\n'))
+ return "\"" + s.Replace("\"", "\"\"") + "\"";
+ return s;
+ }
+
+ private async void OnProcessQueueClick(object sender, RoutedEventArgs e)
+ {
+ if (_activeQueue.Count == 0) return;
+
+ var snapshot = _activeQueue.ToList();
+ foreach (var item in snapshot) item.SetPending();
+
+ _cts = new CancellationTokenSource();
+ UpdateProcessQueueButton();
+
+ var engine = ((App)Application.Current).Engine;
+ var options = BuildOptions();
+
+ var reporter = new Progress(p =>
+ {
+ for (var i = 0; i < snapshot.Count; i++)
+ {
+ if (i < p.Index) snapshot[i].SetState("done");
+ else if (i == p.Index) snapshot[i].SetState($"{(int)(p.FileProgress * 100)}%");
+ else snapshot[i].SetState("queued");
+ }
+ });
+
+ try
+ {
+ var sources = snapshot.Select(s => s.SourcePath).ToList();
+ var results = await engine.ConvertManyAsync(sources, options, reporter, _cts.Token);
+
+ foreach (var (item, result) in snapshot.Zip(results))
+ {
+ long outputSize = 0;
+ foreach (var p in result.OutputPaths)
+ {
+ try { outputSize += new FileInfo(p).Length; } catch { }
+ }
+
+ AddToHistory(new HistoryEntry(
+ Timestamp: DateTime.Now,
+ SourcePath: item.SourcePath,
+ SourceFormat: item.FormatLabel,
+ SourceSizeBytes: item.SourceSizeBytes,
+ OutputSizeBytes: outputSize,
+ OutputCount: result.OutputPaths.Count,
+ MetaLine: item.MetaLine,
+ Status: result.Status,
+ Message: result.Message));
+
+ _activeQueue.Remove(item);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, "변환 중 오류: " + ex.Message, "EverythingToJpeg",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ finally
+ {
+ _cts = null;
+ UpdateBadges();
+ UpdateProcessQueueButton();
+ UpdateActiveQueueVisibility();
+ ApplyAppDataStats();
+ if (_activeQueue.Count == 0) ShowTab("Past");
+ }
+ }
+
+ // ============== 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);
+ if (group is null)
+ {
+ group = new DateGroup(label);
+ _pastResults.Insert(0, group);
+ }
+ 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));
+ var todayGroup = new DateGroup(today);
+ todayGroup.Add(new HistoryRow(
+ FormatLabel: "PNG", FormatBrush: (Brush)FindResource("FsFmtPng"),
+ FileName: "hero_background_final_v2.png",
+ MetaLine: "08:42:12 • 3200x1800",
+ SizeText: "14.2 MB",
+ SavingsText: "↓ 1.1 MB",
+ SourcePath: ""));
+ todayGroup.Add(new HistoryRow(
+ FormatLabel: "HEIC", FormatBrush: (Brush)FindResource("FsFmtHeic"),
+ FileName: "portrait_session_04.heic",
+ MetaLine: "08:35:45 • 4032x3024",
+ SizeText: "6.8 MB",
+ SavingsText: "↓ 2.4 MB",
+ SourcePath: ""));
+ todayGroup.SessionSavingsBytes = (long)(842.4 * 1024 * 1024);
+ _pastResults.Add(todayGroup);
+
+ var yesterday = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today.AddDays(-1)));
+ var yGroup = new DateGroup(yesterday);
+ yGroup.Add(new HistoryRow(
+ FormatLabel: "PDF", FormatBrush: (Brush)FindResource("FsFmtPdf"),
+ FileName: "Q3_Full_Marketing_Deck_v12.pdf",
+ MetaLine: "17:22:10 • 124 Pages",
+ SizeText: "245.4 MB",
+ SavingsText: "↓ 12.8 MB",
+ SourcePath: ""));
+ yGroup.Add(new HistoryRow(
+ FormatLabel: "PNG", FormatBrush: (Brush)FindResource("FsFmtPng"),
+ FileName: "asset_bundle_archive_raw.png",
+ MetaLine: "16:45:33 • 8000x8000",
+ SizeText: "82.1 MB",
+ SavingsText: "↓ 4.5 MB",
+ SourcePath: ""));
+ yGroup.SessionSavingsBytes = (long)(3.1 * 1024 * 1024 * 1024);
+ _pastResults.Add(yGroup);
+
+ UpdateBadges();
+ }
+
+ private static string FormatDateLabel(DateOnly date)
+ {
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var label = date == today ? "Today"
+ : date == today.AddDays(-1) ? "Yesterday"
+ : date.ToString("dddd", CultureInfo.GetCultureInfo("en-US"));
+ return $"{label}, {date:MMM d}";
+ }
+
+ private void ApplyAppDataStats()
+ {
+ var todayLabel = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today));
+ var todayGroup = _pastResults.FirstOrDefault(g => g.DateTitle == todayLabel);
+
+ var processedToday = todayGroup?.Entries.Count ?? 0;
+ var allSavings = _pastResults.Sum(g => g.SessionSavingsBytes);
+
+ ProcessedTodayText.Text = processedToday.ToString("N0", CultureInfo.InvariantCulture);
+ SpaceSavedText.Text = HumanizeBytes(allSavings);
+ }
+
+ // ============== Top-bar actions ==============
+
+ private void OnRegisterClick(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ ContextMenuRegistrar.Register(((App)Application.Current).Engine);
+ MessageBox.Show(this,
+ "컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".",
+ "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, "등록 중 오류: " + ex.Message, "EverythingToJpeg",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private void OnDiagnoseClick(object sender, RoutedEventArgs e)
+ {
+ var window = new DiagnoseWindow(((App)Application.Current).Engine) { Owner = this };
+ window.ShowDialog();
+ }
+
+ private void OnClearAllClick(object sender, RoutedEventArgs e)
+ {
+ if (TabActiveBtn.IsChecked == true)
+ {
+ _activeQueue.Clear();
+ }
+ 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();
+ UpdateActiveQueueVisibility();
+ ApplyAppDataStats();
+ }
+
+ private void OnOpenFolderClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement fe && fe.Tag is string path && File.Exists(path))
+ {
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "explorer.exe",
+ Arguments = $"/select,\"{path}\"",
+ UseShellExecute = true,
+ });
+ }
+ catch { }
+ }
+ }
+
+ public static string HumanizeBytes(long bytes)
+ {
+ if (bytes <= 0) return "0 B";
+ string[] units = { "B", "KB", "MB", "GB", "TB" };
+ double size = bytes;
+ var unit = 0;
+ while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; }
+ return $"{size:0.#} {units[unit]}";
+ }
+}
+
+// ============================================================
+// View models
+// ============================================================
+
+public sealed class QueueItem : INotifyPropertyChanged
+{
+ private string _state = "queued";
+
+ public required string SourcePath { get; init; }
+ public required string FileName { get; init; }
+ public required string FormatLabel { get; init; }
+ public required Brush FormatBrush { get; init; }
+ public required string SizeText { get; init; }
+ public required string MetaLine { get; init; }
+ public required long SourceSizeBytes { get; init; }
+
+ public string StateText
+ {
+ get => _state;
+ set { _state = value; Raise(nameof(StateText)); }
+ }
+
+ public Brush StateBrush => _state switch
+ {
+ "queued" => (Brush)Application.Current.FindResource("FsTextTertiary"),
+ "done" => (Brush)Application.Current.FindResource("FsAccentGreen"),
+ _ => (Brush)Application.Current.FindResource("FsAccentBlue"),
+ };
+
+ public void SetPending() => StateText = "queued";
+ public void SetState(string s)
+ {
+ StateText = s;
+ Raise(nameof(StateBrush));
+ }
+
+ public static QueueItem FromPath(string path)
+ {
+ var ext = Path.GetExtension(path).TrimStart('.').ToLowerInvariant();
+ var (label, brushKey) = FormatPalette.For(ext);
+ long size = 0;
+ try { size = new FileInfo(path).Length; } catch { }
+
+ return new QueueItem
+ {
+ SourcePath = path,
+ FileName = Path.GetFileName(path),
+ FormatLabel = label,
+ FormatBrush = (Brush)Application.Current.FindResource(brushKey),
+ SizeText = MainWindow.HumanizeBytes(size),
+ MetaLine = $"{ext.ToUpperInvariant()} • {MainWindow.HumanizeBytes(size)}",
+ SourceSizeBytes = size,
+ };
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void Raise([CallerMemberName] string? n = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
+}
+
+public sealed class DateGroup : INotifyPropertyChanged
+{
+ public string DateTitle { get; }
+ public ObservableCollection Entries { get; } = new();
+ public long SessionSavingsBytes { get; set; }
+ public string SessionSavingsText => $"Session Savings: {MainWindow.HumanizeBytes(SessionSavingsBytes)}";
+
+ public DateGroup(string dateTitle) { DateTitle = dateTitle; }
+
+ public void Add(HistoryRow row)
+ {
+ Entries.Insert(0, row);
+ SessionSavingsBytes += row.SavingsBytes;
+ Raise(nameof(SessionSavingsText));
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void Raise([CallerMemberName] string? n = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
+}
+
+public sealed record HistoryRow(
+ string FormatLabel,
+ Brush FormatBrush,
+ string FileName,
+ string MetaLine,
+ string SizeText,
+ string SavingsText,
+ string SourcePath,
+ long SavingsBytes = 0)
+{
+ public static HistoryRow From(HistoryEntry e)
+ {
+ var ext = Path.GetExtension(e.SourcePath).TrimStart('.').ToLowerInvariant();
+ var (label, brushKey) = FormatPalette.For(ext);
+ var saved = e.SavingsBytes;
+ var arrow = saved >= 0 ? "↓" : "↑";
+ return new HistoryRow(
+ FormatLabel: label,
+ FormatBrush: (Brush)Application.Current.FindResource(brushKey),
+ FileName: Path.GetFileName(e.SourcePath),
+ MetaLine: $"{e.Timestamp:HH:mm:ss} • {e.OutputCount} output(s)",
+ SizeText: MainWindow.HumanizeBytes(e.SourceSizeBytes),
+ SavingsText: $"{arrow} {MainWindow.HumanizeBytes(Math.Abs(saved))}",
+ SourcePath: e.SourcePath,
+ SavingsBytes: saved);
+ }
+}
+
+internal static class FormatPalette
+{
+ public static (string Label, string BrushKey) For(string ext) => ext switch
+ {
+ "pdf" => ("PDF", "FsFmtPdf"),
+ "png" => ("PNG", "FsFmtPng"),
+ "heic" or "heif" => ("HEIC", "FsFmtHeic"),
+ "jpg" or "jpeg" or "jpe" => ("JPG", "FsFmtJpg"),
+ "doc" or "docx" => ("DOCX", "FsFmtDocx"),
+ "html" or "htm" => ("HTML", "FsFmtHtml"),
+ "hwp" or "hwpx" => ("HWP", "FsFmtHwp"),
+ "gif" => ("GIF", "FsFmtGif"),
+ "tif" or "tiff" => ("TIFF", "FsFmtTiff"),
+ "webp" => ("WEBP", "FsFmtWebp"),
+ "bmp" => ("BMP", "FsFmtBmp"),
+ "raw" or "dng" or "nef" or "cr2" or "cr3" or "arw" or "raf" or "orf" or "rw2" or "srw" or "pef"
+ => ("RAW", "FsFmtRaw"),
+ _ => (ext.ToUpperInvariant(), "FsFmtOther"),
+ };
+}
+
+internal sealed class RelayCommand : ICommand
+{
+ private readonly Action