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
171 changes: 171 additions & 0 deletions src/SharpFM.Model/Clip.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using SharpFM.Model.ClipTypes;
using SharpFM.Model.Parsing;
using SharpFM.Model.Scripting;

namespace SharpFM.Model;

/// <summary>
/// Immutable aggregate for a FileMaker clip: name, wire-format identifier,
/// canonical XML body, and the parsed domain model + fidelity report. Every
/// path that ingests a clip (paste from FileMaker, file load, plugin push,
/// new-clip seed) ends up here, so the parse report is always available to
/// any consumer regardless of how the clip arrived.
/// </summary>
/// <remarks>
/// View-models hold a <c>Clip</c> reference and re-publish change
/// notifications when the reference is replaced — INPC stays out of the
/// domain layer.
/// </remarks>
public sealed class Clip
{
public string Name { get; }
public string FormatId { get; }
public string Xml { get; }

private readonly Func<ClipParseResult> _parseFactory;
private ClipParseResult? _parsed;

/// <summary>
/// Outcome of parsing <see cref="Xml"/> against the registered strategy.
/// Computed on first access — XML→domain parsing is the slow part of
/// constructing a clip, and most callers along the editor's hot path
/// only read <see cref="Xml"/>.
/// </summary>
public ClipParseResult Parsed
{
get
{
// PublicationOnly semantics: multiple racing threads may compute
// the parse, only one result wins. Cheaper than full
// double-checked locking and the strategies are pure functions.
if (_parsed is null)
{
Interlocked.CompareExchange(ref _parsed, _parseFactory(), null);
}
return _parsed;
}
}

private byte[]? _cachedWireBytes;

/// <summary>
/// FileMaker clipboard wire format: 4-byte little-endian length prefix
/// followed by UTF-8 XML. Lazily derived from <see cref="Xml"/>.
/// </summary>
public byte[] WireBytes
{
get
{
if (_cachedWireBytes is null)
{
var payload = Encoding.UTF8.GetBytes(Xml);
var prefix = BitConverter.GetBytes(payload.Length);
_cachedWireBytes = prefix.Concat(payload).ToArray();
}
return _cachedWireBytes;
}
}

private Clip(string name, string formatId, string xml, Func<ClipParseResult> parseFactory)
{
Name = name;
FormatId = formatId;
Xml = xml;
_parseFactory = parseFactory;
}

private Clip(string name, string formatId, string xml, ClipParseResult parsed)
: this(name, formatId, xml, () => parsed)
{
_parsed = parsed;
}

/// <summary>
/// Construct a clip from raw XML. The XML is canonicalised via
/// <see cref="XmlHelpers.PrettyPrint"/>; the strategy parse runs lazily
/// when <see cref="Parsed"/> is first read. This method itself does not
/// throw — well-formedness errors surface as a <see cref="ParseFailure"/>
/// from the strategy on demand.
/// </summary>
public static Clip FromXml(string name, string formatId, string xml)
{
var canonical = XmlHelpers.PrettyPrint(xml ?? string.Empty);
return new Clip(
name,
formatId,
canonical,
() => ClipTypeRegistry.For(formatId).Parse(canonical));
}

/// <summary>
/// Construct a clip from FileMaker clipboard wire bytes (4-byte length
/// prefix + UTF-8 XML). Inputs shorter than 4 bytes are treated as empty.
/// </summary>
public static Clip FromWireBytes(string name, string formatId, byte[] bytes)
{
var xml = bytes.Length < 4
? string.Empty
: Encoding.UTF8.GetString(bytes, 4, bytes.Length - 4);
return FromXml(name, formatId, xml);
}

/// <summary>
/// Construct a clip from a model the editor already holds. Skips the
/// strategy parse + round-trip diff entirely — the editor's domain model
/// is the source of truth, the XML it emits is lossless to that model
/// by definition. Only kind-specific diagnostics that aren't structural
/// (e.g. <see cref="ParseDiagnosticKind.UnknownStep"/> for <c>RawStep</c>)
/// are derived from the model directly.
/// </summary>
/// <remarks>
/// This is the typing hot path for large scripts. Going through
/// <see cref="FromXml"/> on every debounced edit re-parses the XML
/// (~ N steps) plus serialises and structurally diffs (~ N more) on
/// the UI thread. <c>FromEditor</c> drops all of that.
/// </remarks>
public static Clip FromEditor(string name, string formatId, string xml, ClipModel model)
{
var report = ReportForEditorModel(model);
return new Clip(name, formatId, xml, new ParseSuccess(model, report));
}

private static ClipParseReport ReportForEditorModel(ClipModel model)
{
if (model is ScriptClipModel script)
{
var diagnostics = ClipStrategyHelpers.RawStepDiagnostics(script.Script).ToList();
return diagnostics.Count == 0
? ClipParseReport.Empty
: new ClipParseReport(diagnostics);
}
return ClipParseReport.Empty;
}

/// <summary>
/// Return a fresh clip with replacement XML. The parse runs lazily on
/// the new instance. Returns <c>this</c> when <paramref name="newXml"/>
/// is byte-identical to the current canonical XML, which short-circuits
/// the change cascade for keystroke-driven re-syncs that don't actually
/// change anything.
/// </summary>
public Clip WithXml(string newXml)
{
if (string.Equals(newXml, Xml, StringComparison.Ordinal))
{
return this;
}
return FromXml(Name, FormatId, newXml);
}

/// <summary>Return a fresh clip under a new name; the existing parse is reused.</summary>
public Clip Rename(string newName)
{
var parsed = Parsed;
return new Clip(newName, FormatId, Xml, parsed);
}
}
30 changes: 15 additions & 15 deletions src/SharpFM.Model/ClipDataExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
using System.Collections.Generic;
using SharpFM.Model.ClipTypes;
using SharpFM.Model.Parsing;
using SharpFM.Model.Schema;
using SharpFM.Model.Scripting;

