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
35 changes: 35 additions & 0 deletions src/SharpFM.Model/FolderData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Collections.Generic;

namespace SharpFM.Model;

/// <summary>
/// Data transfer object for folder-metadata persistence. Carries the FileMaker
/// <c>&lt;Group&gt;</c> attributes that have no home on individual
/// <see cref="ClipData"/> entries. An entry exists for every folder the user
/// has materialized — including empty folders that contain no clips.
/// </summary>
/// <param name="Path">
/// Hierarchy this folder lives at, as an ordered list of segment names. The
/// final segment is the folder's own name. Empty paths are not valid.
/// </param>
public sealed record FolderData(IReadOnlyList<string> Path)
{
/// <summary>
/// FileMaker <c>Group/@id</c>, preserved when the folder came from a paste.
/// Null for folders the user created locally; the host assigns an id when
/// the folder is copied back to FileMaker.
/// </summary>
public int? Id { get; init; }

/// <summary>
/// FileMaker <c>Group/@includeInMenu</c>. Defaults to <c>true</c>, matching
/// FileMaker's default for new groups.
/// </summary>
public bool IncludeInMenu { get; init; } = true;

/// <summary>
/// FileMaker <c>Group/@groupCollapsed</c>. Defaults to <c>false</c>; the UI
/// uses this to pre-collapse a pasted group on load.
/// </summary>
public bool GroupCollapsed { get; init; }
}
124 changes: 124 additions & 0 deletions src/SharpFM.Model/GroupPasteDecomposer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Collections.Generic;
using System.Xml;
using System.Xml.Linq;

namespace SharpFM.Model;

/// <summary>
/// One script extracted from a FileMaker Group paste, paired with the folder
/// path it should land in. <see cref="Xml"/> is a full <c>&lt;fmxmlsnippet&gt;</c>
/// envelope wrapping a single <c>&lt;Script&gt;</c>, ready to be re-pasted back
/// to FileMaker as a single clip.
/// </summary>
public sealed record GroupPasteEntry(string Name, string Xml, IReadOnlyList<string> FolderPath);

/// <summary>
/// Decomposed view of an <c>fmxmlsnippet</c> Group paste: the per-script
/// entries plus folder metadata captured from every enclosing
/// <c>&lt;Group&gt;</c>.
/// </summary>
public sealed record GroupPasteResult(
IReadOnlyList<GroupPasteEntry> Entries,
IReadOnlyList<FolderData> Folders);

/// <summary>
/// Decomposes a FileMaker script-folder paste (an <c>fmxmlsnippet</c> whose
/// root contains <c>&lt;Group&gt;</c> elements) into one entry per
/// <c>&lt;Script&gt;</c>, preserving the Group hierarchy as
/// <see cref="GroupPasteEntry.FolderPath"/> and the Group attributes as
/// <see cref="FolderData"/>.
/// </summary>
public static class GroupPasteDecomposer
{
/// <summary>
/// Returns the entries and folder metadata when the snippet contains any
/// <c>&lt;Group&gt;</c> at the top level, or <c>null</c> when the snippet
/// is a plain single-clip paste. Scripts that sit at the root alongside a
/// Group are emitted with an empty <see cref="GroupPasteEntry.FolderPath"/>
/// so the caller can place them at the paste root.
/// </summary>
public static GroupPasteResult? TryDecompose(string xml)
{
XDocument doc;
try
{
doc = XDocument.Parse(xml);
}
catch (XmlException)
{
return null;
}

var root = doc.Root;
if (root is null) return null;

var hasGroup = false;
foreach (var _ in root.Elements("Group")) { hasGroup = true; break; }
if (!hasGroup) return null;

var entries = new List<GroupPasteEntry>();
var folders = new List<FolderData>();
Walk(root, new List<string>(), entries, folders);
return new GroupPasteResult(entries, folders);
}

private static void Walk(
XElement parent,
List<string> folderPath,
List<GroupPasteEntry> entries,
List<FolderData> folders)
{
foreach (var child in parent.Elements())
{
switch (child.Name.LocalName)
{
case "Script":
entries.Add(BuildEntry(child, folderPath));
break;
case "Group":
var name = child.Attribute("name")?.Value ?? "Group";
folderPath.Add(name);
folders.Add(BuildFolder(child, folderPath));
Walk(child, folderPath, entries, folders);
folderPath.RemoveAt(folderPath.Count - 1);
break;
}
}
}

private static GroupPasteEntry BuildEntry(XElement script, List<string> folderPath)
{
var name = script.Attribute("name")?.Value;
if (string.IsNullOrEmpty(name)) name = "new-clip";

var snippet = new XElement("fmxmlsnippet",
new XAttribute("type", "FMObjectList"),
new XElement(script));

var xml = new XDocument(snippet).ToString(SaveOptions.DisableFormatting);

return new GroupPasteEntry(name, xml, folderPath.ToArray());
}

private static FolderData BuildFolder(XElement group, List<string> folderPath)
{
int? id = null;
if (int.TryParse(group.Attribute("id")?.Value, out var parsedId))
{
id = parsedId;
}

return new FolderData(folderPath.ToArray())
{
Id = id,
IncludeInMenu = ParseFmBool(group.Attribute("includeInMenu"), defaultValue: true),
GroupCollapsed = ParseFmBool(group.Attribute("groupCollapsed"), defaultValue: false),
};
}

private static bool ParseFmBool(XAttribute? attribute, bool defaultValue)
{
if (attribute is null) return defaultValue;
return string.Equals(attribute.Value, "True", System.StringComparison.OrdinalIgnoreCase);
}
}
16 changes: 16 additions & 0 deletions src/SharpFM.Model/IClipRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ public interface IClipRepository
/// </summary>
Task SaveClipsAsync(IReadOnlyList<ClipData> clips);

