using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; namespace Clipman { internal sealed class FileClipboardEventStore : IDisposable { private const int MaxEvents = 200; private readonly object sync = new object(); private readonly Func passwordProvider; private FileClipboardDatabase database = new FileClipboardDatabase(); private FileSystemWatcher watcher; private Timer reloadTimer; public event EventHandler Changed; public string DatabasePath { get; private set; } public string LastStorageError { get; private set; } public FileClipboardEventStore(string databasePath, Func passwordProvider) { if (string.IsNullOrWhiteSpace(databasePath)) { throw new ArgumentException("File history database path cannot be blank.", "databasePath"); } DatabasePath = databasePath; this.passwordProvider = passwordProvider ?? (() => string.Empty); lock (sync) { LoadLocked(); ResetWatcherLocked(); } } public List GetEvents() { return GetEvents("Manual", false); } public void Reload() { lock (sync) { LoadLocked(); ResetWatcherLocked(); } OnChanged(); } public List GetEvents(string sortMode, bool descending) { lock (sync) { var pinned = database.Events .Where(e => e.Pinned) .OrderBy(e => e.ManualOrder) .ThenByDescending(e => e.CapturedAt); var normal = SortNormalEvents(database.Events.Where(e => !e.Pinned), sortMode, descending); return pinned.Concat(normal) .Select(Clone) .ToList(); } } public bool Contains(ClipboardEventSummary summary) { if (summary == null || summary.Files == null || summary.Files.Count == 0) return false; lock (sync) { return database.Events.Any(item => SameFileClipboardEvent(item, summary)); } } private static IEnumerable SortNormalEvents(IEnumerable events, string sortMode, bool descending) { switch ((sortMode ?? string.Empty).Trim().ToUpperInvariant()) { case "TIME": return descending ? events.OrderByDescending(e => e.CapturedAt) : events.OrderBy(e => e.CapturedAt); case "FILES": return descending ? events.OrderByDescending(e => e.FileCount).ThenBy(PrimaryName, StringComparer.CurrentCultureIgnoreCase) : events.OrderBy(e => e.FileCount).ThenBy(PrimaryName, StringComparer.CurrentCultureIgnoreCase); case "NAME": return descending ? events.OrderByDescending(PrimaryName, StringComparer.CurrentCultureIgnoreCase).ThenByDescending(e => e.CapturedAt) : events.OrderBy(PrimaryName, StringComparer.CurrentCultureIgnoreCase).ThenByDescending(e => e.CapturedAt); case "OPERATION": return descending ? events.OrderByDescending(e => e.Operation ?? string.Empty, StringComparer.CurrentCultureIgnoreCase).ThenBy(PrimaryName, StringComparer.CurrentCultureIgnoreCase) : events.OrderBy(e => e.Operation ?? string.Empty, StringComparer.CurrentCultureIgnoreCase).ThenBy(PrimaryName, StringComparer.CurrentCultureIgnoreCase); case "SOURCE": return descending ? events.OrderByDescending(e => e.Source ?? string.Empty, StringComparer.CurrentCultureIgnoreCase).ThenBy(PrimaryName, StringComparer.CurrentCultureIgnoreCase) : events.OrderBy(e => e.Source ?? string.Empty, StringComparer.CurrentCultureIgnoreCase).ThenBy(PrimaryName, StringComparer.CurrentCultureIgnoreCase); case "MANUAL": default: return descending ? events.OrderByDescending(e => e.ManualOrder).ThenByDescending(e => e.CapturedAt) : events.OrderBy(e => e.ManualOrder).ThenByDescending(e => e.CapturedAt); } } public ClipboardEventSummary Add(ClipboardEventSummary summary) { if (summary == null) return null; Normalize(summary); lock (sync) { var existingIndex = database.Events.FindIndex(item => SameFileClipboardEvent(item, summary)); if (existingIndex >= 0) { summary.Pinned = database.Events[existingIndex].Pinned; summary.ManualOrder = database.Events[existingIndex].ManualOrder; database.Events.RemoveAt(existingIndex); } if (summary.ManualOrder <= 0) { summary.ManualOrder = NextManualOrderLocked(); } if (!summary.Pinned) { MoveToTopOfBandLocked(summary); } database.Events.Insert(0, Clone(summary)); if (database.Events.Count > MaxEvents) { TrimNormalEventsLocked(); } SaveLocked(); } OnChanged(); return Clone(summary); } public ClipboardEventSummary MergeCapturedEvents(string baseId, string firstTapId, ClipboardEventSummary merged) { if (merged == null || merged.Files == null || merged.Files.Count == 0) return null; Normalize(merged); ClipboardEventSummary saved; lock (sync) { var baseIndex = database.Events.FindIndex(item => string.Equals(item.Id, baseId, StringComparison.OrdinalIgnoreCase)); var firstIndex = database.Events.FindIndex(item => string.Equals(item.Id, firstTapId, StringComparison.OrdinalIgnoreCase)); var targetIndex = baseIndex >= 0 && !database.Events[baseIndex].Pinned ? baseIndex : firstIndex >= 0 && !database.Events[firstIndex].Pinned ? firstIndex : -1; if (targetIndex >= 0) { var target = database.Events[targetIndex]; target.CapturedAt = merged.CapturedAt; target.Source = merged.Source; target.Operation = merged.Operation; target.SourceMachine = merged.SourceMachine; target.ContainsText = merged.ContainsText; target.Files = merged.Files.ToList(); target.FileCount = target.Files.Count; target.Formats = merged.Formats.ToList(); saved = target; } else { merged.ManualOrder = NextManualOrderLocked(); MoveToTopOfBandLocked(merged); database.Events.Insert(0, merged); saved = merged; } var partial = database.Events.FirstOrDefault(item => string.Equals(item.Id, firstTapId, StringComparison.OrdinalIgnoreCase) && !string.Equals(item.Id, saved.Id, StringComparison.OrdinalIgnoreCase)); if (partial != null && !partial.Pinned) { database.Events.Remove(partial); } MoveToTopOfBandLocked(saved); NormalizeDatabase(); SaveLocked(); saved = Clone(saved); } OnChanged(); return saved; } public int DeleteMany(IEnumerable ids) { var idSet = new HashSet((ids ?? Enumerable.Empty()).Where(id => !string.IsNullOrEmpty(id))); if (idSet.Count == 0) return 0; lock (sync) { var removed = database.Events.RemoveAll(e => idSet.Contains(e.Id) && !e.Pinned); if (removed == 0) return 0; SaveLocked(); OnChanged(); return removed; } } public int Clear() { lock (sync) { var count = database.Events.Count(e => !e.Pinned); if (count == 0) return 0; database.Events.RemoveAll(e => !e.Pinned); SaveLocked(); OnChanged(); return count; } } public int RemoveUnavailableEvents() { lock (sync) { var removed = database.Events.RemoveAll(e => !e.Pinned && IsUnavailableEvent(e)); if (removed == 0) return 0; SaveLocked(); OnChanged(); return removed; } } public bool TogglePinned(string id) { if (string.IsNullOrWhiteSpace(id)) return false; lock (sync) { var item = database.Events.FirstOrDefault(e => string.Equals(e.Id, id, StringComparison.Ordinal)); if (item == null) return false; item.Pinned = !item.Pinned; if (item.ManualOrder <= 0) item.ManualOrder = NextManualOrderLocked(); SaveLocked(); OnChanged(); return item.Pinned; } } public void MoveEvents(IEnumerable ids, int direction) { var selectedIds = (ids ?? Enumerable.Empty()) .Where(id => !string.IsNullOrWhiteSpace(id)) .ToList(); if (selectedIds.Count == 0 || direction == 0) return; lock (sync) { NormalizeDatabase(); var idSet = new HashSet(selectedIds); var firstEvent = database.Events.FirstOrDefault(e => idSet.Contains(e.Id)); if (firstEvent == null) return; var pinned = firstEvent.Pinned; var section = database.Events .Where(e => e.Pinned == pinned) .OrderBy(e => e.ManualOrder) .ThenByDescending(e => e.CapturedAt) .ToList(); var indexes = section .Select((entry, index) => new { entry, index }) .Where(x => idSet.Contains(x.entry.Id)) .Select(x => x.index) .OrderBy(i => i) .ToList(); if (indexes.Count == 0) return; if (direction < 0) { if (indexes.First() == 0) return; } else { if (indexes.Last() >= section.Count - 1) return; } var selected = section.Where(e => idSet.Contains(e.Id)).ToList(); var firstIndex = indexes.First(); var lastIndex = indexes.Last(); foreach (var item in selected) { section.Remove(item); } if (direction < 0) { section.InsertRange(Math.Max(0, firstIndex - 1), selected); } else { section.InsertRange(Math.Min(section.Count, lastIndex + 1 - selected.Count + 1), selected); } for (var i = 0; i < section.Count; i++) { section[i].ManualOrder = i + 1; } SaveLocked(); OnChanged(); } } private static bool IsUnavailableEvent(ClipboardEventSummary item) { if (item == null) return false; if (item.Files == null || item.Files.Count == 0) return true; return item.Files.All(path => string.IsNullOrWhiteSpace(path) || (!File.Exists(path) && !Directory.Exists(path))); } public void ChangeDatabasePassword() { lock (sync) { SaveLocked(); } } private void LoadLocked() { try { database = ClipDatabaseFile.Load(DatabasePath, CurrentPassword()); NormalizeDatabase(); LastStorageError = string.Empty; } catch (Exception ex) { if (!IsStorageAccessException(ex)) throw; database = new FileClipboardDatabase(); LastStorageError = ex.Message; } } private void SaveLocked() { database.UpdatedUnixMs = TimeUtil.NowUnixMs(); try { ClipDatabaseFile.SaveAtomic(DatabasePath, database, CurrentPassword()); LastStorageError = string.Empty; if (watcher == null) { ResetWatcherLocked(); } } catch (Exception ex) { if (!IsStorageAccessException(ex)) throw; LastStorageError = ex.Message; } } private void ResetWatcherLocked() { if (watcher != null) watcher.Dispose(); if (reloadTimer != null) reloadTimer.Dispose(); watcher = null; reloadTimer = null; var dir = Path.GetDirectoryName(DatabasePath); var file = Path.GetFileName(DatabasePath); if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(file)) return; try { Directory.CreateDirectory(dir); reloadTimer = new Timer(delegate { ReloadFromWatcher(); }, null, Timeout.Infinite, Timeout.Infinite); watcher = new FileSystemWatcher(dir, file) { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName | NotifyFilters.CreationTime }; watcher.Changed += WatcherChanged; watcher.Created += WatcherChanged; watcher.Renamed += WatcherChanged; watcher.EnableRaisingEvents = true; } catch (Exception ex) { if (!IsStorageAccessException(ex)) throw; LastStorageError = ex.Message; if (watcher != null) watcher.Dispose(); if (reloadTimer != null) reloadTimer.Dispose(); watcher = null; reloadTimer = null; } } private void WatcherChanged(object sender, FileSystemEventArgs e) { if (reloadTimer != null) reloadTimer.Change(500, Timeout.Infinite); } private void ReloadFromWatcher() { lock (sync) { try { LoadLocked(); } catch { return; } } OnChanged(); } private void NormalizeDatabase() { if (database == null) database = new FileClipboardDatabase(); if (database.Events == null) database.Events = new List(); database.Version = Math.Max(1, database.Version); foreach (var item in database.Events) { Normalize(item); } database.Events = database.Events .Where(e => e != null) .OrderBy(e => e.Pinned ? 0 : 1) .ThenBy(e => e.ManualOrder) .ThenByDescending(e => e.CapturedAt) .ToList(); foreach (var item in database.Events.Where(e => e.ManualOrder <= 0)) { item.ManualOrder = NextManualOrderLocked(); } TrimNormalEventsLocked(); } private static void Normalize(ClipboardEventSummary item) { if (item == null) return; if (string.IsNullOrWhiteSpace(item.Id)) item.Id = Guid.NewGuid().ToString("N"); if (item.CapturedAt == default(DateTime)) item.CapturedAt = DateTime.Now; if (item.Source == null) item.Source = string.Empty; if (item.Operation == null) item.Operation = string.Empty; if (item.SourceMachine == null) item.SourceMachine = string.Empty; if (item.Files == null) item.Files = new List(); if (item.Formats == null) item.Formats = new List(); if (item.FileCount <= 0 && item.Files.Count > 0) item.FileCount = item.Files.Count; } private long NextManualOrderLocked() { return database.Events.Count == 0 ? 1 : database.Events.Max(e => e.ManualOrder) + 1; } private static string PrimaryName(ClipboardEventSummary item) { if (item == null) return string.Empty; if (item.Files != null && item.Files.Count > 0 && !string.IsNullOrWhiteSpace(item.Files[0])) { var name = Path.GetFileName(item.Files[0]); return string.IsNullOrWhiteSpace(name) ? item.Files[0] : name; } if (!string.IsNullOrWhiteSpace(item.Operation)) return item.Operation; if (item.Formats != null && item.Formats.Count > 0) return item.Formats[0] ?? string.Empty; return "Clipboard event"; } private void MoveToTopOfBandLocked(ClipboardEventSummary item) { if (item == null) return; foreach (var existing in database.Events.Where(e => e.Pinned == item.Pinned && e.ManualOrder > 0)) { existing.ManualOrder++; } item.ManualOrder = 1; } private void TrimNormalEventsLocked() { if (database.Events.Count <= MaxEvents) return; var normal = database.Events .Where(e => !e.Pinned) .OrderBy(e => e.ManualOrder) .ThenByDescending(e => e.CapturedAt) .ToList(); var removable = database.Events.Count - MaxEvents; foreach (var item in normal.AsEnumerable().Reverse().Take(removable).ToList()) { database.Events.Remove(item); } } private static bool SameFileClipboardEvent(ClipboardEventSummary left, ClipboardEventSummary right) { if (left == null || right == null) return false; if (left.Files == null || right.Files == null) return false; if (left.Files.Count != right.Files.Count) return false; if (left.Files.Count == 0) return false; var leftFiles = left.Files .Where(path => !string.IsNullOrWhiteSpace(path)) .Select(path => path.Trim()) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) .ToList(); var rightFiles = right.Files .Where(path => !string.IsNullOrWhiteSpace(path)) .Select(path => path.Trim()) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) .ToList(); return leftFiles.SequenceEqual(rightFiles, StringComparer.OrdinalIgnoreCase); } private static ClipboardEventSummary Clone(ClipboardEventSummary source) { if (source == null) return null; Normalize(source); return new ClipboardEventSummary { Id = source.Id, CapturedAt = source.CapturedAt, Source = source.Source ?? string.Empty, Operation = source.Operation ?? string.Empty, SourceMachine = source.SourceMachine ?? string.Empty, ContainsText = source.ContainsText, FileCount = source.FileCount, Files = source.Files == null ? new List() : source.Files.ToList(), Formats = source.Formats == null ? new List() : source.Formats.ToList(), Pinned = source.Pinned, ManualOrder = source.ManualOrder }; } private string CurrentPassword() { return passwordProvider == null ? string.Empty : (passwordProvider() ?? string.Empty); } private static bool IsStorageAccessException(Exception ex) { return ex is IOException || ex is UnauthorizedAccessException || ex is DirectoryNotFoundException || ex is PathTooLongException || ex is NotSupportedException || ex is System.Security.SecurityException; } private void OnChanged() { var handler = Changed; if (handler != null) { handler(this, EventArgs.Empty); } } public void Dispose() { if (watcher != null) watcher.Dispose(); if (reloadTimer != null) reloadTimer.Dispose(); } } }