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
88 changes: 28 additions & 60 deletions src/SharpFM.Model/Scripting/FmScript.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,15 +192,15 @@ private List<string> ApplyAdd(ScriptStepOperation op)
if (!Registry.StepRegistry.ByName.TryGetValue(op.StepName, out var metadata))
return [$"Unknown step name '{op.StepName}'."];

// Route through each POCO's FromDisplay factory with the caller's
// param map synthesized into HR tokens. The synthesizer consults the
// step's metadata so positional params (no HrLabel) pass as raw
// values and labeled params get the canonical "Label: value" form.
var hrParams = SynthesizeHrParams(op.Params, metadata);
var step = StepDisplayFactory.TryCreate(op.StepName, op.Enabled ?? true, hrParams);
// Construct a blank instance through the registry factory, then set
// each caller param directly onto the POCO's shape-bound properties.
var step = StepDisplayFactory.TryCreate(metadata.Name, op.Enabled ?? true, []);
if (step is null)
return [$"No typed POCO factory registered for '{op.StepName}'."];

var errors = ApplyParams(step, op.Params);
if (errors.Count > 0) return errors;

var index = op.Index < 0 || op.Index >= Steps.Count ? Steps.Count : op.Index;
Steps.Insert(index, step);
return [];
Expand All @@ -211,74 +211,42 @@ private List<string> ApplyUpdate(ScriptStepOperation op)
if (ValidateStepIndex(op.Index) is { } err) return [err];

var step = Steps[op.Index];
if (op.Enabled is not null) step.Enabled = op.Enabled.Value;

if (op.Params is null) return [];

// Param updates are rebuilt by re-parsing the display form with the
// new param map overlaid onto the old. This uses the POCO's own
// FromDisplay factory — the same path ApplyAdd takes — so the
// update never leaves the typed-POCO world.
var metadata = Registry.StepRegistry.MetadataFor(step);
if (metadata is null)
if (op.Params is not null && op.Params.Count > 0
&& Registry.StepRegistry.MetadataFor(step) is null)
{
return [$"Apply/update is not supported for step kind '{step.GetType().Name}'."];
}

var hrParams = SynthesizeHrParams(op.Params, metadata);
var updated = StepDisplayFactory.TryCreate(metadata.Name, step.Enabled, hrParams);
if (updated is null)
return [$"No typed POCO factory registered for '{metadata.Name}'."];
if (op.Enabled is not null) step.Enabled = op.Enabled.Value;

Steps[op.Index] = updated;
return [];
return ApplyParams(step, op.Params);
}

/// <summary>
/// Convert a caller's param map into the ordered HR-token form each step's
/// FromDisplay factory expects. Iterates the shape's display slots in
/// display order, formatting each match as <c>"HrLabel: value"</c> when the
/// slot has a label and as a raw positional value when it doesn't (e.g.
/// <c>SetVariableStep.Name</c>, <c>IfStep.Condition</c> — these go straight
/// into <c>&lt;Name&gt;</c> / <c>&lt;Calculation&gt;</c> without prefix).
/// Keys may address a slot by its bound property, its XML element, or its
/// display label, so pre-cutover param names keep working.
/// Sets each param onto the step's shape-bound public properties in
/// place, through <see cref="ScriptStep.ApplyParam"/>'s virtual dispatch.
/// Properties the map does not name keep their current values. Params
/// apply in map order and application is not atomic: on error, params
/// earlier in the map have already been set.
/// </summary>
/// <remarks>
/// An earlier implementation labeled every non-empty key, which caused
/// positional params to receive a <c>"Name: $foo"</c> string verbatim into
/// XML — structurally valid but semantically broken under FileMaker.
/// </remarks>
private static string[] SynthesizeHrParams(
IReadOnlyDictionary<string, string>? map,
Registry.StepMetadata metadata)
private static List<string> ApplyParams(ScriptStep step, IReadOnlyDictionary<string, string?>? map)
{
if (map is null || map.Count == 0) return [];

var consumedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<string>(map.Count);

foreach (var node in Shapes.ShapeHrView.HrNodes(metadata.Shape))
var errors = new List<string>();
foreach (var (name, value) in map)
{
var matchedKey = map.Keys.FirstOrDefault(k =>
!consumedKeys.Contains(k) && Shapes.ShapeHrView.MatchesName(node, k));
if (matchedKey is null) continue;

consumedKeys.Add(matchedKey);
var value = map[matchedKey];
result.Add(node.HrLabel is not null
? $"{node.HrLabel}: {value}"
: value);
}
if (value is null)
{
errors.Add($"Param '{name}' has no value.");
continue;
}

// Forward-compat: any keys we don't recognise pass through with the
// old formatting so newly-introduced params keep working until their
// metadata catches up.
foreach (var (k, v) in map)
{
if (consumedKeys.Contains(k)) continue;
result.Add(string.IsNullOrEmpty(k) ? v : $"{k}: {v}");
if (step.ApplyParam(name, value) is { } error)
errors.Add(error);
}

return result.ToArray();
return errors;
}

