Initial commit to new repository
This commit is contained in:
575
src/AxCopilot/Services/ClipboardHistoryService.cs
Normal file
575
src/AxCopilot/Services/ClipboardHistoryService.cs
Normal file
@@ -0,0 +1,575 @@
|
||||
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 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()
|
||||
{
|
||||
if (_ignoreNext) { _ignoreNext = false; return; }
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user