namespace SharpFM.Model;

/// <summary>
/// Convenience extensions on <see cref="ClipData"/> for parsing into the
/// appropriate domain model based on the clip's format.
/// appropriate domain model based on the clip's format. Typed accessors
/// dispatch through <see cref="ClipTypeRegistry"/> so adding a new format
/// with a registered strategy automatically lights up the helpers.
/// </summary>
public static class ClipDataExtensions
{
Expand All @@ -16,27 +20,23 @@ public static bool IsScript(this ClipData clip) =>
public static bool IsTable(this ClipData clip) =>
clip.ClipType is "Mac-XMTB" or "Mac-XMFD";

/// <summary>
/// Parse this clip as a script. Returns null if the clip is not a script type.
/// </summary>
/// <summary>Parse this clip as a script; null if the clip is not a script type.</summary>
public static FmScript? AsScript(this ClipData clip) =>
clip.IsScript() ? FmScript.FromXml(clip.Xml) : null;
ClipTypeRegistry.For(clip.ClipType).Parse(clip.Xml) is ParseSuccess { Model: ScriptClipModel s }
? s.Script
: null;

/// <summary>
/// Parse this clip as a table. Returns null if the clip is not a table type.
/// </summary>
/// <summary>Parse this clip as a table; null if the clip is not a table type.</summary>
public static FmTable? AsTable(this ClipData clip) =>
clip.IsTable() ? FmTable.FromXml(clip.Xml) : null;
ClipTypeRegistry.For(clip.ClipType).Parse(clip.Xml) is ParseSuccess { Model: TableClipModel t }
? t.Table
: null;

/// <summary>
/// Get the script's steps as a snapshot list. Returns null if the clip is not a script type.
/// </summary>
/// <summary>Get the script's steps as a snapshot list; null if the clip is not a script type.</summary>
public static IReadOnlyList<ScriptStep>? GetScriptSteps(this ClipData clip) =>
clip.AsScript()?.Steps;

/// <summary>
/// Get the table's fields as a snapshot list. Returns null if the clip is not a table type.
/// </summary>
/// <summary>Get the table's fields as a snapshot list; null if the clip is not a table type.</summary>
public static IReadOnlyList<FmField>? GetTableFields(this ClipData clip) =>
clip.AsTable()?.Fields;
}
88 changes: 88 additions & 0 deletions src/SharpFM.Model/ClipTypes/ClipStrategyHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.Collections.Generic;
using System.Xml;
using System.Xml.Linq;
using SharpFM.Model.Parsing;
using SharpFM.Model.Scripting;
using SharpFM.Model.Scripting.Steps;

namespace SharpFM.Model.ClipTypes;

/// <summary>
/// Shared parsing primitives used by every <see cref="IClipTypeStrategy"/>
/// implementation: a one-line failure builder and the standard
/// <c>&lt;fmxmlsnippet&gt;</c> well-formedness gate.
/// </summary>
internal static class ClipStrategyHelpers
{
/// <summary>Build a <see cref="ParseFailure"/> wrapping a single error-severity diagnostic.</summary>
public static ParseFailure Failure(
ParseDiagnosticKind kind, string location, string message, string reason) =>
new(reason, new ClipParseReport(
[
new ClipParseDiagnostic(kind, ParseDiagnosticSeverity.Error, location, message),
]));

/// <summary>
/// Validate that <paramref name="xml"/> is non-empty, well-formed, and
/// rooted at <c>&lt;fmxmlsnippet&gt;</c>. Returns true with the parsed
/// <see cref="XElement"/> on success; false with a populated
/// <paramref name="failure"/> on any of the three failure modes the
/// strategies share.
/// </summary>
public static bool TryParseFmxmlsnippet(string xml, out XElement root, out ParseFailure failure)
{
root = null!;
failure = null!;

if (string.IsNullOrWhiteSpace(xml))
{
failure = Failure(ParseDiagnosticKind.XmlMalformed, "/", "input was empty", "empty xml");
return false;
}

try
{
root = XElement.Parse(xml);
}
catch (XmlException ex)
{
failure = Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "malformed xml");
return false;
}

if (root.Name.LocalName != "fmxmlsnippet")
{
failure = Failure(
ParseDiagnosticKind.UnsupportedClipType,
"/" + root.Name.LocalName,
$"expected <fmxmlsnippet>, found <{root.Name.LocalName}>",
"unsupported root element");
return false;
}

return true;
}

