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 index 53a8f09..6816237 100644 --- a/src/EverythingToJpeg.App/Views/MainWindow.xaml +++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml @@ -2,110 +2,636 @@ 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="EverythingToJpeg" - Width="880" Height="640" - MinWidth="640" MinHeight="480" + Title="FormatShift Utility" + Width="1280" Height="960" + MinWidth="1080" MinHeight="640" ExtendsContentIntoTitleBar="True" - WindowBackdropType="Mica" + WindowBackdropType="None" WindowCornerPreference="Round" WindowStartupLocation="CenterScreen" AllowDrop="True" Drop="OnFilesDropped" DragOver="OnDragOver" - DragLeave="OnDragLeave"> - + DragLeave="OnDragLeave" + TextOptions.TextFormattingMode="Display" + UseLayoutRounding="True"> + + + + + + + + + - + - - - - - + + - - - - - - - + + + + + + - - - - - 파일을 우클릭하면 끝. PNG · GIF · HEIC · RAW · PDF · DOCX 가 모두 한 번에 변환됩니다. - - + + + + + + + + - - - - - - - - - - 또는 - 파일 선택 - - + + + + + + FormatShift + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs index bc72514..3cd3ee6 100644 --- a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs +++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs @@ -1,256 +1,532 @@ +using System.Collections.ObjectModel; using System.ComponentModel; +using System.Globalization; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; -using System.Windows.Documents; +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.Providers; -using Wpf.Ui.Controls; +using EverythingToJpeg.Core; namespace EverythingToJpeg.App.Views; -public partial class MainWindow : FluentWindow, INotifyPropertyChanged +public partial class MainWindow : Wpf.Ui.Controls.FluentWindow { - private bool _isDraggingOver; - public bool IsDraggingOver - { - get => _isDraggingOver; - set { _isDraggingOver = value; OnPropertyChanged(); } - } + private readonly ObservableCollection _activeQueue = new(); + private readonly ObservableCollection _pastResults = new(); + private CancellationTokenSource? _cts; + private NameCollision _conflictRule = NameCollision.AppendNumber; public MainWindow() { InitializeComponent(); - DataContext = this; - Loaded += async (_, _) => await PopulateAsync(); + + ActiveQueueList.ItemsSource = _activeQueue; + PastResultsList.ItemsSource = _pastResults; + + SeedDemoHistory(); + UpdateBadges(); + UpdateProcessQueueButton(); + ShowTab("Past"); + UpdateActiveQueueVisibility(); + + ApplyAppDataStats(); } - private async Task PopulateAsync() + // ============== Tabs ============== + + private void OnTabClick(object sender, RoutedEventArgs e) { - var engine = ((App)Application.Current).Engine; - ProvidersList.Items.Clear(); - foreach (var provider in engine.Providers.All) + if (sender is ToggleButton tb && tb.Tag is string tag) { - var availability = await provider.CheckAvailabilityAsync(); - ProvidersList.Items.Add(BuildProviderRow(provider.Capability, availability)); + ShowTab(tag); } } - private static UIElement BuildProviderRow(ProviderCapability cap, ProviderAvailability availability) + private void ShowTab(string tag) { - var (badge, badgeKey) = cap.Status switch - { - ProviderStatus.Available => availability.IsReady - ? ("준비됨", "BadgeReadyBrush") - : ("점검 필요", "BadgeWarnBrush"), - ProviderStatus.Preview => ("프리뷰", "BadgeInfoBrush"), - ProviderStatus.RequiresExternal => availability.IsReady - ? ("외부 도구 감지됨", "BadgeReadyBrush") - : ("외부 도구 필요", "BadgeWarnBrush"), - ProviderStatus.ComingSoon => ("개발 중", "BadgeMutedBrush"), - _ => ("비활성", "BadgeMutedBrush"), - }; + TabActiveBtn.IsChecked = tag == "Active"; + TabPreviewBtn.IsChecked = tag == "Preview"; + TabPastBtn.IsChecked = tag == "Past"; - var card = new CardControl - { - Padding = new Thickness(16, 12, 16, 12), - Margin = new Thickness(0, 0, 0, 8), - }; - - var stack = new StackPanel(); - - var headerStack = new StackPanel { Orientation = Orientation.Horizontal }; - headerStack.Children.Add(new TextBlock - { - Text = cap.DisplayName, - FontFamily = new FontFamily("Segoe UI Variable Text, Segoe UI"), - FontSize = 14, - FontWeight = FontWeights.SemiBold, - VerticalAlignment = VerticalAlignment.Center, - }); - headerStack.Children.Add(new Border - { - Background = (Brush)Application.Current.FindResource(badgeKey), - CornerRadius = new CornerRadius(10), - Padding = new Thickness(8, 2, 8, 2), - Margin = new Thickness(8, 0, 0, 0), - Child = new TextBlock { Text = badge, FontSize = 11, Foreground = Brushes.White }, - }); - stack.Children.Add(headerStack); - - stack.Children.Add(new TextBlock - { - Text = cap.Summary, - FontFamily = new FontFamily("Segoe UI Variable Text, Segoe UI"), - FontSize = 12, - Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush"), - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 4, 0, 0), - }); - - stack.Children.Add(new TextBlock - { - Text = "확장자: " + string.Join(", ", cap.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), - }); - } - - if (cap.ExternalDependencies.Count > 0) - { - foreach (var dep in cap.ExternalDependencies) - { - var line = new TextBlock - { - FontSize = 11, - Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush"), - Margin = new Thickness(0, 2, 0, 0), - TextWrapping = TextWrapping.Wrap, - }; - line.Inlines.Add(new Run($"• {dep.Name} — {dep.Description} ")); - if (!string.IsNullOrEmpty(dep.DownloadUrl)) - { - var hl = new Hyperlink(new Run(dep.DownloadUrl)) { NavigateUri = new Uri(dep.DownloadUrl) }; - hl.RequestNavigate += (_, e) => - { - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = e.Uri.AbsoluteUri, - UseShellExecute = true - }); - } - catch { } - e.Handled = true; - }; - line.Inlines.Add(hl); - } - stack.Children.Add(line); - } - } - - if (!string.IsNullOrEmpty(cap.RoadmapNote)) - { - stack.Children.Add(new TextBlock - { - Text = "로드맵: " + cap.RoadmapNote, - FontSize = 11, - FontStyle = FontStyles.Italic, - Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"), - Margin = new Thickness(0, 4, 0, 0), - TextWrapping = TextWrapping.Wrap, - }); - } - - card.Content = stack; - return card; + ActiveQueueView.Visibility = tag == "Active" ? Visibility.Visible : Visibility.Collapsed; + PreviewView.Visibility = tag == "Preview" ? Visibility.Visible : Visibility.Collapsed; + PastResultsView.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed; } + // ============== Drag & Drop ============== + private void OnDragOver(object sender, DragEventArgs e) { - if (e.Data.GetDataPresent(DataFormats.FileDrop)) - { - e.Effects = DragDropEffects.Copy; - IsDraggingOver = true; - } - else - { - e.Effects = DragDropEffects.None; - } + 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) { - IsDraggingOver = false; + DropHintOverlay.Visibility = Visibility.Collapsed; } private void OnFilesDropped(object sender, DragEventArgs e) { - IsDraggingOver = false; + DropHintOverlay.Visibility = Visibility.Collapsed; if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return; - OpenConvertWindow(paths); + + AddToQueue(ExpandPaths(paths)); + ShowTab("Active"); } - private void OnPickFilesClick(object sender, RoutedEventArgs e) + private static IEnumerable ExpandPaths(IEnumerable paths) { - var dlg = new Microsoft.Win32.OpenFileDialog - { - Title = "변환할 파일 선택", - Multiselect = true, - 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) - { - OpenConvertWindow(dlg.FileNames); - } - } - - private void OpenConvertWindow(string[] paths) - { - var files = ExpandPaths(paths); - if (files.Count == 0) return; - var window = new ConvertWindow(((App)Application.Current).Engine, files) { Owner = this }; - window.ShowDialog(); - } - - private static List ExpandPaths(IEnumerable paths) - { - var list = new List(); foreach (var p in paths) { - try + if (File.Exists(p)) yield return p; + else if (Directory.Exists(p)) { - if (File.Exists(p)) list.Add(p); - else if (Directory.Exists(p)) - list.AddRange(Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly)); + foreach (var f in Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly)) + yield return f; } - catch { } } - return list; } - private async void OnRegisterClick(object sender, RoutedEventArgs e) + private void AddToQueue(IEnumerable paths) { + 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(); + } + + 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); + TabPreviewBadge.Text = "0"; + 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 CancellationTokenSource? _previewCts; + + private async void OnQueueRowClick(object sender, MouseButtonEventArgs e) + { + if (sender is not FrameworkElement fe || fe.Tag is not QueueItem item) return; + _selectedPreviewItem = item; + ShowTab("Preview"); + await LoadPreviewAsync(item); + } + + private async Task LoadPreviewAsync(QueueItem item) + { + _previewCts?.Cancel(); + _previewCts = new CancellationTokenSource(); + var token = _previewCts.Token; + + PreviewEmpty.Visibility = Visibility.Collapsed; + PreviewImage.Visibility = Visibility.Collapsed; + PreviewReason.Visibility = Visibility.Collapsed; + PreviewLoading.Visibility = Visibility.Visible; + + PreviewFileName.Text = item.FileName; + PreviewFilePath.Text = item.SourcePath; + PreviewFormatText.Text = item.FormatLabel; + PreviewSizeText.Text = item.SizeText; + PreviewDimText.Text = "—"; + PreviewPageText.Text = "—"; + try { - ContextMenuRegistrar.Register(((App)Application.Current).Engine); - ShowToast("컨텍스트 메뉴를 등록했습니다.\n파일 위에서 우클릭 → \"추가 옵션 표시\"에서 보입니다."); - await PopulateAsync(); + var result = await PreviewService.CreateAsync(item.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; + PreviewReasonText.Text = "미리보기 오류: " + ex.Message; + PreviewReason.Visibility = Visibility.Visible; + } + } + + private void OnPreviewOpenFolder(object sender, RoutedEventArgs e) + { + if (_selectedPreviewItem is null) return; + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"/select,\"{_selectedPreviewItem.SourcePath}\"", + 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("등록 중 오류: " + ex.Message, "EverythingToJpeg", + MessageBox.Show(this, "저장 중 오류: " + ex.Message, "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Error); } } - private async void OnUnregisterClick(object sender, RoutedEventArgs e) + 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) + { + 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 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.Unregister(((App)Application.Current).Engine); - ShowToast("컨텍스트 메뉴를 해제했습니다."); - await PopulateAsync(); + ContextMenuRegistrar.Register(((App)Application.Current).Engine); + MessageBox.Show(this, + "컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".", + "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Information); } catch (Exception ex) { - MessageBox.Show("해제 중 오류: " + ex.Message, "EverythingToJpeg", + MessageBox.Show(this, "등록 중 오류: " + ex.Message, "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Error); } } @@ -261,13 +537,176 @@ public partial class MainWindow : FluentWindow, INotifyPropertyChanged window.ShowDialog(); } - private void ShowToast(string message) + private void OnClearAllClick(object sender, RoutedEventArgs e) { - MessageBox.Show(this, message, "EverythingToJpeg", - MessageBoxButton.OK, MessageBoxImage.Information); + if (TabActiveBtn.IsChecked == true) + { + _activeQueue.Clear(); + } + else if (TabPastBtn.IsChecked == true) + { + _pastResults.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 OnPropertyChanged([CallerMemberName] string? name = null) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + 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"), + }; } diff --git a/src/EverythingToJpeg.Core/HistoryStore.cs b/src/EverythingToJpeg.Core/HistoryStore.cs new file mode 100644 index 0000000..3f4515c --- /dev/null +++ b/src/EverythingToJpeg.Core/HistoryStore.cs @@ -0,0 +1,37 @@ +using System.Collections.ObjectModel; + +namespace EverythingToJpeg.Core; + +public sealed record HistoryEntry( + DateTime Timestamp, + string SourcePath, + string SourceFormat, + long SourceSizeBytes, + long OutputSizeBytes, + int OutputCount, + string? MetaLine, + ConvertStatus Status, + string? Message) +{ + public long SavingsBytes => SourceSizeBytes - OutputSizeBytes; + + public DateOnly Date => DateOnly.FromDateTime(Timestamp); +} + +public sealed class HistoryStore +{ + private readonly ObservableCollection _entries = new(); + + public ReadOnlyObservableCollection Entries { get; } + + public HistoryStore() + { + Entries = new ReadOnlyObservableCollection(_entries); + } + + public void Add(HistoryEntry entry) => _entries.Insert(0, entry); + + public void Clear() => _entries.Clear(); + + public int Count => _entries.Count; +} diff --git a/src/EverythingToJpeg.Core/PreviewService.cs b/src/EverythingToJpeg.Core/PreviewService.cs new file mode 100644 index 0000000..eb28fd9 --- /dev/null +++ b/src/EverythingToJpeg.Core/PreviewService.cs @@ -0,0 +1,128 @@ +using System.Windows.Media.Imaging; +using ImageMagick; +using PDFtoImage; +using PhotoSauce.MagicScaler; +using PhotoSauce.NativeCodecs.Libheif; +using SkiaSharp; + +namespace EverythingToJpeg.Core; + +public sealed record PreviewResult(BitmapSource? Image, string? Reason, string? Dimensions, int? PageCount); + +public static class PreviewService +{ + private static int _heifConfigured; + + public static async Task CreateAsync(string path, int maxLongEdge = 720, CancellationToken ct = default) + { + if (!File.Exists(path)) return new PreviewResult(null, "파일을 찾을 수 없습니다.", null, null); + + var ext = Path.GetExtension(path).ToLowerInvariant(); + try + { + return ext switch + { + ".pdf" => await Task.Run(() => RenderPdf(path, maxLongEdge), ct).ConfigureAwait(false), + ".heic" or ".heif" => await Task.Run(() => RenderHeic(path, maxLongEdge), ct).ConfigureAwait(false), + ".html" or ".htm" => new PreviewResult(null, "HTML 미리보기는 변환 시점에 렌더됩니다.", null, null), + ".doc" or ".docx" or ".hwp" or ".hwpx" => + new PreviewResult(null, "문서 미리보기는 다음 업데이트에서 지원합니다.", null, null), + _ => await Task.Run(() => RenderViaMagick(path, maxLongEdge), ct).ConfigureAwait(false), + }; + } + catch (Exception ex) + { + return new PreviewResult(null, "미리보기 생성 실패: " + ex.Message, null, null); + } + } + + private static PreviewResult RenderViaMagick(string path, int maxLongEdge) + { + using var image = new MagickImage(path); + var w = (int)image.Width; + var h = (int)image.Height; + try { image.AutoOrient(); } catch { } + if (image.HasAlpha) + { + image.BackgroundColor = MagickColors.White; + image.Alpha(AlphaOption.Remove); + image.Alpha(AlphaOption.Off); + } + if (w > maxLongEdge || h > maxLongEdge) + { + var geom = new MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false }; + image.Resize(geom); + } + image.Quality = 88; + image.Format = MagickFormat.Jpeg; + var bytes = image.ToByteArray(); + return new PreviewResult(BytesToBitmap(bytes), null, $"{w} × {h}", null); + } + + private static PreviewResult RenderHeic(string path, int maxLongEdge) + { + if (Interlocked.Exchange(ref _heifConfigured, 1) == 0) + CodecManager.Configure(c => c.UseLibheif()); + + var tempPng = Path.Combine(Path.GetTempPath(), $"e2j_pv_{Guid.NewGuid():N}.png"); + try + { + MagicImageProcessor.ProcessImage(path, tempPng, ProcessImageSettings.Default); + return RenderViaMagick(tempPng, maxLongEdge); + } + finally + { + try { if (File.Exists(tempPng)) File.Delete(tempPng); } catch { } + } + } + + private static PreviewResult RenderPdf(string path, int maxLongEdge) + { + int pageCount; + using (var probe = File.OpenRead(path)) + { + pageCount = Conversion.GetPageCount(probe); + } + if (pageCount <= 0) return new PreviewResult(null, "PDF 페이지가 없습니다.", null, 0); + + var ms = new MemoryStream(); + using (var input = File.OpenRead(path)) + { + var renderOptions = new RenderOptions + { + Dpi = 144, + BackgroundColor = SKColors.White, + WithAnnotations = true, + WithFormFill = true, + }; + Conversion.SaveJpeg(ms, input, page: 0, leaveOpen: false, password: null, options: renderOptions); + } + ms.Position = 0; + var bytes = ms.ToArray(); + + // optional resize via Magick + using var image = new MagickImage(bytes); + var w = (int)image.Width; + var h = (int)image.Height; + if (w > maxLongEdge || h > maxLongEdge) + { + var geom = new MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false }; + image.Resize(geom); + image.Quality = 88; + image.Format = MagickFormat.Jpeg; + bytes = image.ToByteArray(); + } + return new PreviewResult(BytesToBitmap(bytes), null, $"{w} × {h}", pageCount); + } + + private static BitmapSource BytesToBitmap(byte[] bytes) + { + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.StreamSource = new MemoryStream(bytes); + bmp.EndInit(); + bmp.Freeze(); + return bmp; + } +}