/// <summary>
/// Load folder metadata records — one per materialized folder (including
/// empty ones). Default implementation returns an empty list for backends
/// that don't model folders independently of clip paths.
/// </summary>
Task<IReadOnlyList<FolderData>> LoadFoldersAsync() =>
Task.FromResult<IReadOnlyList<FolderData>>([]);

/// <summary>
/// Save folder metadata. The implementation should handle creates,
/// updates, and deletes (folders not in the list should be removed).
/// Default implementation is a no-op for backends that infer folders from
/// clip paths only.
/// </summary>
Task SaveFoldersAsync(IReadOnlyList<FolderData> folders) => Task.CompletedTask;

/// <summary>
/// Open a location picker and switch to the selected location.
/// Only called if <see cref="SupportsLocationPicker"/> is true.
Expand Down
159 changes: 95 additions & 64 deletions src/SharpFM/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@
<MenuItem Header="_File">
<MenuItem
Command="{Binding NewScriptCommand}"
Header="New Script"
Header="New Script (whole script)"
InputGesture="Ctrl+N" />
<MenuItem Command="{Binding NewScriptStepsCommand}" Header="New Script Steps (lines only)" />
<MenuItem
Command="{Binding NewTableCommand}"
Header="New Table"
InputGesture="Ctrl+Shift+N" />
<MenuItem Command="{Binding NewFolderCommand}" Header="New Folder..." />
<Separator />
<MenuItem Command="{Binding OpenFolderPicker}" Header="Open Folder..." />
<MenuItem
Expand All @@ -64,6 +66,7 @@
<Separator />
<MenuItem Command="{Binding CopyAsClass}" Header="Copy as C# Class" />
<Separator />
<MenuItem Command="{Binding RenameSelectedClip}" Header="Rename..." />
<MenuItem Command="{Binding DeleteSelectedClip}" Header="Delete Clip" />
</MenuItem>
<MenuItem Header="_Tools">
Expand Down Expand Up @@ -156,72 +159,100 @@

<!-- Left panel: Clips navigation with Fluent 2 surface treatment -->
<Border Grid.Column="0" Classes="Fluent2SurfaceElevated">
<ScrollViewer>
<StackPanel Margin="16" Spacing="16">
<!-- Search section -->
<StackPanel Spacing="8">
<TextBlock Classes="Fluent2Subtitle" Text="Search Clips" />
<TextBox
Classes="Fluent2"
Text="{Binding SearchText}"
Watermark="Type to search clips..." />
</StackPanel>
<!--
DockPanel keeps the search/header rows docked at the top
and lets the TreeView consume all remaining height so the
empty space below the last node is part of the tree's hit
area (root-targeted right-click).
-->
<DockPanel Margin="16" LastChildFill="True">
<!-- Search section -->
<StackPanel
Margin="0,0,0,16"
DockPanel.Dock="Top"
Spacing="8">
<TextBlock Classes="Fluent2Subtitle" Text="Search Clips" />
<TextBox
Classes="Fluent2"
Text="{Binding SearchText}"
Watermark="Type to search clips..." />
</StackPanel>

<TextBlock
Margin="0,0,0,8"
Classes="Fluent2Subtitle"
DockPanel.Dock="Top"
Text="Available Clips" />

