592 lines
22 KiB
C#
592 lines
22 KiB
C#
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.RegularExpressions;
|
|
using System.Windows;
|
|
using System.Windows.Interop;
|
|
using System.Windows.Media.Imaging;
|
|
using AxCopilot.Models;
|
|
|
|
namespace AxCopilot.Services;
|
|
|
|
/// <summary>
|
|
/// 클립보드 변경을 감지하여 히스토리를 관리합니다.
|
|
/// WM_CLIPBOARDUPDATE 메시지를 수신하기 위해 숨겨진 메시지 창을 생성합니다.
|
|
/// </summary>
|
|
public class ClipboardHistoryService : IDisposable
|
|
{
|
|
private const int WM_CLIPBOARDUPDATE = 0x031D;
|
|
|
|
private static readonly string HistoryPath = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"AxCopilot", "clipboard_history.dat");
|
|
|
|
// 구버전 평문 파일 경로 (마이그레이션용)
|
|
private static readonly string LegacyPath = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"AxCopilot", "clipboard_history.json");
|
|
|
|
/// <summary>원본 이미지 캐시 폴더 (%APPDATA%\AxCopilot\clipboard_images\)</summary>
|
|
private static readonly string ImageCachePath = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"AxCopilot", "clipboard_images");
|
|
|
|
private const long MaxCacheSizeBytes = 500 * 1024 * 1024; // 500MB
|
|
private const int MaxCacheAgeDays = 30;
|
|
|
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
|
{
|
|
WriteIndented = false,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
private readonly SettingsService _settings;
|
|
private HwndSource? _msgSource;
|
|
private readonly object _lock = new();
|
|
private volatile bool _ignoreNext; // 자체 클립보드 조작 시 히스토리 추가 방지
|
|
private uint _lastClipboardSequenceNumber;
|
|
private bool _disposed;
|
|
|
|
private readonly List<ClipboardEntry> _history = new();
|
|
|
|
public IReadOnlyList<ClipboardEntry> History
|
|
{
|
|
get { lock (_lock) return _history.ToList(); }
|
|
}
|
|
|
|
public event EventHandler? HistoryChanged;
|
|
|
|
public ClipboardHistoryService(SettingsService settings)
|
|
{
|
|
_settings = settings;
|
|
}
|
|
|
|
/// <summary>메시지 창을 생성하고 클립보드 알림을 등록합니다.</summary>
|
|
public void Initialize()
|
|
{
|
|
if (_disposed) return;
|
|
if (_msgSource != null) return; // 이미 초기화됨
|
|
if (!_settings.Settings.ClipboardHistory.Enabled) return;
|
|
|
|
// 저장된 히스토리 복원 (텍스트 항목만)
|
|
LoadHistory();
|
|
|
|
Application.Current.Dispatcher.Invoke(() =>
|
|
{
|
|
var sourceParams = new HwndSourceParameters("axClipboardMonitor")
|
|
{
|
|
Width = 0,
|
|
Height = 0,
|
|
ParentWindow = new IntPtr(-3), // HWND_MESSAGE — 화면에 표시 안 됨
|
|
WindowStyle = 0,
|
|
ExtendedWindowStyle = 0
|
|
};
|
|
_msgSource = new HwndSource(sourceParams);
|
|
_msgSource.AddHook(WndProc);
|
|
AddClipboardFormatListener(_msgSource.Handle);
|
|
LogService.Info("클립보드 히스토리 서비스 시작");
|
|
});
|
|
}
|
|
|
|
/// <summary>자체 클립보드 조작(히스토리 붙여넣기) 시 히스토리가 중복 추가되지 않도록 플래그 설정.</summary>
|
|
public void SuppressNextCapture() => _ignoreNext = true;
|
|
|
|
/// <summary>항목을 사용했을 때 CopiedAt을 현재 시각으로 갱신하고 목록 맨 위로 이동합니다.</summary>
|
|
public void PromoteEntry(ClipboardEntry entry)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_history.Remove(entry);
|
|
var updated = new ClipboardEntry(entry.Text, DateTime.Now)
|
|
{
|
|
Image = entry.Image,
|
|
OriginalImagePath = entry.OriginalImagePath,
|
|
IsPinned = entry.IsPinned,
|
|
Category = entry.Category,
|
|
};
|
|
_history.Insert(0, updated);
|
|
}
|
|
HistoryChanged?.Invoke(this, EventArgs.Empty);
|
|
_ = SaveHistoryAsync();
|
|
}
|
|
|
|
public void ClearHistory()
|
|
{
|
|
lock (_lock) _history.Clear();
|
|
HistoryChanged?.Invoke(this, EventArgs.Empty);
|
|
_ = SaveHistoryAsync();
|
|
}
|
|
|
|
/// <summary>항목의 핀 고정을 토글합니다.</summary>
|
|
public void TogglePin(ClipboardEntry entry)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (!entry.IsPinned)
|
|
{
|
|
// 최대 핀 개수 체크
|
|
var maxPins = _settings.Settings.Launcher.MaxPinnedClipboardItems;
|
|
var currentPins = _history.Count(e => e.IsPinned);
|
|
if (currentPins >= maxPins) return; // 최대 도달 시 무시
|
|
}
|
|
entry.IsPinned = !entry.IsPinned;
|
|
}
|
|
HistoryChanged?.Invoke(this, EventArgs.Empty);
|
|
_ = SaveHistoryAsync();
|
|
}
|
|
|
|
/// <summary>텍스트 내용에서 카테고리를 자동 감지합니다.</summary>
|
|
private static string DetectCategory(string text)
|
|
{
|
|
var trimmed = text.Trim();
|
|
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) &&
|
|
(uri.Scheme == "http" || uri.Scheme == "https"))
|
|
return "URL";
|
|
if (trimmed.StartsWith("\\\\") || (trimmed.Length >= 3 && trimmed[1] == ':' && (trimmed[2] == '\\' || trimmed[2] == '/')))
|
|
return "경로";
|
|
if (trimmed.Contains('{') && trimmed.Contains('}') || trimmed.Contains("function ") ||
|
|
trimmed.Contains("class ") || trimmed.Contains("public ") || trimmed.Contains("private ") ||
|
|
trimmed.Contains("def ") || trimmed.Contains("import ") || trimmed.Contains("using "))
|
|
return "코드";
|
|
return "일반";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 클립보드 감지가 동작하지 않을 때 강제 재시작합니다.
|
|
/// Dispose 후 호출하면 내부 상태를 초기화하고 클립보드 모니터링을 재개합니다.
|
|
/// </summary>
|
|
public void Reinitialize()
|
|
{
|
|
_disposed = false;
|
|
_msgSource = null;
|
|
Initialize();
|
|
LogService.Info("클립보드 히스토리 서비스 재초기화 완료");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
if (_msgSource != null)
|
|
{
|
|
RemoveClipboardFormatListener(_msgSource.Handle);
|
|
_msgSource.Dispose();
|
|
_msgSource = null;
|
|
}
|
|
// 종료 시 히스토리 저장 (동기)
|
|
SaveHistorySync();
|
|
}
|
|
|
|
// ─── 히스토리 영속성 (DPAPI 암호화) ─────────────────────────────────────
|
|
// Windows DPAPI(DataProtectionScope.CurrentUser)를 사용하여
|
|
// 현재 Windows 사용자 계정에서만 복호화 가능하도록 합니다.
|
|
// 이 앱 외부에서는 파일 내용을 읽을 수 없습니다.
|
|
|
|
private void LoadHistory()
|
|
{
|
|
try
|
|
{
|
|
// 구버전 평문 파일 → 암호화 파일 마이그레이션
|
|
if (!File.Exists(HistoryPath) && File.Exists(LegacyPath))
|
|
{
|
|
MigrateLegacyFile();
|
|
}
|
|
|
|
if (!File.Exists(HistoryPath)) return;
|
|
|
|
var encrypted = File.ReadAllBytes(HistoryPath);
|
|
var plain = ProtectedData.Unprotect(encrypted, null, DataProtectionScope.CurrentUser);
|
|
var json = Encoding.UTF8.GetString(plain);
|
|
var saved = JsonSerializer.Deserialize<List<SavedClipEntry>>(json, JsonOpts);
|
|
if (saved == null) return;
|
|
|
|
int max = _settings.Settings.ClipboardHistory.MaxItems;
|
|
lock (_lock)
|
|
{
|
|
foreach (var s in saved.Take(max))
|
|
{
|
|
if (!string.IsNullOrEmpty(s.ImageBase64))
|
|
{
|
|
var img = Base64ToImage(s.ImageBase64);
|
|
if (img != null)
|
|
{
|
|
_history.Add(new ClipboardEntry("", s.CopiedAt)
|
|
{
|
|
Image = img,
|
|
OriginalImagePath = s.OriginalImagePath,
|
|
IsPinned = s.IsPinned,
|
|
Category = s.Category,
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
_history.Add(new ClipboardEntry(s.Text, s.CopiedAt) { IsPinned = s.IsPinned, Category = s.Category });
|
|
}
|
|
}
|
|
// 시작 시 이미지 캐시 정리
|
|
Task.Run(CleanupImageCache);
|
|
LogService.Info($"클립보드 히스토리 {_history.Count}개 복원 (암호화)");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn($"클립보드 히스토리 로드 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void MigrateLegacyFile()
|
|
{
|
|
try
|
|
{
|
|
var json = File.ReadAllText(LegacyPath);
|
|
var plain = Encoding.UTF8.GetBytes(json);
|
|
var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!);
|
|
File.WriteAllBytes(HistoryPath, encrypted);
|
|
File.Delete(LegacyPath); // 평문 파일 삭제
|
|
LogService.Info("클립보드 히스토리 평문→암호화 마이그레이션 완료");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn($"클립보드 히스토리 마이그레이션 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task SaveHistoryAsync()
|
|
{
|
|
try
|
|
{
|
|
var snapshot = BuildSnapshot();
|
|
var json = JsonSerializer.Serialize(snapshot, JsonOpts);
|
|
var plain = Encoding.UTF8.GetBytes(json);
|
|
var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!);
|
|
await File.WriteAllBytesAsync(HistoryPath, encrypted).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn($"클립보드 히스토리 저장 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void SaveHistorySync()
|
|
{
|
|
try
|
|
{
|
|
var snapshot = BuildSnapshot();
|
|
var json = JsonSerializer.Serialize(snapshot, JsonOpts);
|
|
var plain = Encoding.UTF8.GetBytes(json);
|
|
var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!);
|
|
File.WriteAllBytes(HistoryPath, encrypted);
|
|
}
|
|
catch { /* 종료 시 실패 무시 */ }
|
|
}
|
|
|
|
private List<SavedClipEntry> BuildSnapshot()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
return _history.Select(e =>
|
|
{
|
|
if (e.IsText)
|
|
return new SavedClipEntry { Text = e.Text, CopiedAt = e.CopiedAt, IsPinned = e.IsPinned, Category = e.Category };
|
|
var b64 = ImageToBase64(e.Image);
|
|
return new SavedClipEntry
|
|
{
|
|
CopiedAt = e.CopiedAt,
|
|
ImageBase64 = b64,
|
|
OriginalImagePath = e.OriginalImagePath,
|
|
IsPinned = e.IsPinned,
|
|
Category = e.Category,
|
|
};
|
|
}).ToList();
|
|
}
|
|
}
|
|
|
|
// System.Text.Json 역직렬화를 위해 기본 생성자 + 프로퍼티 형태로 선언
|
|
private class SavedClipEntry
|
|
{
|
|
public string Text { get; set; } = "";
|
|
public DateTime CopiedAt { get; set; }
|
|
/// <summary>이미지 썸네일 PNG 바이트 (Base64 인코딩). null이면 텍스트 항목.</summary>
|
|
public string? ImageBase64 { get; set; }
|
|
/// <summary>원본 이미지 파일 경로 (clipboard_images 폴더).</summary>
|
|
public string? OriginalImagePath { get; set; }
|
|
public bool IsPinned { get; set; }
|
|
public string Category { get; set; } = "일반";
|
|
}
|
|
|
|
// ─── 내부 ──────────────────────────────────────────────────────────────
|
|
|
|
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
|
{
|
|
if (msg == WM_CLIPBOARDUPDATE)
|
|
{
|
|
OnClipboardUpdate();
|
|
handled = false;
|
|
}
|
|
return IntPtr.Zero;
|
|
}
|
|
|
|
private void OnClipboardUpdate()
|
|
{
|
|
var sequence = GetClipboardSequenceNumber();
|
|
|
|
if (_ignoreNext)
|
|
{
|
|
_ignoreNext = false;
|
|
_lastClipboardSequenceNumber = sequence;
|
|
return;
|
|
}
|
|
|
|
if (sequence != 0 && sequence == _lastClipboardSequenceNumber)
|
|
return;
|
|
|
|
_lastClipboardSequenceNumber = sequence;
|
|
if (!_settings.Settings.ClipboardHistory.Enabled) return;
|
|
|
|
Application.Current.Dispatcher.Invoke(() =>
|
|
{
|
|
try
|
|
{
|
|
ClipboardEntry? entry = null;
|
|
|
|
// ─── 텍스트 ────────────────────────────────────────────────────
|
|
if (Clipboard.ContainsText())
|
|
{
|
|
var text = Clipboard.GetText();
|
|
if (string.IsNullOrWhiteSpace(text)) return;
|
|
if (text.Length > 10_000) return;
|
|
|
|
// 제외 패턴 검사
|
|
foreach (var pattern in _settings.Settings.ClipboardHistory.ExcludePatterns)
|
|
{
|
|
try
|
|
{
|
|
if (Regex.IsMatch(text.Trim(), pattern,
|
|
RegexOptions.None, TimeSpan.FromMilliseconds(200)))
|
|
return;
|
|
}
|
|
catch { /* 잘못된 패턴 무시 */ }
|
|
}
|
|
|
|
var category = _settings.Settings.Launcher.EnableClipboardAutoCategory
|
|
? DetectCategory(text) : "일반";
|
|
entry = new ClipboardEntry(text, DateTime.Now) { Category = category };
|
|
}
|
|
// ─── 이미지 ────────────────────────────────────────────────────
|
|
else if (Clipboard.ContainsImage())
|
|
{
|
|
var src = Clipboard.GetImage();
|
|
if (src == null) return;
|
|
|
|
// 원본 이미지를 캐시 폴더에 PNG로 저장
|
|
var originalPath = SaveOriginalImage(src);
|
|
|
|
// 표시용 썸네일 (최대 80px 폭)
|
|
var thumb = CreateThumbnail(src, 80);
|
|
entry = new ClipboardEntry("", DateTime.Now)
|
|
{
|
|
Image = thumb,
|
|
OriginalImagePath = originalPath,
|
|
};
|
|
}
|
|
|
|
if (entry == null) return;
|
|
|
|
lock (_lock)
|
|
{
|
|
// 텍스트 중복 제거
|
|
if (entry.IsText)
|
|
{
|
|
if (_history.Count > 0 && _history[0].Text == entry.Text) return;
|
|
_history.RemoveAll(e => e.IsText && e.Text == entry.Text);
|
|
}
|
|
|
|
_history.Insert(0, entry);
|
|
|
|
int max = _settings.Settings.ClipboardHistory.MaxItems;
|
|
while (_history.Count > max)
|
|
{
|
|
// 핀 고정 항목은 삭제 보호 — 뒤에서부터 핀 아닌 항목 제거
|
|
var removeIdx = _history.FindLastIndex(e => !e.IsPinned);
|
|
if (removeIdx >= 0) _history.RemoveAt(removeIdx);
|
|
else break; // 전부 핀이면 중단
|
|
}
|
|
}
|
|
|
|
HistoryChanged?.Invoke(this, EventArgs.Empty);
|
|
_ = SaveHistoryAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn($"클립보드 캡처 실패: {ex.Message}");
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>원본 이미지를 캐시 폴더에 PNG로 저장합니다.</summary>
|
|
private static string? SaveOriginalImage(BitmapSource src)
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(ImageCachePath);
|
|
var fileName = $"clip_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png";
|
|
var filePath = Path.Combine(ImageCachePath, fileName);
|
|
|
|
var encoder = new PngBitmapEncoder();
|
|
encoder.Frames.Add(BitmapFrame.Create(src));
|
|
using var fs = new FileStream(filePath, FileMode.Create);
|
|
encoder.Save(fs);
|
|
return filePath;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn($"원본 이미지 저장 실패: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>원본 이미지를 파일에서 로드합니다.</summary>
|
|
public static BitmapSource? LoadOriginalImage(string? path)
|
|
{
|
|
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
|
|
try
|
|
{
|
|
var bi = new BitmapImage();
|
|
bi.BeginInit();
|
|
bi.CacheOption = BitmapCacheOption.OnLoad;
|
|
bi.UriSource = new Uri(path, UriKind.Absolute);
|
|
bi.EndInit();
|
|
bi.Freeze();
|
|
return bi;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제).</summary>
|
|
public static void CleanupImageCache()
|
|
{
|
|
try
|
|
{
|
|
if (!Directory.Exists(ImageCachePath)) return;
|
|
var files = new DirectoryInfo(ImageCachePath)
|
|
.GetFiles("clip_*.png")
|
|
.OrderBy(f => f.LastWriteTime)
|
|
.ToList();
|
|
|
|
// 30일 초과 파일 삭제
|
|
var cutoff = DateTime.Now.AddDays(-MaxCacheAgeDays);
|
|
foreach (var f in files.Where(f => f.LastWriteTime < cutoff).ToList())
|
|
{
|
|
try { f.Delete(); files.Remove(f); } catch { }
|
|
}
|
|
|
|
// 500MB 초과 시 오래된 파일부터 삭제
|
|
var totalSize = files.Sum(f => f.Length);
|
|
while (totalSize > MaxCacheSizeBytes && files.Count > 0)
|
|
{
|
|
var oldest = files[0];
|
|
totalSize -= oldest.Length;
|
|
try { oldest.Delete(); } catch { }
|
|
files.RemoveAt(0);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn($"이미지 캐시 정리 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static BitmapSource CreateThumbnail(BitmapSource src, int maxWidth)
|
|
{
|
|
if (src.PixelWidth <= maxWidth) return src;
|
|
var scale = (double)maxWidth / src.PixelWidth;
|
|
return new TransformedBitmap(src, new System.Windows.Media.ScaleTransform(scale, scale));
|
|
}
|
|
|
|
private static string? ImageToBase64(BitmapSource? img)
|
|
{
|
|
if (img == null) return null;
|
|
try
|
|
{
|
|
var encoder = new PngBitmapEncoder();
|
|
encoder.Frames.Add(BitmapFrame.Create(img));
|
|
using var ms = new MemoryStream();
|
|
encoder.Save(ms);
|
|
return Convert.ToBase64String(ms.ToArray());
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
private static BitmapSource? Base64ToImage(string? base64)
|
|
{
|
|
if (string.IsNullOrEmpty(base64)) return null;
|
|
try
|
|
{
|
|
var bytes = Convert.FromBase64String(base64);
|
|
using var ms = new MemoryStream(bytes);
|
|
var decoder = BitmapDecoder.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
|
|
return decoder.Frames[0];
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
// ─── P/Invoke ──────────────────────────────────────────────────────────
|
|
|
|
[DllImport("user32.dll", SetLastError = true)]
|
|
private static extern bool AddClipboardFormatListener(IntPtr hwnd);
|
|
|
|
[DllImport("user32.dll", SetLastError = true)]
|
|
private static extern bool RemoveClipboardFormatListener(IntPtr hwnd);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern uint GetClipboardSequenceNumber();
|
|
}
|
|
|
|
/// <summary>클립보드 히스토리 단일 항목. 텍스트 또는 이미지 중 하나를 담습니다.</summary>
|
|
public record ClipboardEntry(string Text, DateTime CopiedAt)
|
|
{
|
|
/// <summary>이미지 항목의 표시용 썸네일 (텍스트 항목은 null)</summary>
|
|
public BitmapSource? Image { get; init; }
|
|
|
|
/// <summary>원본 해상도 이미지 파일 경로 (clipboard_images 폴더). null이면 썸네일만 존재.</summary>
|
|
public string? OriginalImagePath { get; init; }
|
|
|
|
/// <summary>핀 고정 여부 (핀 항목은 삭제되지 않고 상단에 표시)</summary>
|
|
public bool IsPinned { get; set; }
|
|
|
|
/// <summary>카테고리 (URL, 코드, 경로, 일반)</summary>
|
|
public string Category { get; set; } = "일반";
|
|
|
|
/// <summary>텍스트 항목 여부</summary>
|
|
public bool IsText => Image == null;
|
|
|
|
/// <summary>UI 표시용 첫 줄 미리보기 (최대 80자)</summary>
|
|
public string Preview
|
|
{
|
|
get
|
|
{
|
|
if (!IsText) return "[이미지]";
|
|
var line = Text.Replace("\r\n", "↵ ").Replace("\n", "↵ ").Replace("\r", "↵ ");
|
|
return line.Length > 80 ? line[..77] + "…" : line;
|
|
}
|
|
}
|
|
|
|
/// <summary>복사 시각 상대 표시</summary>
|
|
public string RelativeTime
|
|
{
|
|
get
|
|
{
|
|
var diff = DateTime.Now - CopiedAt;
|
|
if (diff.TotalSeconds < 60) return "방금 전";
|
|
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}분 전";
|
|
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}시간 전";
|
|
return CopiedAt.ToString("MM/dd HH:mm");
|
|
}
|
|
}
|
|
}
|