diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index 48c35ab..58cd892 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -421,52 +421,40 @@ public async Task PasteFileMakerClipData() // that's a selected clip's folder or an explicitly tapped empty one. var pasteRoot = TargetFolderPath; - foreach (var format in formats.Where(f => f.StartsWith("Mac-", StringComparison.CurrentCultureIgnoreCase)).Distinct()) + var recognized = new List(); + var unrecognized = new List(); + foreach (var format in formats.Distinct()) { if (string.IsNullOrEmpty(format)) continue; + if (!format.StartsWith("Mac-", StringComparison.CurrentCultureIgnoreCase)) continue; + (ClipTypeRegistry.IsRegistered(format) ? recognized : unrecognized).Add(format); + } - object? clipData = await _clipboard.GetDataAsync(format); - - if (clipData is not byte[] dataObj) continue; - - var rawClip = Clip.FromWireBytes("new-clip", format, dataObj); + foreach (var format in recognized) + { + var (added, last) = await PasteOneFormat(format, pasteRoot); + lastAdded = last ?? lastAdded; + count += added; + } - var decomposed = GroupPasteDecomposer.TryDecompose(rawClip.Xml); - if (decomposed is { Entries.Count: > 0 }) + // Fall back to opaque only when no recognized format produced a + // paste — same payload often arrives in both a known and a + // not-yet-registered encoding, and the known one already covered it. + string? unknownFormatPasted = null; + if (count == 0) + { + foreach (var format in unrecognized) { - foreach (var folder in decomposed.Folders) - { - var fullPath = Combine(pasteRoot, folder.Path); - UpsertFolder(folder with { Path = fullPath }); - } - - foreach (var entry in decomposed.Entries) - { - var entryClip = Clip.FromXml("new-clip", format, entry.Xml); - if (FileMakerClips.Any(k => k.Clip.Xml == entryClip.Xml && - FolderPathsEqual(k.FolderPath, Combine(pasteRoot, entry.FolderPath)))) - continue; - - var folderPath = Combine(pasteRoot, entry.FolderPath); - entryClip = entryClip.Rename(UniqueClipName(entry.Name, folderPath)); - - lastAdded = new ClipViewModel(entryClip) { FolderPath = folderPath }; - FileMakerClips.Add(lastAdded); - count++; - } - continue; + var (added, last) = await PasteOneFormat(format, pasteRoot); + if (added == 0) continue; + lastAdded = last ?? lastAdded; + count += added; + unknownFormatPasted = format; + _logger.LogInformation( + "Pasted unknown format {Format}; will round-trip as raw XML.", + format); + break; } - - // don't add duplicates - if (FileMakerClips.Any(k => k.Clip.Xml == rawClip.Xml)) continue; - - var sourceName = ClipTypeRegistry.For(format).TryGetSourceName(rawClip.Xml); - var desired = string.IsNullOrWhiteSpace(sourceName) ? "new-clip" : sourceName; - var singleClip = rawClip.Rename(UniqueClipName(desired, pasteRoot)); - - lastAdded = new ClipViewModel(singleClip) { FolderPath = pasteRoot }; - FileMakerClips.Add(lastAdded); - count++; } if (lastAdded is not null) @@ -474,7 +462,14 @@ public async Task PasteFileMakerClipData() SelectedClip = lastAdded; } - ShowStatus(count > 0 ? $"Pasted {count} clip(s) from FileMaker" : "No FileMaker clips found on clipboard"); + if (unknownFormatPasted is not null) + { + ShowStatus($"Pasted unknown format {unknownFormatPasted}; will round-trip as raw XML."); + } + else + { + ShowStatus(count > 0 ? $"Pasted {count} clip(s) from FileMaker" : "No FileMaker clips found on clipboard"); + } } catch (Exception e) { @@ -483,6 +478,55 @@ public async Task PasteFileMakerClipData() } } + private async Task<(int added, ClipViewModel? last)> PasteOneFormat(string format, IReadOnlyList pasteRoot) + { + object? clipData = await _clipboard.GetDataAsync(format); + if (clipData is not byte[] dataObj) return (0, null); + + var rawClip = Clip.FromWireBytes("new-clip", format, dataObj); + int added = 0; + ClipViewModel? last = null; + + var decomposed = GroupPasteDecomposer.TryDecompose(rawClip.Xml); + if (decomposed is { Entries.Count: > 0 }) + { + foreach (var folder in decomposed.Folders) + { + var fullPath = Combine(pasteRoot, folder.Path); + UpsertFolder(folder with { Path = fullPath }); + } + + foreach (var entry in decomposed.Entries) + { + var entryClip = Clip.FromXml("new-clip", format, entry.Xml); + if (FileMakerClips.Any(k => k.Clip.Xml == entryClip.Xml && + FolderPathsEqual(k.FolderPath, Combine(pasteRoot, entry.FolderPath)))) + { + continue; + } + + var folderPath = Combine(pasteRoot, entry.FolderPath); + entryClip = entryClip.Rename(UniqueClipName(entry.Name, folderPath)); + + last = new ClipViewModel(entryClip) { FolderPath = folderPath }; + FileMakerClips.Add(last); + added++; + } + return (added, last); + } + + // don't add duplicates + if (FileMakerClips.Any(k => k.Clip.Xml == rawClip.Xml)) return (0, null); + + var sourceName = ClipTypeRegistry.For(format).TryGetSourceName(rawClip.Xml); + var desired = string.IsNullOrWhiteSpace(sourceName) ? "new-clip" : sourceName; + var singleClip = rawClip.Rename(UniqueClipName(desired, pasteRoot)); + + last = new ClipViewModel(singleClip) { FolderPath = pasteRoot }; + FileMakerClips.Add(last); + return (1, last); + } + private static IReadOnlyList Combine(IReadOnlyList root, IReadOnlyList sub) { if (root.Count == 0) return sub; diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs index 3ca8a2f..7da5e72 100644 --- a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs +++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs @@ -41,16 +41,35 @@ public class MainWindowViewModelTests private static MainWindowViewModel CreateVm( MockClipboardService? clipboard = null, MockFolderService? folderService = null, - IInputPrompt? prompt = null) + IInputPrompt? prompt = null, + ILogger? logger = null) { - var logger = NullLoggerFactory.Instance.CreateLogger(); return new MainWindowViewModel( - logger, + logger ?? NullLoggerFactory.Instance.CreateLogger(), clipboard ?? new MockClipboardService(), folderService ?? new MockFolderService(), prompt); } + private sealed class CapturingLogger : ILogger + { + public List Messages { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => + NullLogger.Instance.BeginScope(state); + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Messages.Add(formatter(state, exception)); + } + } + private sealed class FakeInputPrompt(params string?[]? answers) : IInputPrompt { // A single explicit `null` argument resolves to `params` = null rather @@ -485,6 +504,56 @@ public async Task PasteFileMakerClipData_CollidingName_AppendsNumericSuffix() Assert.Equal("OrderTotal (2)", vm.SelectedClip!.Clip.Name); } + [Fact] + public async Task PasteFileMakerClipData_UnknownFormat_PastesAsOpaqueAndWarns() + { + var clipboard = new MockClipboardService(); + clipboard.ClipboardData["Mac-XMZZ"] = BuildClipBytes( + ""); + var vm = CreateVm(clipboard); + vm.FileMakerClips.Clear(); + + await vm.PasteFileMakerClipData(); + + var pasted = Assert.Single(vm.FileMakerClips); + Assert.Equal("Mac-XMZZ", pasted.ClipType); + Assert.Contains("Pasted unknown format Mac-XMZZ", vm.StatusMessage); + Assert.Contains("raw XML", vm.StatusMessage); + } + + [Fact] + public async Task PasteFileMakerClipData_UnknownFormat_LogsFormatId() + { + var clipboard = new MockClipboardService(); + clipboard.ClipboardData["Mac-XMZZ"] = BuildClipBytes( + ""); + var capturing = new CapturingLogger(); + var vm = CreateVm(clipboard, logger: capturing); + vm.FileMakerClips.Clear(); + + await vm.PasteFileMakerClipData(); + + Assert.Contains(capturing.Messages, m => m.Contains("Mac-XMZZ")); + } + + [Fact] + public async Task PasteFileMakerClipData_RecognizedAlongsideUnknown_PastesRecognizedOnly() + { + var clipboard = new MockClipboardService(); + clipboard.ClipboardData["Mac-XMSC"] = BuildClipBytes( + "