private List<string> ApplyRemove(ScriptStepOperation op)
Expand Down
11 changes: 11 additions & 0 deletions src/SharpFM.Model/Scripting/ScriptStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ protected ScriptStep(bool enabled)
/// </summary>
protected internal abstract void PopulateFromDisplay(string[] hrParams);

/// <summary>
/// Sets one shape-bound public property from an HR-friendly text value,
/// mutating this instance in place; properties not named are untouched.
/// Returns null on success, or a human-readable error message. Steps
/// whose slots need hand grammar (button blocks, variant targets)
/// override this and fall back to base for everything else. The base
/// rejects everything — <c>RawStep</c> has no param surface.
/// </summary>
protected internal virtual string? ApplyParam(string name, string value) =>
$"Step '{GetType().Name}' does not support param updates.";

public virtual List<ScriptDiagnostic> Validate(int lineIndex) => new();

/// <summary>
Expand Down
3 changes: 3 additions & 0 deletions src/SharpFM.Model/Scripting/ScriptStepOfT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ protected internal override void PopulateFromXml(XElement step) =>
protected internal override void PopulateFromDisplay(string[] hrParams) =>
StepDisplayParser.Populate(this, hrParams, TSelf.Metadata);

protected internal override string? ApplyParam(string name, string value) =>
StepParamApplier.Apply(this, TSelf.Metadata, name, value);

/// <summary>Typed counterpart of the <see cref="ScriptStep.FromXml"/> dispatch
/// for callers that already know the step kind.</summary>
public static TSelf Parse(XElement step)
Expand Down
8 changes: 8 additions & 0 deletions src/SharpFM.Model/Scripting/Serialization/ShapeReflection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,12 @@ public static void Set(object target, string name, object? value)
/// <summary>Declared type of a shape-bound property (for typed list parsing).</summary>
public static Type PropertyType(object source, string name) =>
Prop(source.GetType(), name).PropertyType;

/// <summary>
/// True when the shape-bound property has a setter. False marks an
/// emit-only projection (e.g. a wire-order alias of another property),
/// which callers that need the write to actually land must route around.
/// </summary>
public static bool CanWrite(object target, string name) =>
Prop(target.GetType(), name).CanWrite;
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ private static void Assign(object target, ShapeNode node, string value)
}

/// <summary>Inverse of the renderer's wire→display translation.</summary>
private static string ToWireValue(ShapeNode node, string display)
internal static string ToWireValue(ShapeNode node, string display)
{
if (node.DisplayValues is null || node.ValidValues is null) return display;
var i = node.DisplayValues.ToList().FindIndex(v => v.Equals(display, StringComparison.OrdinalIgnoreCase));
Expand Down
Loading
Loading