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
8 changes: 8 additions & 0 deletions docs/advanced-filemaker-scripting-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ Appended to a quoted or `Table::Field` name:
Omitted when the id is zero or unknown — `(#0)` would be visual noise
for unresolved references.

A literal `"` inside the quoted name is doubled (`"O""Brien"`),
FileMaker's own calculation-string escape convention. This keeps a
hand-edited display line unambiguous about where the name ends, even
when the name itself contains a quote or looks like an `(#id)` suffix.
`DisplayQuoting` (`SharpFM.Model.Scripting.Serialization`) is the shared
helper for emitting and parsing this form — steps with a quoted
`NamedRef` should call it rather than hand-rolling quote handling.

### Form 2 — inline word tokens

Parsed at a specific named prefix, matching FM Pro's own rendering of
Expand Down
84 changes: 84 additions & 0 deletions src/SharpFM.Model/Scripting/Serialization/DisplayQuoting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.Text.RegularExpressions;
using SharpFM.Model.Scripting.Values;

namespace SharpFM.Model.Scripting.Serialization;

/// <summary>
/// Quote/unquote convention for names embedded in a step's human-readable
/// display line — e.g. the script name in <c>Perform Script [ "Sync" (#4) ]</c>.
/// Follows FileMaker's own calculation-string escape: a literal quote inside
/// the name is doubled (<c>"O""Brien"</c>) rather than backslash-escaped, so
/// a hand-edited display line stays unambiguous about where the name ends.
/// </summary>
public static class DisplayQuoting
{
private static readonly Regex QuotedWithId = new(
"^\"(?<name>.*)\"\\s*\\(#(?<id>\\d+)\\)$",
RegexOptions.Compiled);

/// <summary>Wraps <paramref name="name"/> in quotes, doubling any embedded quote.</summary>
public static string Quote(string name) => $"\"{name.Replace("\"", "\"\"")}\"";

/// <summary>
/// Formats the lossless <c>"name" (#id)</c> form used for static
/// references, or just <c>"name"</c> when <paramref name="id"/> is the
/// unknown sentinel (0).
/// </summary>
public static string QuoteWithId(string name, int id) =>
id == 0 ? Quote(name) : $"{Quote(name)} (#{id})";

/// <summary>Parses a <c>"name" (#id)</c> token, undoubling escaped quotes.</summary>
public static bool TryParseQuotedWithId(string token, out string name, out int id)
{
var match = QuotedWithId.Match(token);
if (match.Success)
{
name = Unescape(match.Groups["name"].Value);
id = int.Parse(match.Groups["id"].Value);
return true;
}

name = "";
id = 0;
return false;
}

/// <summary>Parses a bare <c>"name"</c> token, undoubling escaped quotes.</summary>
public static bool TryParseQuoted(string token, out string name)
{
if (token.Length >= 2 && token[0] == '"' && token[^1] == '"')
{
name = Unescape(token[1..^1]);
return true;
}

name = "";
return false;
}

/// <summary>
/// Parses a <c>"name" (#id)</c> token, falling back to a bare
/// <c>"name"</c> token with the unknown-id sentinel (0) when no id
/// suffix is present — the shared degrade-on-hand-edit behavior used by
/// every step that quotes a <see cref="NamedRef"/> in its display line.
/// </summary>
public static bool TryParseNamedRef(string token, out NamedRef namedRef)
{
if (TryParseQuotedWithId(token, out var name, out var id))
{
namedRef = new NamedRef(id, name);
return true;
}

if (TryParseQuoted(token, out var bareName))
{
namedRef = new NamedRef(0, bareName);
return true;
}

namedRef = new NamedRef(0, "");
return false;
}

private static string Unescape(string s) => s.Replace("\"\"", "\"");
}
33 changes: 8 additions & 25 deletions src/SharpFM.Model/Scripting/Steps/GoToLayoutStep.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using SharpFM.Model.Scripting.Registry;
using SharpFM.Model.Scripting.Serialization;
using SharpFM.Model.Scripting.Shapes;
using SharpFM.Model.Scripting.Values;

Expand Down Expand Up @@ -105,9 +105,7 @@ public override string ToDisplayLine()
// and dropped the suffix, or caller constructed without an
// id). Suppressing (#0) keeps the display clean when we
// don't actually have an id to preserve.
parts.Add(named.Layout.Id == 0
? $"\"{named.Layout.Name}\""
: $"\"{named.Layout.Name}\" (#{named.Layout.Id})");
parts.Add(DisplayQuoting.QuoteWithId(named.Layout.Name, named.Layout.Id));
break;

