filemaker-patchwork 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- filemaker_patchwork-0.1.0.dist-info/METADATA +245 -0
- filemaker_patchwork-0.1.0.dist-info/RECORD +28 -0
- filemaker_patchwork-0.1.0.dist-info/WHEEL +5 -0
- filemaker_patchwork-0.1.0.dist-info/entry_points.txt +3 -0
- filemaker_patchwork-0.1.0.dist-info/licenses/LICENSE +21 -0
- filemaker_patchwork-0.1.0.dist-info/top_level.txt +1 -0
- patchwork/__init__.py +7 -0
- patchwork/__main__.py +5 -0
- patchwork/build.py +249 -0
- patchwork/cli.py +352 -0
- patchwork/export.py +162 -0
- patchwork/mcp.py +172 -0
- patchwork/objects.py +352 -0
- patchwork/ops.py +249 -0
- patchwork/place.py +201 -0
- patchwork/project.py +96 -0
- patchwork/rebrand.py +153 -0
- patchwork/script.py +475 -0
- patchwork/skills/fm-data/SKILL.md +16 -0
- patchwork/skills/fm-explore/SKILL.md +16 -0
- patchwork/skills/fm-standards/SKILL.md +22 -0
- patchwork/skills/patchwork/SKILL.md +66 -0
- patchwork/templates/AGENTS.md +30 -0
- patchwork/templates/gitignore +9 -0
- patchwork/templates/naming.md +12 -0
- patchwork/tools.py +190 -0
- patchwork/uses.py +123 -0
- patchwork/workspace.py +174 -0
patchwork/rebrand.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Rebrand a whole FileMaker solution from one brand.json file.
|
|
2
|
+
|
|
3
|
+
Pipeline, every step on a new copy:
|
|
4
|
+
guids -> export -> [logo field + theme] patch -> export -> layouts patch -> export -> verify
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from patchwork import PatchworkError
|
|
16
|
+
from patchwork.build import add_field_action, field_xml, image_calc, layouts_patch, normalize_hex, split_field, theme_replace, wrap
|
|
17
|
+
from patchwork.export import Export
|
|
18
|
+
from patchwork.tools import Runner, require_copy, require_source
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Logo:
|
|
23
|
+
image: Path
|
|
24
|
+
replace: str
|
|
25
|
+
field: str = "Brand Logo"
|
|
26
|
+
width: int | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Brand:
|
|
31
|
+
name: str
|
|
32
|
+
colors: dict[str, str] = field(default_factory=dict)
|
|
33
|
+
fonts: dict[str, str] = field(default_factory=dict)
|
|
34
|
+
theme: str | None = None
|
|
35
|
+
logo: Logo | None = None
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def load(cls, path: Path) -> "Brand":
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
raise PatchworkError(f"Brand file not found: {path}")
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
43
|
+
except json.JSONDecodeError as exc:
|
|
44
|
+
raise PatchworkError(f"{path.name} is not valid JSON: {exc}") from exc
|
|
45
|
+
return cls.from_dict(data, path.parent)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, data: dict, base: Path) -> "Brand":
|
|
49
|
+
unknown = set(data) - {"$schema", "name", "colors", "fonts", "theme", "logo"}
|
|
50
|
+
if unknown:
|
|
51
|
+
raise PatchworkError(f"Unknown key(s) in brand file: {', '.join(sorted(unknown))}")
|
|
52
|
+
if not data.get("name"):
|
|
53
|
+
raise PatchworkError('The brand file needs a "name": it becomes the theme name.')
|
|
54
|
+
colors = {normalize_hex(k): normalize_hex(v) for k, v in data.get("colors", {}).items()}
|
|
55
|
+
logo = None
|
|
56
|
+
if data.get("logo"):
|
|
57
|
+
spec = data["logo"]
|
|
58
|
+
if not spec.get("image") or not spec.get("replace"):
|
|
59
|
+
raise PatchworkError('"logo" needs "image" (a PNG) and "replace" (Table::Field shown on layouts).')
|
|
60
|
+
split_field(spec["replace"])
|
|
61
|
+
logo = Logo(
|
|
62
|
+
image=(base / spec["image"]).resolve(),
|
|
63
|
+
replace=spec["replace"],
|
|
64
|
+
field=spec.get("field", "Brand Logo"),
|
|
65
|
+
width=spec.get("width"),
|
|
66
|
+
)
|
|
67
|
+
if not logo.image.is_file():
|
|
68
|
+
raise PatchworkError(f"Logo image not found: {logo.image}")
|
|
69
|
+
return cls(data["name"], colors, data.get("fonts", {}), data.get("theme"), logo)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Report:
|
|
74
|
+
theme: str
|
|
75
|
+
layouts: list[str]
|
|
76
|
+
logo_refs: int | None
|
|
77
|
+
work: Path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def rebrand(
|
|
81
|
+
src: Path,
|
|
82
|
+
dest: Path,
|
|
83
|
+
brand: Brand,
|
|
84
|
+
runner: Runner,
|
|
85
|
+
force: bool = False,
|
|
86
|
+
keep_work: bool = False,
|
|
87
|
+
say=print,
|
|
88
|
+
) -> Report:
|
|
89
|
+
src = require_source(src)
|
|
90
|
+
dest = require_copy(src, dest, force)
|
|
91
|
+
work = Path(tempfile.mkdtemp(prefix="patchwork-"))
|
|
92
|
+
try:
|
|
93
|
+
say(f"1/5 Giving every object a UUID ({src.name})")
|
|
94
|
+
current = work / "0-guids.fmp12"
|
|
95
|
+
runner.generate_guids(src, current)
|
|
96
|
+
export = export_of(runner, current, work / "0.xml")
|
|
97
|
+
|
|
98
|
+
theme = brand.theme or export.main_theme()
|
|
99
|
+
theme_block = export.theme(theme)
|
|
100
|
+
say(f"2/5 Rewriting theme {theme_block.name!r} as {brand.name!r}" + (" + adding the logo field" if brand.logo else ""))
|
|
101
|
+
actions = []
|
|
102
|
+
if brand.logo:
|
|
103
|
+
table, _ = split_field(brand.logo.replace)
|
|
104
|
+
export.field(*split_field(brand.logo.replace))
|
|
105
|
+
calc = image_calc(brand.logo.image, brand.logo.width)
|
|
106
|
+
fxml = field_xml(export, table, brand.logo.field, "Container", f"{brand.name} logo, added by Patchwork", True, calc)
|
|
107
|
+
actions.append(add_field_action(export, table, fxml))
|
|
108
|
+
actions.append(theme_replace(export, theme, brand.name, brand.colors, brand.fonts))
|
|
109
|
+
current = step(runner, current, wrap(*actions, version=export.version), work / "1-theme")
|
|
110
|
+
export = export_of(runner, current, work / "1.xml")
|
|
111
|
+
|
|
112
|
+
say("3/5 Rewriting the layouts that change")
|
|
113
|
+
remap = None
|
|
114
|
+
if brand.logo:
|
|
115
|
+
table, _ = split_field(brand.logo.replace)
|
|
116
|
+
remap = (brand.logo.replace, f"{table}::{brand.logo.field}")
|
|
117
|
+
patch, names = layouts_patch(export, None, brand.colors, brand.fonts, remap)
|
|
118
|
+
if names:
|
|
119
|
+
current = step(runner, current, patch, work / "2-layouts")
|
|
120
|
+
say(f" {len(names)} layout(s) rewritten")
|
|
121
|
+
|
|
122
|
+
say("4/5 Checking the result")
|
|
123
|
+
export = export_of(runner, current, work / "final.xml")
|
|
124
|
+
displays = [display for display, _, _ in export.themes()]
|
|
125
|
+
if brand.name not in displays:
|
|
126
|
+
raise PatchworkError(f"FMUpgradeTool reported success but theme {brand.name!r} is missing. Work kept in {work}")
|
|
127
|
+
logo_refs = None
|
|
128
|
+
if brand.logo:
|
|
129
|
+
table, _ = split_field(brand.logo.replace)
|
|
130
|
+
_, logo_uuid = export.field(table, brand.logo.field)
|
|
131
|
+
logo_refs = export.field_references(logo_uuid)
|
|
132
|
+
|
|
133
|
+
say(f"5/5 Writing {dest.name}")
|
|
134
|
+
if dest.exists():
|
|
135
|
+
dest.unlink()
|
|
136
|
+
shutil.copy2(current, dest)
|
|
137
|
+
return Report(brand.name, names, logo_refs, work)
|
|
138
|
+
finally:
|
|
139
|
+
if not keep_work:
|
|
140
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def export_of(runner: Runner, fmp12: Path, xml: Path) -> Export:
|
|
144
|
+
runner.export_xml(fmp12, xml, force=True)
|
|
145
|
+
return Export.load(xml)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def step(runner: Runner, current: Path, patch: str, stem: Path) -> Path:
|
|
149
|
+
patch_file = stem.with_suffix(".xml")
|
|
150
|
+
patch_file.write_text(patch, encoding="utf-8")
|
|
151
|
+
result = stem.with_suffix(".fmp12")
|
|
152
|
+
runner.apply(current, patch_file, result)
|
|
153
|
+
return result
|
patchwork/script.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Script steps as FileMaker text, both ways.
|
|
2
|
+
|
|
3
|
+
`render` turns any <Step> into one line, the way the Script Workspace shows it:
|
|
4
|
+
Set Field [ Clients::Status ; "Gold" ]
|
|
5
|
+
`build` turns those lines back into <Step> XML for the steps in STEP_IDS. The XML shapes and
|
|
6
|
+
<Options> bits were read from real Save-as-XML exports (FileMaker 20 to 2026).
|
|
7
|
+
A line that is exactly the rendering of a step already in the script reuses that step's XML,
|
|
8
|
+
so any step, supported or not, survives an edit of the text around it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import html
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from patchwork import PatchworkError, objects
|
|
18
|
+
from patchwork.export import Export, escape, unescape
|
|
19
|
+
|
|
20
|
+
BLOCK_OPEN = {"If", "Loop"}
|
|
21
|
+
BLOCK_MIDDLE = {"Else If", "Else"}
|
|
22
|
+
BLOCK_CLOSE = {"End If", "End Loop"}
|
|
23
|
+
ON_OFF = {"True": "On", "False": "Off"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------- reading ----------
|
|
27
|
+
|
|
28
|
+
def cdata(xml: str) -> str:
|
|
29
|
+
found = re.search(r"<!\[CDATA\[([\s\S]*?)\]\]>", xml)
|
|
30
|
+
return re.sub(r"\r\n|\r", "\n", found.group(1)) if found else ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def param(step: str, kind: str) -> str:
|
|
34
|
+
found = re.search(rf'<Parameter type="{re.escape(kind)}"[^>]*>([\s\S]*?)</Parameter>\s*(?=<Parameter type=|</ParameterValues>)', step)
|
|
35
|
+
return found.group(1) if found else ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def calc(step: str, position: str | None = None) -> str:
|
|
39
|
+
"""Calculation text of a step parameter, by its position attribute."""
|
|
40
|
+
pattern = rf'<Calculation datatype="\d+" position="{position}">' if position else r'<Calculation datatype="\d+" position="\d+">'
|
|
41
|
+
found = re.search(pattern + r"[\s\S]*?</Calculation>\s*</Calculation>", step)
|
|
42
|
+
return cdata(found.group(0)) if found else ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def field_name(xml: str) -> str:
|
|
46
|
+
ref = re.search(r'<FieldReference [^>]*\bname="([^"]*)"[\s\S]*?</FieldReference>', xml)
|
|
47
|
+
if not ref:
|
|
48
|
+
return ""
|
|
49
|
+
to = re.search(r'<TableOccurrenceReference [^>]*\bname="([^"]*)"', ref.group(0))
|
|
50
|
+
rep = re.search(r"<repetition>[\s\S]*?</repetition>", ref.group(0))
|
|
51
|
+
rep_text = cdata(rep.group(0)) if rep else "1"
|
|
52
|
+
name = f"{unescape(to.group(1))}::{unescape(ref.group(1))}" if to else unescape(ref.group(1))
|
|
53
|
+
return name + (f"[{rep_text}]" if rep_text not in ("", "1") else "")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def booleans(step: str) -> list[tuple[str, str]]:
|
|
57
|
+
return [(unescape(t or ""), v) for t, v in re.findall(r'<Boolean (?:type="([^"]*)" )?id="\d+" value="(True|False)"', step)
|
|
58
|
+
if t != "Collapsed"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def quoted(name: str) -> str:
|
|
62
|
+
return '"' + name + '"'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render(step: str) -> str:
|
|
66
|
+
head = step[: step.index(">")]
|
|
67
|
+
name = unescape(re.search(r'\sname="([^"]*)"', head).group(1))
|
|
68
|
+
off = "// " if 'enable="False"' in head else ""
|
|
69
|
+
if name == "# (comment)":
|
|
70
|
+
comment = re.search(r'<Comment value="([^"]*)"', step)
|
|
71
|
+
text = html.unescape(comment.group(1)) if comment else ""
|
|
72
|
+
return f"{off}# {text}".rstrip().replace("\r", "\n").replace("\n", "¶")
|
|
73
|
+
parts = RENDERERS.get(name, generic)(step)
|
|
74
|
+
if not parts:
|
|
75
|
+
return f"{off}{name}"
|
|
76
|
+
out = f"{off}{name} [ "
|
|
77
|
+
for i, part in enumerate(parts):
|
|
78
|
+
out += part + ("\n" if "//" in part.rsplit("\n", 1)[-1] else " ")
|
|
79
|
+
out += "; " if i < len(parts) - 1 else "]"
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def generic(step: str) -> list[str]:
|
|
84
|
+
parts = []
|
|
85
|
+
field = field_name(step)
|
|
86
|
+
if field:
|
|
87
|
+
parts.append(field)
|
|
88
|
+
for ref in ("ScriptReference", "LayoutReference"):
|
|
89
|
+
found = re.search(rf'<{ref} [^>]*\bname="([^"]*)"', step)
|
|
90
|
+
if found:
|
|
91
|
+
parts.append(quoted(unescape(found.group(1))))
|
|
92
|
+
parts += [f"{t}: {ON_OFF[v]}" if t else ON_OFF[v] for t, v in booleans(step)]
|
|
93
|
+
parts += [unescape(n) for n in re.findall(r'<List name="([^"]*)"', step) if n not in ("From list", "Always")]
|
|
94
|
+
parts += [cdata(c) for c in re.findall(r'<Calculation datatype="\d+" position="(?!10")\d+">[\s\S]*?</Calculation>\s*</Calculation>', step)]
|
|
95
|
+
return parts
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_set_variable(step: str) -> list[str]:
|
|
99
|
+
name = re.search(r'<Name value="([^"]*)"', step)
|
|
100
|
+
rep = re.search(r"<repetition>[\s\S]*?</repetition>", step)
|
|
101
|
+
rep_text = cdata(rep.group(0)) if rep else "1"
|
|
102
|
+
target = unescape(name.group(1)) if name else "$"
|
|
103
|
+
if rep_text not in ("", "1"):
|
|
104
|
+
target += f"[{rep_text}]"
|
|
105
|
+
value = re.search(r"<value>[\s\S]*?</value>", step)
|
|
106
|
+
return [target, f"Value: {cdata(value.group(0)) if value else ''}"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_go_to_layout(step: str) -> list[str]:
|
|
110
|
+
layout = re.search(r'<LayoutReference [^>]*\bname="([^"]*)"', step)
|
|
111
|
+
container = re.search(r'<LayoutReferenceContainer value="(\d+)"', step)
|
|
112
|
+
parts = [quoted(unescape(layout.group(1))) if layout else
|
|
113
|
+
"original layout" if container and container.group(1) == "1" else f"Layout: {calc(step) or '?'}"]
|
|
114
|
+
animation = re.search(r'<Animation name="([^"]*)"', step)
|
|
115
|
+
if animation and animation.group(1) != "None":
|
|
116
|
+
parts.append(f"Animation: {animation.group(1)}")
|
|
117
|
+
return parts
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def render_perform_script(step: str) -> list[str]:
|
|
121
|
+
script = re.search(r'<ScriptReference [^>]*\bname="([^"]*)"', step)
|
|
122
|
+
parts = [quoted(unescape(script.group(1))) if script else f"By name: {calc(step, '1')}"]
|
|
123
|
+
parameter = calc(param(step, "Parameter"))
|
|
124
|
+
if parameter:
|
|
125
|
+
parts.append(f"Parameter: {parameter}")
|
|
126
|
+
return parts
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render_dialog(step: str) -> list[str]:
|
|
130
|
+
buttons = [unescape(b) for b in re.findall(r'<Parameter type="Button\d" value="([^"]*)"', step)]
|
|
131
|
+
parts = [calc(param(step, "Title")), calc(param(step, "Message"))]
|
|
132
|
+
if buttons and buttons != ["OK"]:
|
|
133
|
+
parts.append("Buttons: " + ", ".join(quoted(b) for b in buttons))
|
|
134
|
+
return parts
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def render_exit_script(step: str) -> list[str]:
|
|
138
|
+
result = calc(step)
|
|
139
|
+
return [f"Text Result: {result}"] if result else []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def render_close_window(step: str) -> list[str]:
|
|
143
|
+
return ["Current Window"] if 'type="current"' in step else generic(step)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render_switch(step: str) -> list[str]:
|
|
147
|
+
return [ON_OFF[v] for _, v in booleans(step)]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render_loop(step: str) -> list[str]:
|
|
151
|
+
flush = re.search(r'<List name="([^"]*)"', step)
|
|
152
|
+
return [] if not flush or flush.group(1) == "Always" else [f"Flush: {flush.group(1)}"]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
RENDERERS: dict[str, Callable[[str], list[str]]] = {
|
|
156
|
+
"Set Field": lambda s: [field_name(s), calc(s, "0")],
|
|
157
|
+
"Set Variable": render_set_variable,
|
|
158
|
+
"If": lambda s: [calc(s)], "Else If": lambda s: [calc(s)], "Exit Loop If": lambda s: [calc(s)],
|
|
159
|
+
"Else": lambda s: [], "Loop": render_loop,
|
|
160
|
+
"Go to Layout": render_go_to_layout,
|
|
161
|
+
"Perform Script": render_perform_script,
|
|
162
|
+
"Show Custom Dialog": render_dialog,
|
|
163
|
+
"Exit Script": render_exit_script,
|
|
164
|
+
"Close Window": render_close_window,
|
|
165
|
+
"Set Error Capture": render_switch, "Allow User Abort": render_switch,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def steps_of(xml: str) -> list[str]:
|
|
170
|
+
return re.findall(r"<Step\b[\s\S]*?</Step>", xml)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def name_of(step: str) -> str:
|
|
174
|
+
return unescape(re.search(r'\sname="([^"]*)"', step[: step.index(">")]).group(1))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def to_text(steps_xml: str) -> str:
|
|
178
|
+
lines, depth = [], 0
|
|
179
|
+
for step in steps_of(steps_xml):
|
|
180
|
+
name = name_of(step)
|
|
181
|
+
if name in BLOCK_CLOSE or name in BLOCK_MIDDLE:
|
|
182
|
+
depth = max(depth - 1, 0)
|
|
183
|
+
lines.append(" " * depth + render(step))
|
|
184
|
+
if name in BLOCK_OPEN or name in BLOCK_MIDDLE:
|
|
185
|
+
depth += 1
|
|
186
|
+
return "\n".join(lines)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def normalized(steps_xml: str) -> list[str]:
|
|
190
|
+
return [re.sub(r"\s+", " ", render(s)).strip() for s in steps_of(steps_xml)]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------- writing ----------
|
|
194
|
+
|
|
195
|
+
def split_lines(text: str) -> list[str]:
|
|
196
|
+
"""One entry per step: a line, plus following lines while a [ ... ] is still open."""
|
|
197
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
198
|
+
out, start, depth, state, i = [], None, 0, "", 0
|
|
199
|
+
while i < len(text):
|
|
200
|
+
ch = text[i]
|
|
201
|
+
if start is None:
|
|
202
|
+
if ch.isspace():
|
|
203
|
+
i += 1
|
|
204
|
+
continue
|
|
205
|
+
start, depth, state = i, 0, ""
|
|
206
|
+
head = text[i:].lstrip("/ ")
|
|
207
|
+
if head.startswith("#"):
|
|
208
|
+
end = text.find("\n", i)
|
|
209
|
+
end = len(text) if end < 0 else end
|
|
210
|
+
out.append(text[start:end].strip())
|
|
211
|
+
start, i = None, end
|
|
212
|
+
continue
|
|
213
|
+
if text.startswith("//", i):
|
|
214
|
+
i += 2
|
|
215
|
+
continue
|
|
216
|
+
if state == '"':
|
|
217
|
+
if ch == "\\":
|
|
218
|
+
i += 1
|
|
219
|
+
elif ch == '"':
|
|
220
|
+
state = ""
|
|
221
|
+
elif state == "/*":
|
|
222
|
+
if text.startswith("*/", i):
|
|
223
|
+
state, i = "", i + 1
|
|
224
|
+
elif state == "//":
|
|
225
|
+
if ch == "\n":
|
|
226
|
+
state = ""
|
|
227
|
+
continue
|
|
228
|
+
elif ch == '"':
|
|
229
|
+
state = '"'
|
|
230
|
+
elif text.startswith("/*", i):
|
|
231
|
+
state, i = "/*", i + 1
|
|
232
|
+
elif text.startswith("//", i) and depth > 0:
|
|
233
|
+
state, i = "//", i + 1
|
|
234
|
+
elif ch in "[(":
|
|
235
|
+
depth += 1
|
|
236
|
+
elif ch in "])":
|
|
237
|
+
depth -= 1
|
|
238
|
+
elif ch == "\n" and depth <= 0:
|
|
239
|
+
out.append(text[start:i].strip())
|
|
240
|
+
start = None
|
|
241
|
+
i += 1
|
|
242
|
+
if start is not None:
|
|
243
|
+
if depth > 0 or state in ('"', "/*"):
|
|
244
|
+
raise PatchworkError(f"Unclosed [, quote or comment in: {text[start:start + 80]}")
|
|
245
|
+
out.append(text[start:].strip())
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def split_params(inside: str) -> list[str]:
|
|
250
|
+
parts, start, depth, state, i = [], 0, 0, "", 0
|
|
251
|
+
while i < len(inside):
|
|
252
|
+
ch = inside[i]
|
|
253
|
+
if state == '"':
|
|
254
|
+
if ch == "\\":
|
|
255
|
+
i += 1
|
|
256
|
+
elif ch == '"':
|
|
257
|
+
state = ""
|
|
258
|
+
elif state == "/*":
|
|
259
|
+
if inside.startswith("*/", i):
|
|
260
|
+
state, i = "", i + 1
|
|
261
|
+
elif state == "//":
|
|
262
|
+
if ch == "\n":
|
|
263
|
+
state = ""
|
|
264
|
+
elif ch == '"':
|
|
265
|
+
state = '"'
|
|
266
|
+
elif inside.startswith("/*", i):
|
|
267
|
+
state, i = "/*", i + 1
|
|
268
|
+
elif inside.startswith("//", i):
|
|
269
|
+
state, i = "//", i + 1
|
|
270
|
+
elif ch in "([{":
|
|
271
|
+
depth += 1
|
|
272
|
+
elif ch in ")]}":
|
|
273
|
+
depth -= 1
|
|
274
|
+
elif ch == ";" and depth == 0:
|
|
275
|
+
parts.append(inside[start:i].strip())
|
|
276
|
+
start = i + 1
|
|
277
|
+
i += 1
|
|
278
|
+
rest = inside[start:].strip()
|
|
279
|
+
if rest or parts:
|
|
280
|
+
parts.append(rest)
|
|
281
|
+
return parts
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def parse_line(line: str) -> tuple[str, list[str], bool]:
|
|
285
|
+
text = line.strip()
|
|
286
|
+
enabled = not text.startswith("//")
|
|
287
|
+
if not enabled:
|
|
288
|
+
text = text[2:].strip()
|
|
289
|
+
if text.startswith("#"):
|
|
290
|
+
return "# (comment)", [text[1:].strip()], enabled
|
|
291
|
+
match = re.match(r"([^\[]+?)\s*(?:\[([\s\S]*)\])?\s*$", text)
|
|
292
|
+
if not match:
|
|
293
|
+
raise PatchworkError(f"Cannot read this step: {line.strip()[:80]}")
|
|
294
|
+
return match.group(1).strip(), split_params(match.group(2) or ""), enabled
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def labelled(params: list[str], label: str) -> str | None:
|
|
298
|
+
for p in params:
|
|
299
|
+
if p.lower().startswith(label.lower() + ":"):
|
|
300
|
+
return p[len(label) + 1:].strip()
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def calc_xml(text: str, position: int, datatype: int = 1) -> str:
|
|
305
|
+
if "]]>" in text:
|
|
306
|
+
raise PatchworkError("A calculation cannot contain ']]>'.")
|
|
307
|
+
return f'<Calculation datatype="{datatype}" position="{position}"><Calculation><Text><![CDATA[{text}]]></Text></Calculation></Calculation>'
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def step_xml(name: str, options: int, params: list[str] = (), enabled: bool = True) -> str:
|
|
311
|
+
inner = (f'<ParameterValues membercount="{len(params)}">' + "".join(params) + "</ParameterValues>") if params else ""
|
|
312
|
+
return (f'<Step id="{STEP_IDS[name]}" name="{escape(name)}" enable="{enabled}"><UUID>{objects.new_uuid()}</UUID>'
|
|
313
|
+
f"<OwnerID></OwnerID><Options>{options}</Options>{inner}</Step>")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def boolean(kind: str, ident: int, value: bool) -> str:
|
|
317
|
+
return f'<Parameter type="Boolean"><Boolean type="{escape(kind)}" id="{ident}" value="{value}"></Boolean></Parameter>'
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
COLLAPSED = boolean("Collapsed", 33554432, False)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def on(value: str | None, default: bool) -> bool:
|
|
324
|
+
if value is None:
|
|
325
|
+
return default
|
|
326
|
+
if value.strip().lower() not in ("on", "off"):
|
|
327
|
+
raise PatchworkError(f"Expected On or Off, got: {value}")
|
|
328
|
+
return value.strip().lower() == "on"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def target_field(export: Export, text: str) -> str:
|
|
332
|
+
match = re.fullmatch(r"(.+?::.+?)(?:\[(.+)\])?", text.strip())
|
|
333
|
+
if not match:
|
|
334
|
+
raise PatchworkError(f"Expected a field as Occurrence::Field, got: {text}")
|
|
335
|
+
from patchwork.place import field_target
|
|
336
|
+
field_id, name, field_uuid, to_ref = field_target(export, match.group(1))
|
|
337
|
+
rep = calc_xml(match.group(2) or "1", 10)
|
|
338
|
+
return (f'<Parameter type="FieldReference"><FieldReference id="{field_id}" name="{escape(name)}" UUID="{field_uuid}">'
|
|
339
|
+
f"<repetition>{rep}</repetition>{to_ref}</FieldReference></Parameter>")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def unquote(text: str) -> str:
|
|
343
|
+
text = text.strip()
|
|
344
|
+
if len(text) >= 2 and text[0] == text[-1] == '"':
|
|
345
|
+
return text[1:-1]
|
|
346
|
+
return text
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def build_step(export: Export, name: str, params: list[str], enabled: bool) -> str:
|
|
350
|
+
def need(count: int) -> None:
|
|
351
|
+
if len(params) < count:
|
|
352
|
+
raise PatchworkError(f"{name} needs {count} parameter(s) in [ ... ], got {len(params)}.")
|
|
353
|
+
|
|
354
|
+
if name == "# (comment)":
|
|
355
|
+
text = params[0].replace("¶", "\r") if params else ""
|
|
356
|
+
value = f' value="{escape(text).replace(chr(13), " ")}"' if text else ""
|
|
357
|
+
return step_xml(name, 4 if text else 0, [f'<Parameter type="Comment"><Comment{value}></Comment></Parameter>'], enabled)
|
|
358
|
+
if name == "Set Field":
|
|
359
|
+
need(2)
|
|
360
|
+
return step_xml(name, 16385, [target_field(export, params[0]), f'<Parameter type="Calculation">{calc_xml(params[1], 0)}</Parameter>'], enabled)
|
|
361
|
+
if name == "Set Variable":
|
|
362
|
+
need(2)
|
|
363
|
+
match = re.fullmatch(r"(\$\$?[^\[\s]+)(?:\[(.+)\])?", params[0].strip())
|
|
364
|
+
if not match:
|
|
365
|
+
raise PatchworkError(f"Set Variable needs a $name or $$name, got: {params[0]}")
|
|
366
|
+
value = labelled(params[1:], "Value")
|
|
367
|
+
value = params[1] if value is None else value
|
|
368
|
+
return step_xml(name, 16388, [f'<Parameter type="Variable"><value>{calc_xml(value, 1)}</value><Name value="{escape(match.group(1))}"></Name>'
|
|
369
|
+
f"<repetition>{calc_xml(match.group(2) or '1', 2)}</repetition></Parameter>"], enabled)
|
|
370
|
+
if name in ("If", "Else If"):
|
|
371
|
+
need(1)
|
|
372
|
+
return step_xml(name, 16384, [COLLAPSED, f'<Parameter type="Calculation">{calc_xml(params[0], 0, 7)}</Parameter>'], enabled)
|
|
373
|
+
if name == "Exit Loop If":
|
|
374
|
+
need(1)
|
|
375
|
+
return step_xml(name, 16384, [f'<Parameter type="Calculation">{calc_xml(params[0], 0, 7)}</Parameter>'], enabled)
|
|
376
|
+
if name == "Else":
|
|
377
|
+
return step_xml(name, 0, [COLLAPSED, COLLAPSED], enabled)
|
|
378
|
+
if name == "Loop":
|
|
379
|
+
return step_xml(name, 0, [COLLAPSED, '<Parameter type="List"><List name="Always" value="1"></List></Parameter>'], enabled)
|
|
380
|
+
if name == "Go to Layout":
|
|
381
|
+
need(1)
|
|
382
|
+
if params[0].strip().lower() == "original layout":
|
|
383
|
+
container = '<LayoutReferenceContainer value="1"><Label>original layout</Label></LayoutReferenceContainer>'
|
|
384
|
+
options = 0
|
|
385
|
+
else:
|
|
386
|
+
layout = objects.find(export, "layout", unquote(params[0]))
|
|
387
|
+
container = (f'<LayoutReferenceContainer value="5"><LayoutReference id="{layout.id}" name="{escape(layout.name)}" '
|
|
388
|
+
f'UUID="{layout.uuid}"></LayoutReference></LayoutReferenceContainer>')
|
|
389
|
+
options = 8
|
|
390
|
+
return step_xml(name, options, [f'<Parameter type="LayoutReferenceContainer">{container}</Parameter>',
|
|
391
|
+
'<Parameter type="Animation"><Animation name="None" value="0"></Animation></Parameter>'], enabled)
|
|
392
|
+
if name == "Perform Script":
|
|
393
|
+
need(1)
|
|
394
|
+
script = objects.find(export, "script", unquote(params[0]))
|
|
395
|
+
parameter = labelled(params[1:], "Parameter")
|
|
396
|
+
inner = calc_xml(parameter, 0) if parameter else ""
|
|
397
|
+
return step_xml(name, 16448 if parameter else 64, [
|
|
398
|
+
f'<Parameter type="List"><List name="From list" value="1"><ScriptReference id="{script.id}" name="{escape(script.name)}" '
|
|
399
|
+
f'UUID="{script.uuid}"></ScriptReference></List></Parameter>',
|
|
400
|
+
f'<Parameter type="Parameter"><Parameter>{inner}</Parameter></Parameter>'], enabled)
|
|
401
|
+
if name == "Show Custom Dialog":
|
|
402
|
+
need(2)
|
|
403
|
+
buttons = labelled(params[2:], "Buttons")
|
|
404
|
+
labels = re.findall(r'"([^"]*)"', buttons) if buttons else ["OK"]
|
|
405
|
+
if len(labels) > 3:
|
|
406
|
+
raise PatchworkError("Show Custom Dialog has at most 3 buttons.")
|
|
407
|
+
labels += [""] * (3 - len(labels))
|
|
408
|
+
button_xml = "".join(
|
|
409
|
+
f'<Parameter type="Button{i + 1}"{f" value=" + chr(34) + escape(b) + chr(34) if b else ""}>'
|
|
410
|
+
f'<Boolean type="Commit" value="{i == 0}"></Boolean></Parameter>' for i, b in enumerate(labels))
|
|
411
|
+
return step_xml(name, 16388, [f'<Parameter type="Title">{calc_xml(params[0], 0)}</Parameter>',
|
|
412
|
+
f'<Parameter type="Message">{calc_xml(params[1], 1)}</Parameter>', button_xml], enabled)
|
|
413
|
+
if name == "Exit Script":
|
|
414
|
+
result = labelled(params, "Text Result")
|
|
415
|
+
result = params[0] if result is None and params else result
|
|
416
|
+
if not result:
|
|
417
|
+
return step_xml(name, 0, enabled=enabled)
|
|
418
|
+
return step_xml(name, 16384, [f'<Parameter type="Calculation">{calc_xml(result, 0)}</Parameter>'], enabled)
|
|
419
|
+
if name in ("Commit Records/Requests", "Delete Record/Request"):
|
|
420
|
+
dialog = on(labelled(params, "With dialog"), True)
|
|
421
|
+
options = 0 if dialog else 128
|
|
422
|
+
values = [boolean("With dialog", 128, dialog)]
|
|
423
|
+
if name == "Commit Records/Requests":
|
|
424
|
+
skip, force = on(labelled(params, "Skip data entry validation"), False), on(labelled(params, "Force Commit"), False)
|
|
425
|
+
options += 256 * skip + 512 * force
|
|
426
|
+
values = [boolean("Skip data entry validation", 256, skip), *values, boolean("Force Commit", 512, force)]
|
|
427
|
+
return step_xml(name, options, values, enabled)
|
|
428
|
+
if name in ("Set Error Capture", "Allow User Abort"):
|
|
429
|
+
need(1)
|
|
430
|
+
value = on(params[0], True)
|
|
431
|
+
return step_xml(name, 196608 if value else 65536,
|
|
432
|
+
[f'<Parameter type="Boolean"><Boolean id="131072" value="{value}"></Boolean></Parameter>'], enabled)
|
|
433
|
+
if name == "Refresh Window":
|
|
434
|
+
join, external = on(labelled(params, "Flush cached join results"), False), on(labelled(params, "Flush cached external data"), False)
|
|
435
|
+
return step_xml(name, 256 * join + 512 * external, [boolean("Flush cached join results", 256, join),
|
|
436
|
+
boolean("Flush cached external data", 512, external)], enabled)
|
|
437
|
+
if name == "Close Window":
|
|
438
|
+
return step_xml(name, -2147483648, ['<Parameter type="WindowReference"><WindowReference><Select kind="0" type="current">'
|
|
439
|
+
"</Select></WindowReference></Parameter>"], enabled)
|
|
440
|
+
if name in NO_PARAMS:
|
|
441
|
+
return step_xml(name, 0, enabled=enabled)
|
|
442
|
+
raise PatchworkError(f"Unknown step for text: {name}. Text works for: {', '.join(sorted(STEP_IDS))}. "
|
|
443
|
+
f"For another step, keep its line exactly as `show script` prints it, or pass <Step> XML "
|
|
444
|
+
f"(see `patchwork show step {name!r}`).")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
NO_PARAMS = {"End If", "End Loop", "Freeze Window", "New Record/Request", "Show All Records", "Halt Script", "Beep"}
|
|
448
|
+
STEP_IDS = {
|
|
449
|
+
"# (comment)": 89, "Set Field": 76, "Set Variable": 141, "If": 68, "Else If": 125, "Else": 69, "End If": 70,
|
|
450
|
+
"Loop": 71, "Exit Loop If": 72, "End Loop": 73, "Go to Layout": 6, "Perform Script": 1, "Show Custom Dialog": 87,
|
|
451
|
+
"Exit Script": 103, "Commit Records/Requests": 75, "Delete Record/Request": 9, "Set Error Capture": 86,
|
|
452
|
+
"Allow User Abort": 85, "Refresh Window": 80, "Close Window": 121, "Freeze Window": 79, "New Record/Request": 7,
|
|
453
|
+
"Show All Records": 23, "Halt Script": 90, "Beep": 93,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def build(export: Export, text: str, existing: str = "") -> str:
|
|
458
|
+
"""<Step> XML for script text. Lines identical to an existing step reuse that step."""
|
|
459
|
+
known: dict[str, list[str]] = {}
|
|
460
|
+
for step in steps_of(existing):
|
|
461
|
+
known.setdefault(re.sub(r"\s+", " ", render(step)).strip(), []).append(step)
|
|
462
|
+
out = []
|
|
463
|
+
for line in split_lines(text):
|
|
464
|
+
key = re.sub(r"\s+", " ", line).strip()
|
|
465
|
+
if known.get(key):
|
|
466
|
+
out.append(known[key].pop(0))
|
|
467
|
+
continue
|
|
468
|
+
out.append(build_step(export, *parse_line(line)))
|
|
469
|
+
if not out:
|
|
470
|
+
raise PatchworkError("No step found in the text.")
|
|
471
|
+
return "\n".join(out)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def is_xml(steps: str) -> bool:
|
|
475
|
+
return "<Step" in steps
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fm-data
|
|
3
|
+
description: >
|
|
4
|
+
Read and write FileMaker records, run scripts, and load sample data through
|
|
5
|
+
the FileMaker Data API or OData. Use when the user asks for records, sample
|
|
6
|
+
data, or to run a FileMaker script on a hosted file.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Data
|
|
10
|
+
|
|
11
|
+
Patchwork changes structure only: FMUpgradeTool cannot read or write records. Records go through the server.
|
|
12
|
+
|
|
13
|
+
- Use an account of the role being tested, not the Full Access account that writes schema.
|
|
14
|
+
- Credentials live in a `.env` file that Git ignores. Configure them only once the user has given the server, the database and the account.
|
|
15
|
+
- Sample data is fictional. Never pull production records into the conversation.
|
|
16
|
+
- References: the Claris [Data API guide](https://help.claris.com/en/data-api-guide/) and [OData guide](https://help.claris.com/en/odata-guide/).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fm-explore
|
|
3
|
+
description: >
|
|
4
|
+
Map and document an existing FileMaker solution: tables, fields, layouts,
|
|
5
|
+
scripts, value lists, relationships. Use when the user asks to explore,
|
|
6
|
+
understand, document, or audit a FileMaker file.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Explore a solution
|
|
10
|
+
|
|
11
|
+
1. `patchwork_open` the file (or reuse the open workspace), then `patchwork_status` and `patchwork_list` with no kind for the counts.
|
|
12
|
+
2. List each kind: `patchwork_list kind=tables`, then `kind=fields table=...` per table, `kind=layouts`, `kind=scripts`, `kind=value-lists`, `kind=custom-functions`.
|
|
13
|
+
3. Open what matters with `patchwork_show` before describing it. Read a whole script before saying what it does. Do not claim behavior the XML does not show.
|
|
14
|
+
4. The full export is `.patchwork/vNNN.xml` (the highest number is current). For relationships read its `<RelationshipCatalog>`; for privileges `<PrivilegeSetsCatalog>`.
|
|
15
|
+
|
|
16
|
+
Write the map to `docs/map.md`: tables and key fields, entry-point scripts (startup, buttons, triggers), script-to-layout navigation, value lists, open questions. Never copy record data.
|