From 624f62cbddb05d47cf7e8ab2bb2d17e7649ad39b Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sun, 17 May 2026 13:03:04 -0500 Subject: [PATCH] feat: derive clip name from FileMaker metadata on paste --- .../ClipTypes/IClipTypeStrategy.cs | 9 +++ .../ClipTypes/LayoutClipStrategy.cs | 2 + .../ClipTypes/OpaqueClipStrategy.cs | 2 + .../ClipTypes/ScriptClipStrategy.cs | 16 +++++ .../ClipTypes/TableClipStrategy.cs | 16 +++++ src/SharpFM/ViewModels/MainWindowViewModel.cs | 17 +++++ .../ClipTypes/LayoutClipStrategyTests.cs | 9 +++ .../ClipTypes/OpaqueClipStrategyTests.cs | 8 +++ .../ClipTypes/ScriptClipStrategyTests.cs | 35 +++++++++ .../ClipTypes/TableClipStrategyTests.cs | 26 +++++++ .../ViewModels/MainWindowViewModelTests.cs | 72 +++++++++++++++++++ 11 files changed, 212 insertions(+) diff --git a/src/SharpFM.Model/ClipTypes/IClipTypeStrategy.cs b/src/SharpFM.Model/ClipTypes/IClipTypeStrategy.cs index 9ac95ce..3d3bd41 100644 --- a/src/SharpFM.Model/ClipTypes/IClipTypeStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/IClipTypeStrategy.cs @@ -29,4 +29,13 @@ public interface IClipTypeStrategy /// "new clip" flows in the host and by plugins. /// string DefaultXml(string clipName); + + /// + /// Pull a clip-display-name hint out of when the + /// wire format carries one (e.g. <Script name="…"> for + /// Mac-XMSC, <BaseTable name="…"> for Mac-XMTB). + /// Returns null for formats with no embedded name or when the + /// element is absent. Must not throw on malformed input. + /// + string? TryGetSourceName(string xml); } diff --git a/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs b/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs index 8fe3c4b..71723a5 100644 --- a/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs @@ -29,4 +29,6 @@ public ClipParseResult Parse(string xml) public string DefaultXml(string clipName) => ""; + + public string? TryGetSourceName(string xml) => null; } diff --git a/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs b/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs index 46c6043..c21153f 100644 --- a/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs @@ -44,4 +44,6 @@ public ClipParseResult Parse(string xml) public string DefaultXml(string clipName) => ""; + + public string? TryGetSourceName(string xml) => null; } diff --git a/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs b/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs index 0c88d82..dd4224e 100644 --- a/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Xml; +using System.Xml.Linq; using SharpFM.Model.Parsing; using SharpFM.Model.Scripting; @@ -63,4 +66,17 @@ public ClipParseResult Parse(string xml) public string DefaultXml(string clipName) => ""; + + public string? TryGetSourceName(string xml) + { + try + { + var name = XDocument.Parse(xml).Descendants("Script").FirstOrDefault()?.Attribute("name")?.Value; + return string.IsNullOrEmpty(name) ? null : name; + } + catch (XmlException) + { + return null; + } + } } diff --git a/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs b/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs index 2e25f52..6d4f103 100644 --- a/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Xml; +using System.Xml.Linq; using SharpFM.Model.Parsing; using SharpFM.Model.Schema; using SharpFM.Model.Scripting; @@ -66,4 +69,17 @@ public string DefaultXml(string clipName) => _wrapsBaseTable ? $"" : ""; + + public string? TryGetSourceName(string xml) + { + try + { + var name = XDocument.Parse(xml).Descendants("BaseTable").FirstOrDefault()?.Attribute("name")?.Value; + return string.IsNullOrEmpty(name) ? null : name; + } + catch (XmlException) + { + return null; + } + } } diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index 59fb5c5..bde6459 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -334,6 +334,19 @@ public async Task CopyAsClass() } } + // Pick a clip name that doesn't collide with anything already loaded. + // Returns the input when it's free, otherwise appends "(2)", "(3)", … until + // a free slot is found. + private string UniqueClipName(string desired) + { + if (FileMakerClips.All(c => c.Clip.Name != desired)) return desired; + for (var n = 2; ; n++) + { + var candidate = $"{desired} ({n})"; + if (FileMakerClips.All(c => c.Clip.Name != candidate)) return candidate; + } + } + public async Task PasteFileMakerClipData() { try @@ -355,6 +368,10 @@ public async Task PasteFileMakerClipData() // don't add duplicates if (FileMakerClips.Any(k => k.Clip.Xml == clip.Xml)) continue; + var sourceName = ClipTypeRegistry.For(format).TryGetSourceName(clip.Xml); + var desired = string.IsNullOrWhiteSpace(sourceName) ? "new-clip" : sourceName; + clip = clip.Rename(UniqueClipName(desired)); + lastAdded = new ClipViewModel(clip); FileMakerClips.Add(lastAdded); count++; diff --git a/tests/SharpFM.Tests/ClipTypes/LayoutClipStrategyTests.cs b/tests/SharpFM.Tests/ClipTypes/LayoutClipStrategyTests.cs index b3e1de5..de06923 100644 --- a/tests/SharpFM.Tests/ClipTypes/LayoutClipStrategyTests.cs +++ b/tests/SharpFM.Tests/ClipTypes/LayoutClipStrategyTests.cs @@ -12,6 +12,15 @@ public void Identity_IsLayout() Assert.Equal("Layout", LayoutClipStrategy.Instance.DisplayName); } + [Fact] + public void TryGetSourceName_ReturnsNull() + { + var name = LayoutClipStrategy.Instance.TryGetSourceName( + ""); + + Assert.Null(name); + } + [Fact] public void Parse_ValidLayoutSnippet_ReturnsSuccess() { diff --git a/tests/SharpFM.Tests/ClipTypes/OpaqueClipStrategyTests.cs b/tests/SharpFM.Tests/ClipTypes/OpaqueClipStrategyTests.cs index 43a8c35..a294dc8 100644 --- a/tests/SharpFM.Tests/ClipTypes/OpaqueClipStrategyTests.cs +++ b/tests/SharpFM.Tests/ClipTypes/OpaqueClipStrategyTests.cs @@ -53,4 +53,12 @@ public void DefaultXml_ProducesParseableSnippet() var result = OpaqueClipStrategy.Instance.Parse(seed); Assert.IsType(result); } + + [Fact] + public void TryGetSourceName_ReturnsNull() + { + var name = OpaqueClipStrategy.Instance.TryGetSourceName(""); + + Assert.Null(name); + } } diff --git a/tests/SharpFM.Tests/ClipTypes/ScriptClipStrategyTests.cs b/tests/SharpFM.Tests/ClipTypes/ScriptClipStrategyTests.cs index b111bf6..60a067c 100644 --- a/tests/SharpFM.Tests/ClipTypes/ScriptClipStrategyTests.cs +++ b/tests/SharpFM.Tests/ClipTypes/ScriptClipStrategyTests.cs @@ -14,6 +14,41 @@ public void StepsAndScript_HaveDistinctIdentities() Assert.Equal("Script", ScriptClipStrategy.Script.DisplayName); } + [Fact] + public void TryGetSourceName_ReturnsScriptNameAttribute() + { + var name = ScriptClipStrategy.Script.TryGetSourceName( + ""); + + Assert.Equal("FooBar", name); + } + + [Fact] + public void TryGetSourceName_StepsClipWithoutScriptWrapper_ReturnsNull() + { + var name = ScriptClipStrategy.Steps.TryGetSourceName( + ""); + + Assert.Null(name); + } + + [Fact] + public void TryGetSourceName_MalformedXml_ReturnsNull() + { + var name = ScriptClipStrategy.Script.TryGetSourceName(""); + + Assert.Null(name); + } + + [Fact] + public void TryGetSourceName_PreservesPunctuationInName() + { + var name = ScriptClipStrategy.Script.TryGetSourceName( + ""); + + Assert.Equal("My \"favorite\" script", name); + } + [Fact] public void Parse_EmptyScriptSnippet_ReturnsLosslessSuccess() { diff --git a/tests/SharpFM.Tests/ClipTypes/TableClipStrategyTests.cs b/tests/SharpFM.Tests/ClipTypes/TableClipStrategyTests.cs index 73e5ca7..d299d54 100644 --- a/tests/SharpFM.Tests/ClipTypes/TableClipStrategyTests.cs +++ b/tests/SharpFM.Tests/ClipTypes/TableClipStrategyTests.cs @@ -12,6 +12,32 @@ public void TableAndField_HaveDistinctIdentities() Assert.Equal("Mac-XMFD", TableClipStrategy.Field.FormatId); } + [Fact] + public void TryGetSourceName_ReturnsBaseTableNameAttribute() + { + var name = TableClipStrategy.Table.TryGetSourceName( + ""); + + Assert.Equal("Customers", name); + } + + [Fact] + public void TryGetSourceName_FieldClipWithoutBaseTable_ReturnsNull() + { + var name = TableClipStrategy.Field.TryGetSourceName( + ""); + + Assert.Null(name); + } + + [Fact] + public void TryGetSourceName_MalformedXml_ReturnsNull() + { + var name = TableClipStrategy.Table.TryGetSourceName(""); + + Assert.Null(name); + } + [Fact] public void Parse_ValidTable_ReturnsSuccess() { diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs index 6e84eb2..958d82c 100644 --- a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs +++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using SharpFM.Dialogs; +using SharpFM.Model; using SharpFM.Plugin; using SharpFM.Plugin.UI; using SharpFM.Services; @@ -199,6 +200,77 @@ public async Task PasteFileMakerClipData_SelectsLastPastedClip() Assert.Contains("Pasted 2 clip(s)", vm.StatusMessage); } + [Fact] + public async Task PasteFileMakerClipData_ScriptWithMetadataName_UsesMetadataAsClipName() + { + var clipboard = new MockClipboardService(); + clipboard.ClipboardData["Mac-XMSC"] = BuildClipBytes( + ""); + var vm = CreateVm(clipboard); + vm.FileMakerClips.Clear(); + + await vm.PasteFileMakerClipData(); + + Assert.Equal("OrderTotal", vm.SelectedClip!.Clip.Name); + } + + [Fact] + public async Task PasteFileMakerClipData_TableWithBaseTableName_UsesItAsClipName() + { + var clipboard = new MockClipboardService(); + clipboard.ClipboardData["Mac-XMTB"] = BuildClipBytes( + ""); + var vm = CreateVm(clipboard); + vm.FileMakerClips.Clear(); + + await vm.PasteFileMakerClipData(); + + Assert.Equal("Customers", vm.SelectedClip!.Clip.Name); + } + + [Fact] + public async Task PasteFileMakerClipData_NoMetadataName_FallsBackToNewClip() + { + var clipboard = new MockClipboardService(); + clipboard.ClipboardData["Mac-XMSS"] = BuildClipBytes( + ""); + var vm = CreateVm(clipboard); + vm.FileMakerClips.Clear(); + + await vm.PasteFileMakerClipData(); + + Assert.Equal("new-clip", vm.SelectedClip!.Clip.Name); + } + + [Fact] + public async Task PasteFileMakerClipData_CollidingName_AppendsNumericSuffix() + { + var clipboard = new MockClipboardService(); + clipboard.ClipboardData["Mac-XMSC"] = BuildClipBytes( + ""); + var vm = CreateVm(clipboard); + vm.FileMakerClips.Clear(); + vm.FileMakerClips.Add(new ClipViewModel(Clip.FromXml("OrderTotal", "Mac-XMSC", ""))); + + await vm.PasteFileMakerClipData(); + + Assert.Equal("OrderTotal (2)", vm.SelectedClip!.Clip.Name); + } + + [Fact] + public async Task PasteFileMakerClipData_PreservesPunctuationInName() + { + var clipboard = new MockClipboardService(); + clipboard.ClipboardData["Mac-XMSC"] = BuildClipBytes( + ""); + var vm = CreateVm(clipboard); + vm.FileMakerClips.Clear(); + + await vm.PasteFileMakerClipData(); + + Assert.Equal("My \"favorite\" script", vm.SelectedClip!.Clip.Name); + } + private static byte[] BuildClipBytes(string xml) { var payload = Encoding.UTF8.GetBytes(xml);