case LayoutTarget.ByNameCalc byName:
Expand All @@ -127,10 +125,6 @@ public override string ToDisplayLine()

// --- Display text parse ---

private static readonly Regex NamedLayoutToken = new(
"^\"(?<name>.*)\"\\s*\\(#(?<id>\\d+)\\)$",
RegexOptions.Compiled);

protected internal override void PopulateFromDisplay(string[] hrParams)
{
LayoutTarget target = new LayoutTarget.Original();
Expand Down Expand Up @@ -160,24 +154,13 @@ protected internal override void PopulateFromDisplay(string[] hrParams)
var expr = token.Substring("Layout Number:".Length).Trim();
target = new LayoutTarget.ByNumberCalc(new Calculation(expr));
}
else
// Named layout with (#id) suffix is the lossless form. Bare
// quoted names without an id degrade to a NamedRef with id 0 —
// the user edited the display text and dropped the id, there's
// nothing better we can do.
else if (DisplayQuoting.TryParseNamedRef(token, out var namedRef))
{
// Named layout with (#id) suffix is the lossless form.
// Bare quoted names without an id degrade to a NamedRef
// with id 0 — the user edited the display text and
// dropped the id, there's nothing better we can do.
var match = NamedLayoutToken.Match(token);
if (match.Success)
{
var name = match.Groups["name"].Value;
var id = int.Parse(match.Groups["id"].Value);
target = new LayoutTarget.Named(new NamedRef(id, name));
}
else if (token.StartsWith("\"") && token.EndsWith("\"") && token.Length >= 2)
{
var name = token.Substring(1, token.Length - 2);
target = new LayoutTarget.Named(new NamedRef(0, name));
}
target = new LayoutTarget.Named(namedRef);
}
}

Expand Down
21 changes: 11 additions & 10 deletions src/SharpFM.Model/Scripting/Steps/GoToRelatedRecordStep.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using SharpFM.Model.Scripting.Registry;
using SharpFM.Model.Scripting.Serialization;
using SharpFM.Model.Scripting.Shapes;
using SharpFM.Model.Scripting.Values;