<!--
Clips tree section. Mirrors the repository's folder
hierarchy (ClipViewModel.FolderPath). Single-tap a
clip for a preview tab, double-tap for a permanent
tab.
-->
<TreeView
x:Name="clipsTreeView"
ContextRequested="ClipsTree_ContextRequested"
DoubleTapped="ClipsTree_DoubleTapped"
ItemsSource="{Binding RootNodes}"
Tapped="ClipsTree_Tapped">
<!--
Clips tree section. Mirrors the repository's folder
hierarchy (ClipViewModel.FolderPath). Single-tap a
clip for a preview tab, double-tap for a permanent
tab.
Bind TreeViewItem.IsExpanded two-way to the VM so the
tree honours ClipTreeNodeViewModel.IsExpanded (default
true) instead of collapsing every node on rebuild,
and so the user's chevron clicks flow back to the VM.
-->
<StackPanel Spacing="8">
<TextBlock Classes="Fluent2Subtitle" Text="Available Clips" />
<TreeView
x:Name="clipsTreeView"
ContextRequested="ClipsTree_ContextRequested"
DoubleTapped="ClipsTree_DoubleTapped"
ItemsSource="{Binding RootNodes}"
Tapped="ClipsTree_Tapped">
<TreeView.ContextMenu>
<ContextMenu>
<MenuItem Command="{Binding CopySelectedToClip}" Header="Copy to FileMaker" />
<MenuItem Command="{Binding CopyAsScript}" Header="Copy as Script" />
<MenuItem Command="{Binding CopyAsScriptSteps}" Header="Copy as Script Steps" />
<MenuItem Command="{Binding CopyAsClass}" Header="Copy as C# Class" />
<Separator />
<MenuItem Command="{Binding RenameSelectedClip}" Header="Rename..." />
<MenuItem Command="{Binding DeleteSelectedClip}" Header="Delete Clip" />
</ContextMenu>
</TreeView.ContextMenu>
<TreeView.DataTemplates>
<TreeDataTemplate DataType="vm:ClipTreeNodeViewModel" ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock
FontWeight="SemiBold"
IsVisible="{Binding IsFolder}"
Text="{Binding Name}" />
<StackPanel
IsVisible="{Binding IsClip}"
Orientation="Horizontal"
Spacing="6">
<TextBlock Text="{Binding Clip.Clip.Name}" />
<TextBlock
Classes="Fluent2Caption"
Opacity="0.6"
Text="{Binding Clip.ClipTypeDisplay}" />
<TextBlock
Classes="Fluent2Caption"
Foreground="DarkOrange"
IsVisible="{Binding !Clip.IsLossless}"
Text="!"
ToolTip.Tip="This clip's XML did not round-trip cleanly through the domain model. See the status bar for details when selected." />
</StackPanel>
</StackPanel>
</TreeDataTemplate>
</TreeView.DataTemplates>
</TreeView>
</StackPanel>
</StackPanel>
</ScrollViewer>
<TreeView.Styles>
<Style x:DataType="vm:ClipTreeNodeViewModel" Selector="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
</Style>
</TreeView.Styles>
<TreeView.ContextMenu>
<ContextMenu>
<MenuItem Command="{Binding NewScriptCommand}" Header="New Script (whole script)" />
<MenuItem Command="{Binding NewScriptStepsCommand}" Header="New Script Steps (lines only)" />
<MenuItem Command="{Binding NewTableCommand}" Header="New Table" />
<MenuItem Command="{Binding NewFolderCommand}" Header="New Folder..." />
<Separator />
<MenuItem Command="{Binding PasteFileMakerClipData}" Header="Paste from FileMaker" />
<Separator />
<MenuItem Command="{Binding CopySelectedToClip}" Header="Copy to FileMaker" />
<MenuItem Command="{Binding CopyAsScript}" Header="Copy as Script (whole script)" />
<MenuItem Command="{Binding CopyAsScriptSteps}" Header="Copy as Script Steps (lines only)" />
<MenuItem Command="{Binding CopyAsClass}" Header="Copy as C# Class" />
<Separator />
<MenuItem Command="{Binding RenameSelectedClip}" Header="Rename..." />
<MenuItem Command="{Binding DeleteSelectedClip}" Header="Delete Clip" />
</ContextMenu>
</TreeView.ContextMenu>
<TreeView.DataTemplates>
<TreeDataTemplate DataType="vm:ClipTreeNodeViewModel" ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock
FontWeight="SemiBold"
IsVisible="{Binding IsFolder}"
Text="{Binding Name}" />
<StackPanel
IsVisible="{Binding IsClip}"
Orientation="Horizontal"
Spacing="6">
<TextBlock Text="{Binding Clip.Clip.Name}" />
<TextBlock
Classes="Fluent2Caption"
Opacity="0.6"
Text="{Binding Clip.ClipTypeDisplay}" />
<TextBlock
Classes="Fluent2Caption"
Foreground="DarkOrange"
IsVisible="{Binding !Clip.IsLossless}"
Text="!"
ToolTip.Tip="This clip's XML did not round-trip cleanly through the domain model. See the status bar for details when selected." />
</StackPanel>
</StackPanel>
</TreeDataTemplate>
</TreeView.DataTemplates>
</TreeView>
</DockPanel>
</Border>

<!-- Splitter space -->
Expand Down
Loading
Loading