Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 85 additions & 41 deletions src/SharpFM/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -421,60 +421,55 @@ 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<string>();
var unrecognized = new List<string>();
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)
{
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)
{
Expand All @@ -483,6 +478,55 @@ public async Task PasteFileMakerClipData()
}
}

private async Task<(int added, ClipViewModel? last)> PasteOneFormat(string format, IReadOnlyList<string> 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<string> Combine(IReadOnlyList<string> root, IReadOnlyList<string> sub)
{
if (root.Count == 0) return sub;
Expand Down
75 changes: 72 additions & 3 deletions tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MainWindowViewModel>();
return new MainWindowViewModel(
logger,
logger ?? NullLoggerFactory.Instance.CreateLogger<MainWindowViewModel>(),
clipboard ?? new MockClipboardService(),
folderService ?? new MockFolderService(),
prompt);
}

private sealed class CapturingLogger : ILogger
{
public List<string> Messages { get; } = new();

public IDisposable BeginScope<TState>(TState state) where TState : notnull =>
NullLogger.Instance.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}

private sealed class FakeInputPrompt(params string?[]? answers) : IInputPrompt
{
// A single explicit `null` argument resolves to `params` = null rather
Expand Down Expand Up @@ -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(
"<fmxmlsnippet><Future name=\"Whatever\"/></fmxmlsnippet>");
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(
"<fmxmlsnippet><Future name=\"Whatever\"/></fmxmlsnippet>");
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(
"<fmxmlsnippet><Script name=\"Known\"/></fmxmlsnippet>");
clipboard.ClipboardData["Mac-XMZZ"] = BuildClipBytes(
"<fmxmlsnippet><Future name=\"Unknown\"/></fmxmlsnippet>");
var vm = CreateVm(clipboard);
vm.FileMakerClips.Clear();

await vm.PasteFileMakerClipData();

var pasted = Assert.Single(vm.FileMakerClips);
Assert.Equal("Mac-XMSC", pasted.ClipType);
Assert.DoesNotContain("unknown", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task PasteFileMakerClipData_PreservesPunctuationInName()
{
Expand Down
Loading