Expand Down Expand Up @@ -64,9 +65,9 @@ public GoToRelatedRecordStep(
public override string ToDisplayLine()
{
var parts = new System.Collections.Generic.List<string>();
parts.Add($"From table: \"{Table.Name}\"");
parts.Add($"From table: {DisplayQuoting.Quote(Table.Name)}");
if (Layout is not null && (Layout.Id != 0 || !string.IsNullOrEmpty(Layout.Name)))
parts.Add($"Using layout: \"{Layout.Name}\"");
parts.Add($"Using layout: {DisplayQuoting.Quote(Layout.Name)}");
if (ShowOnlyRelated) parts.Add("Show only related records");
if (MatchAllRecords) parts.Add("Match found set");
if (ShowInNewWindow) parts.Add("New window");
Expand All @@ -84,9 +85,15 @@ protected internal override void PopulateFromDisplay(string[] hrParams)
{
var t = tok.Trim();
if (t.StartsWith("From table:", StringComparison.OrdinalIgnoreCase))
table = new NamedRef(0, Unquote(t.Substring(11).Trim()));
{
var name = t.Substring(11).Trim();
table = new NamedRef(0, DisplayQuoting.TryParseQuoted(name, out var parsed) ? parsed : name);
}
else if (t.StartsWith("Using layout:", StringComparison.OrdinalIgnoreCase))
layout = new NamedRef(0, Unquote(t.Substring(13).Trim()));
{
var name = t.Substring(13).Trim();
layout = new NamedRef(0, DisplayQuoting.TryParseQuoted(name, out var parsed) ? parsed : name);
}
else if (t.Equals("Show only related records", StringComparison.OrdinalIgnoreCase))
showOnly = true;
else if (t.Equals("Match found set", StringComparison.OrdinalIgnoreCase))
Expand All @@ -104,12 +111,6 @@ protected internal override void PopulateFromDisplay(string[] hrParams)
Layout = layout;
}

private static string Unquote(string s)
{
if (s.StartsWith("\"") && s.EndsWith("\"") && s.Length >= 2) return s.Substring(1, s.Length - 2);
return s;
}

public static StepMetadata Metadata { get; } = new()
{
Name = XmlName,
Expand Down
7 changes: 3 additions & 4 deletions src/SharpFM.Model/Scripting/Steps/InstallMenuSetStep.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using SharpFM.Model.Scripting.Registry;
using SharpFM.Model.Scripting.Serialization;
using SharpFM.Model.Scripting.Shapes;
using SharpFM.Model.Scripting.Values;

Expand All @@ -24,7 +25,7 @@ public InstallMenuSetStep(NamedRef? menuSet = null, bool useAsFileDefault = fals

// Hand-written: quoted menu-set name token the shape renderer cannot produce.
public override string ToDisplayLine() =>
$"Install Menu Set [ \"{MenuSet.Name}\" ; Use as file default: {(UseAsFileDefault ? "On" : "Off")} ]";
$"Install Menu Set [ {DisplayQuoting.Quote(MenuSet.Name)} ; Use as file default: {(UseAsFileDefault ? "On" : "Off")} ]";

protected internal override void PopulateFromDisplay(string[] hrParams)
{
Expand All @@ -40,9 +41,7 @@ protected internal override void PopulateFromDisplay(string[] hrParams)
}
else if (!menuSeen && !string.IsNullOrWhiteSpace(t))
{
var name = t;
if (name.StartsWith("\"") && name.EndsWith("\"") && name.Length >= 2)
name = name.Substring(1, name.Length - 2);
var name = DisplayQuoting.TryParseQuoted(t, out var parsed) ? parsed : t;
// The built-in menu set has the fixed id 1; custom menu sets
// carry file-specific ids the canonical form wildcards.
menu = new NamedRef(name == "[Standard FileMaker Menus]" ? 1 : 0, name);
Expand Down
7 changes: 3 additions & 4 deletions src/SharpFM.Model/Scripting/Steps/InstallOnTimerScriptStep.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using SharpFM.Model.Scripting.Registry;
using SharpFM.Model.Scripting.Serialization;
using SharpFM.Model.Scripting.Shapes;
using SharpFM.Model.Scripting.Values;

Expand Down Expand Up @@ -31,7 +32,7 @@ public InstallOnTimerScriptStep(NamedRef? script = null, Calculation? interval =
// the shape renderer cannot produce.
public override string ToDisplayLine()
{
var script = Script is null ? "<no script>" : $"\"{Script.Name}\"";
var script = Script is null ? "<no script>" : DisplayQuoting.Quote(Script.Name);
return Interval is null
? $"Install OnTimer Script [ {script} ]"
: $"Install OnTimer Script [ {script} ; Interval: {Interval.Text} ]";
Expand All @@ -51,9 +52,7 @@ protected internal override void PopulateFromDisplay(string[] hrParams)
}
else if (!scriptSeen && !string.IsNullOrWhiteSpace(t) && t != "<no script>")
{
var name = t;
if (name.StartsWith("\"") && name.EndsWith("\"") && name.Length >= 2)
name = name.Substring(1, name.Length - 2);
var name = DisplayQuoting.TryParseQuoted(t, out var parsed) ? parsed : t;
script = new NamedRef(0, name);
scriptSeen = true;
}
Expand Down
15 changes: 4 additions & 11 deletions src/SharpFM.Model/Scripting/Steps/PerformScriptOnServerStep.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Xml.Linq;
using SharpFM.Model.Scripting.Registry;
using SharpFM.Model.Scripting.Serialization;
using SharpFM.Model.Scripting.Shapes;
using SharpFM.Model.Scripting.Values;

Expand Down Expand Up @@ -56,9 +57,7 @@ public override string ToDisplayLine()
switch (Target)
{
case PerformScriptTarget.ByReference byRef:
parts.Add(byRef.Script.Id == 0
? $"\"{byRef.Script.Name}\""
: $"\"{byRef.Script.Name}\" (#{byRef.Script.Id})");
parts.Add(DisplayQuoting.QuoteWithId(byRef.Script.Name, byRef.Script.Id));
break;
case PerformScriptTarget.ByCalculation byCalc:
parts.Add($"By name: {byCalc.NameCalc.Text}");
Expand Down Expand Up @@ -103,14 +102,8 @@ protected internal override void PopulateFromDisplay(string[] hrParams)
parameter = new Calculation(t.Substring(10).Trim());
else if (t.StartsWith("By name:", System.StringComparison.OrdinalIgnoreCase))
target = new PerformScriptTarget.ByCalculation(new Calculation(t.Substring(8).Trim()));
else if (t.StartsWith("\"") && t.Contains("(#"))
{
var idMatch = System.Text.RegularExpressions.Regex.Match(t, @"^""(?<name>.*)""\s*\(#(?<id>\d+)\)$");
if (idMatch.Success)
target = new PerformScriptTarget.ByReference(new NamedRef(int.Parse(idMatch.Groups["id"].Value), idMatch.Groups["name"].Value));
}
else if (t.StartsWith("\"") && t.EndsWith("\"") && t.Length >= 2)
target = new PerformScriptTarget.ByReference(new NamedRef(0, t.Substring(1, t.Length - 2)));
else if (DisplayQuoting.TryParseNamedRef(t, out var namedRef))
target = new PerformScriptTarget.ByReference(namedRef);
}
WaitForCompletion = wait;
Target = target;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Xml.Linq;
using SharpFM.Model.Scripting.Registry;
using SharpFM.Model.Scripting.Serialization;
using SharpFM.Model.Scripting.Shapes;
using SharpFM.Model.Scripting.Values;

Expand Down Expand Up @@ -64,14 +65,14 @@ public override string ToDisplayLine()
switch (Target)
{
case PerformScriptTarget.ByReference byRef:
parts.Add(byRef.Script.Id == 0 ? $"\"{byRef.Script.Name}\"" : $"\"{byRef.Script.Name}\" (#{byRef.Script.Id})");
parts.Add(DisplayQuoting.QuoteWithId(byRef.Script.Name, byRef.Script.Id));
break;
case PerformScriptTarget.ByCalculation byCalc:
parts.Add($"By name: {byCalc.NameCalc.Text}");
break;
}
if (Parameter is not null) parts.Add($"Parameter: {Parameter.Text}");
if (CallbackScript is not null) parts.Add($"Callback: \"{CallbackScript.Name}\"");
if (CallbackScript is not null) parts.Add($"Callback: {DisplayQuoting.Quote(CallbackScript.Name)}");
return $"Perform Script on Server with Callback [ {string.Join(" ; ", parts)} ]";
}

Expand Down
25 changes: 4 additions & 21 deletions src/SharpFM.Model/Scripting/Steps/PerformScriptStep.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using SharpFM.Model.Scripting.Registry;
using SharpFM.Model.Scripting.Serialization;
using SharpFM.Model.Scripting.Shapes;
using SharpFM.Model.Scripting.Values;

Expand Down Expand Up @@ -84,9 +84,7 @@ public override string ToDisplayLine()
case PerformScriptTarget.ByReference byRef:
// Suppress (#0) when we don't actually have a script id to
// preserve — same convention as GoToLayoutStep.
parts.Add(byRef.Script.Id == 0
? $"\"{byRef.Script.Name}\""
: $"\"{byRef.Script.Name}\" (#{byRef.Script.Id})");
parts.Add(DisplayQuoting.QuoteWithId(byRef.Script.Name, byRef.Script.Id));
break;

case PerformScriptTarget.ByCalculation byCalc:
Expand All @@ -100,10 +98,6 @@ public override string ToDisplayLine()
return $"Perform Script [ {string.Join(" ; ", parts)} ]";
}

private static readonly Regex NamedScriptToken = new(
"^\"(?<name>.*)\"\\s*\\(#(?<id>\\d+)\\)$",
RegexOptions.Compiled);

protected internal override void PopulateFromDisplay(string[] hrParams)
{
PerformScriptTarget target = new PerformScriptTarget.ByReference(new NamedRef(0, ""));
Expand All @@ -124,20 +118,9 @@ protected internal override void PopulateFromDisplay(string[] hrParams)
var expr = token.Substring("By name:".Length).Trim();
target = new PerformScriptTarget.ByCalculation(new Calculation(expr));
}
else
else if (DisplayQuoting.TryParseNamedRef(token, out var namedRef))
{
var match = NamedScriptToken.Match(token);
if (match.Success)
{
var name = match.Groups["name"].Value;
var id = int.Parse(match.Groups["id"].Value);
target = new PerformScriptTarget.ByReference(new NamedRef(id, name));
}
else if (token.StartsWith("\"") && token.EndsWith("\"") && token.Length >= 2)
{
var name = token.Substring(1, token.Length - 2);
target = new PerformScriptTarget.ByReference(new NamedRef(0, name));
}
target = new PerformScriptTarget.ByReference(namedRef);
}
}

Expand Down
Loading
Loading