/// <summary>
/// Walk a script's steps and emit one Info-severity <see cref="ParseDiagnosticKind.UnknownStep"/>
/// diagnostic per <see cref="RawStep"/>. Used by both the script strategy
/// (after a fresh parse) and the trusted-edit path (after a model-only
/// reuse) so the same UI signal surfaces regardless of source.
/// </summary>
public static IEnumerable<ClipParseDiagnostic> RawStepDiagnostics(FmScript script)
{
var index = 0;
foreach (var step in script.Steps)
{
index++;
if (step is RawStep raw)
{
yield return new ClipParseDiagnostic(
ParseDiagnosticKind.UnknownStep,
ParseDiagnosticSeverity.Info,
$"/fmxmlsnippet/Step[{index}]",
$"step '{raw.Name}' is not modeled by the host; preserved verbatim as RawStep");
}
}
}
}
44 changes: 44 additions & 0 deletions src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;

namespace SharpFM.Model.ClipTypes;

/// <summary>
/// Compile-time registry of <see cref="IClipTypeStrategy"/> implementations
/// keyed by <c>Mac-XM*</c> format id. Strategies are fully owned by SharpFM
/// (no plugin extension point), so the table is built once from a static
/// list and is read-only thereafter — no locks, no bootstrap, no reset.
/// Adding a new clip type means writing the strategy and adding it to
/// <see cref="BuiltIns"/>.
/// </summary>
public static class ClipTypeRegistry
{
public static IReadOnlyList<IClipTypeStrategy> BuiltIns { get; } =
[
ScriptClipStrategy.Steps,
ScriptClipStrategy.Script,
TableClipStrategy.Table,
TableClipStrategy.Field,
LayoutClipStrategy.Instance,
];

private static readonly Dictionary<string, IClipTypeStrategy> _byFormatId =
BuiltIns.ToDictionary(s => s.FormatId);

/// <summary>All built-in strategies (excludes the opaque fallback).</summary>
public static IReadOnlyList<IClipTypeStrategy> All => BuiltIns;

/// <summary>
/// Resolve a strategy for the given format id. Unknown ids fall back to
/// <see cref="OpaqueClipStrategy.Instance"/> so callers always receive a
/// usable strategy.
/// </summary>
public static IClipTypeStrategy For(string formatId) =>
_byFormatId.TryGetValue(formatId, out var strategy)
? strategy
: OpaqueClipStrategy.Instance;

/// <summary>True if the given format id has a dedicated built-in strategy.</summary>
public static bool IsRegistered(string formatId) =>
_byFormatId.ContainsKey(formatId);
}
32 changes: 32 additions & 0 deletions src/SharpFM.Model/ClipTypes/IClipTypeStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using SharpFM.Model.Parsing;

namespace SharpFM.Model.ClipTypes;

/// <summary>
/// Per-clip-type extension point. Each <c>Mac-XM*</c> format gets one
/// implementation registered with <see cref="ClipTypeRegistry"/>; everything
/// the host needs to know about that format (display name, parse, default
/// XML for a fresh clip) hangs off this interface.
/// </summary>
public interface IClipTypeStrategy
{
/// <summary>The wire-format identifier this strategy handles, e.g. <c>"Mac-XMSS"</c>.</summary>
string FormatId { get; }

/// <summary>Human-readable label shown in the UI, e.g. <c>"Script Steps"</c>.</summary>
string DisplayName { get; }

/// <summary>
/// Parse <paramref name="xml"/> into a <see cref="ClipModel"/> and emit a
/// <see cref="ClipParseReport"/> describing any data the parse couldn't
/// represent in the domain model. Implementations must not throw — failures
/// are returned as <see cref="ParseFailure"/>.
/// </summary>
ClipParseResult Parse(string xml);

/// <summary>
/// Produce a starter XML body for a fresh clip with the given name. Used by
/// "new clip" flows in the host and by plugins.
/// </summary>
string DefaultXml(string clipName);
}
Loading
Loading