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.
patchwork/mcp.py ADDED
@@ -0,0 +1,172 @@
1
+ """MCP server over stdio (newline-delimited JSON-RPC 2.0), standard library only.
2
+
3
+ Works with any MCP client: Claude Code, Claude Desktop, Cursor, Codex, VS Code.
4
+ patchwork mcp [--project DIR]
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any, Callable
13
+
14
+ from patchwork import PatchworkError, __version__, ops, script
15
+ from patchwork.workspace import Workspace
16
+
17
+ PROTOCOL = "2025-06-18"
18
+ INSTRUCTIONS = """Patchwork edits FileMaker (.fmp12) files through Claris FMUpgradeTool, on a versioned working copy.
19
+ Start with patchwork_open, then read before you write: patchwork_list and patchwork_show give the real XML of any object.
20
+ Every edit creates a new version, is re-exported and checked; a change FMUpgradeTool silently skipped is refused.
21
+ Scripts read and write as FileMaker text, one step per line: Set Field [ Clients::Status ; "Gold" ].
22
+ Read a script with patchwork_show kind=script, edit the text, send it back with patchwork_set_steps.
23
+ Use patchwork_undo to step back, patchwork_save to write the result to a new file. The source file is never modified."""
24
+
25
+ S = {"type": "string"}
26
+ EDIT = {"message": {"type": "string", "description": "What this change does, kept in the version log."},
27
+ "dry_run": {"type": "boolean", "description": "Only write the patch, do not apply it."}}
28
+ STEPS = {"type": "string", "description": 'One step per line, as FileMaker shows them: Set Variable [ $n ; Value: 1 ], If [ $n > 0 ], '
29
+ 'Show Custom Dialog [ "Title" ; "Message" ; Buttons: "OK", "Cancel" ], End If, # comment. Or <Step> XML.'}
30
+ KIND_ENUM = ["layout", "script", "value-list", "custom-function", "theme", "field", "step"]
31
+
32
+
33
+ def tool(name: str, description: str, properties: dict, required: list[str], handler: Callable, needs_ws: bool = True,
34
+ read_only: bool = False) -> dict:
35
+ return {"name": name, "description": description, "handler": handler, "needs_ws": needs_ws,
36
+ "inputSchema": {"type": "object", "properties": properties, "required": required},
37
+ "annotations": {"readOnlyHint": read_only, "destructiveHint": False, "idempotentHint": read_only}}
38
+
39
+
40
+ TOOLS = [
41
+ tool("patchwork_open", "Open a closed .fmp12 as a new working copy (v000). Never modifies the source. The password comes from PATCHWORK_PASSWORD.",
42
+ {"source": S, "account": S, "no_password": {"type": "boolean"}, "project": {"type": "string", "description": "Folder for the .patchwork workspace. Default: the source file's folder."},
43
+ "force": {"type": "boolean", "description": "Replace an existing workspace."}},
44
+ ["source", "account"], None, needs_ws=False),
45
+ tool("patchwork_status", "Current version, source file and object counts.", {}, [], lambda ws, a: ops.status(ws), read_only=True),
46
+ tool("patchwork_list", "List objects: layouts, scripts, value-lists, custom-functions, themes, tables, table-occurrences, or fields (with table).",
47
+ {"kind": {"type": "string", "enum": list(ops.LISTABLE)}, "table": S}, [],
48
+ lambda ws, a: ops.list_objects(ws, a.get("kind", ""), a.get("table", "")), read_only=True),
49
+ tool("patchwork_show", "Show one object. A script comes as FileMaker text, one step per line (xml=true for its XML). "
50
+ "Other kinds come as Save-as-XML. kind=field takes Table::Field. kind=step shows the XML of a real step by its name, e.g. 'Set Field'.",
51
+ {"kind": {"type": "string", "enum": KIND_ENUM}, "name": S, "xml": {"type": "boolean"}}, ["kind", "name"],
52
+ lambda ws, a: ops.show(ws, a["kind"], a["name"], bool(a.get("xml"))), read_only=True),
53
+ tool("patchwork_add_field", "Add a field to a table. type: Text, Number, Date, Time, Timestamp, Container. With calc it becomes a calculation.",
54
+ {"table": S, "name": S, "type": {"type": "string", "enum": ["Text", "Number", "Date", "Time", "Timestamp", "Container"]},
55
+ "calc": S, "is_global": {"type": "boolean"}, "comment": S, "context": {"type": "string", "description": "Table occurrence the calculation is evaluated from."}, **EDIT},
56
+ ["table", "name"], lambda ws, a: ops.add_field(ws, **pick(a, "table", "name", "type", "calc", "is_global", "comment", "context", "message", "dry_run"))),
57
+ tool("patchwork_add_value_list", "Add a custom value list.", {"name": S, "values": {"type": "array", "items": S}, **EDIT}, ["name", "values"],
58
+ lambda ws, a: ops.add_value_list(ws, **pick(a, "name", "values", "message", "dry_run"))),
59
+ tool("patchwork_add_custom_function", "Add a custom function.", {"name": S, "calc": S, "params": {"type": "array", "items": S}, **EDIT},
60
+ ["name", "calc"], lambda ws, a: ops.add_custom_function(ws, **pick(a, "name", "calc", "params", "message", "dry_run"))),
61
+ tool("patchwork_add_script", "Create a script. steps: FileMaker script text, one step per line, or <Step> XML. "
62
+ f"Text works for: {', '.join(sorted(script.STEP_IDS))}.",
63
+ {"name": S, "steps": STEPS, **EDIT}, ["name"], lambda ws, a: ops.add_script(ws, **pick(a, "name", "steps", "message", "dry_run"))),
64
+ tool("patchwork_set_steps", "Replace all the steps of a script with the text from patchwork_show, edited. "
65
+ "Lines left as they were keep their step exactly, whatever the step.", {"script": S, "steps": STEPS, **EDIT}, ["script", "steps"],
66
+ lambda ws, a: ops.set_steps(ws, a["script"], **pick(a, "steps", "message", "dry_run"))),
67
+ tool("patchwork_append_steps", "Add steps at the end of a script.", {"script": S, "steps": STEPS, **EDIT}, ["script", "steps"],
68
+ lambda ws, a: ops.append_steps(ws, a["script"], **pick(a, "steps", "message", "dry_run"))),
69
+ tool("patchwork_replace", "Replace a whole object with edited XML from patchwork_show. Supported: layout, theme, value-list, custom-function.",
70
+ {"kind": {"type": "string", "enum": ["layout", "theme", "value-list", "custom-function"]}, "name": S, "xml": S, **EDIT},
71
+ ["kind", "name", "xml"], lambda ws, a: ops.replace(ws, **pick(a, "kind", "name", "xml", "message", "dry_run"))),
72
+ tool("patchwork_place_field", "Put a field on a layout. Copies the look and label of `near`, a field already on the layout, "
73
+ "and inserts a row below it (objects underneath move down, the part grows). side=right places it next to `near` if there is room.",
74
+ {"layout": S, "field": {"type": "string", "description": "Occurrence::Field, e.g. Clients::Status"}, "near": S,
75
+ "label": {"type": "string", "description": "Label text. Default: the field name."},
76
+ "side": {"type": "string", "enum": ["below", "right"]}, **EDIT},
77
+ ["layout", "field", "near"], lambda ws, a: ops.place_field(ws, **pick(a, "layout", "field", "near", "label", "side", "message", "dry_run"))),
78
+ tool("patchwork_uses", "What uses an object: references (certain) and mentions by name in calculations (possible). "
79
+ "Call it before deleting or renaming. Kinds: layout, script, value-list, custom-function, table, table-occurrence, field (Table::Field).",
80
+ {"kind": {"type": "string", "enum": ["layout", "script", "value-list", "custom-function", "theme", "table", "table-occurrence", "field"]},
81
+ "name": S}, ["kind", "name"], lambda ws, a: ops.uses(ws, **pick(a, "kind", "name"))),
82
+ tool("patchwork_delete", "Delete an object: script, layout, value-list, custom-function, or field (Table::Field). "
83
+ "Refused when other objects reference it: show the list to the user, and only then retry with force=true.",
84
+ {"kind": {"type": "string", "enum": ["script", "layout", "value-list", "custom-function", "field"]}, "name": S,
85
+ "force": {"type": "boolean"}, **EDIT},
86
+ ["kind", "name"], lambda ws, a: ops.delete(ws, **pick(a, "kind", "name", "message", "dry_run", "force"))),
87
+ tool("patchwork_retheme", "Rewrite a theme: colors (map of hex to hex), fonts (map 'WinName,MacName' to 'WinName,MacName'), new display name.",
88
+ {"theme": S, "colors": {"type": "object"}, "fonts": {"type": "object"}, "rename": S, **EDIT}, [],
89
+ lambda ws, a: ops.retheme(ws, **pick(a, "theme", "colors", "fonts", "rename", "message", "dry_run"))),
90
+ tool("patchwork_recolor_layouts", "Rewrite the layouts that change: local colors, fonts, or one field swapped for another everywhere.",
91
+ {"colors": {"type": "object"}, "fonts": {"type": "object"}, "swap_field": {"type": "array", "items": S, "minItems": 2, "maxItems": 2},
92
+ "only": {"type": "array", "items": S}, **EDIT}, [],
93
+ lambda ws, a: ops.recolor_layouts(ws, **pick(a, "colors", "fonts", "swap_field", "only", "message", "dry_run"))),
94
+ tool("patchwork_palette", "Colors a theme uses, most frequent first.", {"theme": S}, [], lambda ws, a: ops.palette(ws, a.get("theme", "")), read_only=True),
95
+ tool("patchwork_push", "Apply a hand-written FMUpgradeToolPatch. Prefer the specific tools.", {"patch_xml": S, **EDIT}, ["patch_xml"],
96
+ lambda ws, a: ops.push(ws, **pick(a, "patch_xml", "message", "dry_run"))),
97
+ tool("patchwork_log", "Version history with the changes of each version.", {}, [], lambda ws, a: ops.log(ws), read_only=True),
98
+ tool("patchwork_undo", "Drop the last version.", {}, [], lambda ws, a: ops.undo(ws)),
99
+ tool("patchwork_save", "Write the current version to a new .fmp12 file (never the source).", {"dest": S, "force": {"type": "boolean"}}, ["dest"],
100
+ lambda ws, a: ops.save(ws, a["dest"], a.get("force", False))),
101
+ ]
102
+ BY_NAME = {t["name"]: t for t in TOOLS}
103
+
104
+
105
+ def pick(args: dict, *names: str) -> dict:
106
+ return {n: args[n] for n in names if n in args and args[n] is not None}
107
+
108
+
109
+ class Server:
110
+ def __init__(self, project: Path | None = None) -> None:
111
+ self.project = project
112
+
113
+ def workspace(self) -> Workspace:
114
+ return Workspace.locate(self.project)
115
+
116
+ def call(self, name: str, args: dict) -> str:
117
+ spec = BY_NAME.get(name)
118
+ if not spec:
119
+ raise PatchworkError(f"Unknown tool: {name}")
120
+ if name == "patchwork_open":
121
+ source = Path(args["source"]).expanduser()
122
+ project = args.get("project") or (str(self.project) if self.project else str(source.resolve().parent))
123
+ text = ops.open_file(str(source), args["account"], args.get("no_password", False), project, args.get("force", False))
124
+ self.project = Path(project)
125
+ return text
126
+ return spec["handler"](self.workspace(), args)
127
+
128
+ def handle(self, message: dict) -> dict | None:
129
+ method, ident = message.get("method"), message.get("id")
130
+ if ident is None:
131
+ return None
132
+ try:
133
+ if method == "initialize":
134
+ requested = (message.get("params") or {}).get("protocolVersion") or PROTOCOL
135
+ result: Any = {"protocolVersion": requested, "capabilities": {"tools": {"listChanged": False}},
136
+ "serverInfo": {"name": "patchwork", "version": __version__}, "instructions": INSTRUCTIONS}
137
+ elif method == "ping":
138
+ result = {}
139
+ elif method == "tools/list":
140
+ result = {"tools": [{k: t[k] for k in ("name", "description", "inputSchema", "annotations")} for t in TOOLS]}
141
+ elif method == "tools/call":
142
+ params = message.get("params") or {}
143
+ try:
144
+ text, error = self.call(params.get("name", ""), params.get("arguments") or {}), False
145
+ except PatchworkError as exc:
146
+ text, error = str(exc), True
147
+ result = {"content": [{"type": "text", "text": text}], "isError": error}
148
+ else:
149
+ return {"jsonrpc": "2.0", "id": ident, "error": {"code": -32601, "message": f"Method not found: {method}"}}
150
+ except Exception as exc: # never let one call kill the server
151
+ return {"jsonrpc": "2.0", "id": ident, "error": {"code": -32603, "message": f"{type(exc).__name__}: {exc}"}}
152
+ return {"jsonrpc": "2.0", "id": ident, "result": result}
153
+
154
+
155
+ def serve(project: Path | None = None, stdin=None, stdout=None) -> int:
156
+ stdin = stdin or sys.stdin
157
+ stdout = stdout or sys.stdout
158
+ server = Server(project)
159
+ for line in stdin:
160
+ line = line.strip()
161
+ if not line:
162
+ continue
163
+ try:
164
+ message = json.loads(line)
165
+ except json.JSONDecodeError:
166
+ reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
167
+ else:
168
+ reply = server.handle(message)
169
+ if reply is not None:
170
+ stdout.write(json.dumps(reply, ensure_ascii=False) + "\n")
171
+ stdout.flush()
172
+ return 0
patchwork/objects.py ADDED
@@ -0,0 +1,352 @@
1
+ """Find, show, add, replace and delete FileMaker objects in a Save-as-XML export.
2
+
3
+ Every builder returns patch actions. Only the operations below were verified on real files
4
+ (FMUpgradeTool 26.0.3): anything else is refused rather than silently skipped.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import uuid
11
+ from dataclasses import dataclass
12
+
13
+ from patchwork import PatchworkError
14
+ from patchwork.export import Export, escape, layout_uuid, unescape
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Kind:
19
+ name: str
20
+ plural: str
21
+ catalog: str
22
+ tag: str
23
+ reference: str
24
+ can_add: bool = False
25
+ can_replace: bool = False
26
+ can_delete: bool = False
27
+ object_list: bool = False
28
+
29
+
30
+ KINDS = {
31
+ k.name: k
32
+ for k in (
33
+ Kind("layout", "layouts", "LayoutCatalog", "Layout", "LayoutReference", can_replace=True, can_delete=True),
34
+ Kind("script", "scripts", "ScriptCatalog", "Script", "ScriptReference", can_add=True, can_delete=True),
35
+ Kind("value-list", "value-lists", "ValueListCatalog", "ValueList", "ValueListReference", can_add=True, can_replace=True, can_delete=True),
36
+ Kind("custom-function", "custom-functions", "CustomFunctionsCatalog", "CustomFunction", "CustomFunctionReference",
37
+ can_add=True, can_replace=True, can_delete=True, object_list=True),
38
+ Kind("theme", "themes", "ThemeCatalog", "Theme", "LayoutThemeReference", can_replace=True),
39
+ Kind("table", "tables", "BaseTableCatalog", "BaseTable", "BaseTableReference"),
40
+ Kind("table-occurrence", "table-occurrences", "TableOccurrenceCatalog", "TableOccurrence", "TableOccurrenceReference"),
41
+ )
42
+ }
43
+ PLURALS = {k.plural: k for k in KINDS.values()}
44
+
45
+
46
+ @dataclass
47
+ class Item:
48
+ kind: str
49
+ id: str
50
+ name: str
51
+ uuid: str
52
+ xml: str
53
+
54
+
55
+ def kind(name: str) -> Kind:
56
+ found = KINDS.get(name) or PLURALS.get(name)
57
+ if not found:
58
+ raise PatchworkError(f"Unknown kind: {name}. Choose from {', '.join(KINDS)}, field.")
59
+ return found
60
+
61
+
62
+ def items(export: Export, kind_name: str) -> list[Item]:
63
+ k = kind(kind_name)
64
+ if k.name == "theme":
65
+ return [Item("theme", "", display or internal, uid, export.theme(internal or display).xml if uid else "")
66
+ for display, internal, uid in export.themes()]
67
+ try:
68
+ section = export.section(k.catalog)
69
+ except PatchworkError:
70
+ return []
71
+ found = []
72
+ for match in re.finditer(rf"<{k.tag} [^>]*>[\s\S]*?</{k.tag}>", section):
73
+ block = match.group(0)
74
+ head = block[: block.index(">")]
75
+ ident, name = re.search(r'\sid="(\d+)"', head), re.search(r'\sname="([^"]*)"', head)
76
+ if not ident or not name or "isFolder=" in head or 'isSeparatorItem="True"' in head:
77
+ continue
78
+ found.append(Item(k.name, ident.group(1), unescape(name.group(1)), own_uuid(block), block))
79
+ return found
80
+
81
+
82
+ def find(export: Export, kind_name: str, name: str) -> Item:
83
+ for item in items(export, kind_name):
84
+ if item.name == name:
85
+ return item
86
+ raise PatchworkError(f"{kind(kind_name).name} not found: {name}")
87
+
88
+
89
+ def own_uuid(block: str) -> str:
90
+ try:
91
+ return layout_uuid(block)
92
+ except PatchworkError:
93
+ found = re.search(r"<UUID[^>]*>([^<]*)</UUID>", block)
94
+ return found.group(1) if found else ""
95
+
96
+
97
+ def catalog_uuid(export: Export, catalog: str) -> str:
98
+ found = re.search(r"<UUID[^>]*>([^<]+)</UUID>", export.section(catalog))
99
+ if not found:
100
+ raise PatchworkError(f"{catalog} has no UUID. Run `patchwork open` again on the original file.")
101
+ return found.group(1)
102
+
103
+
104
+ def next_id(export: Export, k: Kind) -> int:
105
+ try:
106
+ section = export.section(k.catalog)
107
+ except PatchworkError:
108
+ return 1
109
+ return max((int(i) for i in re.findall(rf'<{k.tag} [^>]*?\bid="(\d+)"', section)), default=0) + 1
110
+
111
+
112
+ # ---------- show ----------
113
+
114
+ def script_steps(export: Export, name: str) -> tuple[str, str]:
115
+ """(ScriptReference tag, steps XML) of a script."""
116
+ script = find(export, "script", name)
117
+ steps = export.section("StepsForScripts")
118
+ match = re.search(
119
+ rf'<Script>\s*(<ScriptReference id="{script.id}" [^>]*></ScriptReference>)\s*(?:<ObjectList[^>]*>([\s\S]*?)</ObjectList>)?\s*</Script>',
120
+ steps,
121
+ )
122
+ if not match:
123
+ reference = f'<ScriptReference id="{script.id}" name="{escape(script.name)}" UUID="{script.uuid}"></ScriptReference>'
124
+ return reference, ""
125
+ return match.group(1), match.group(2) or ""
126
+
127
+
128
+ def show(export: Export, kind_name: str, name: str) -> str:
129
+ if kind_name == "field":
130
+ table, field = split_field(name)
131
+ _, _, catalog = export.field_catalog(table)
132
+ match = re.search(rf'<Field id="\d+" name="{re.escape(escape(field))}"[\s\S]*?</Field>', catalog)
133
+ if not match:
134
+ raise PatchworkError(f"Field not found: {name}")
135
+ return dedent(match.group(0))
136
+ if kind_name == "step":
137
+ match = re.search(rf'<Step [^>]*name="{re.escape(escape(name))}"[\s\S]*?</Step>', export.section("StepsForScripts"))
138
+ if not match:
139
+ raise PatchworkError(f"No script in this file uses the step {name!r}. Look at another file for an example.")
140
+ return dedent(match.group(0))
141
+ item = find(export, kind_name, name)
142
+ if item.kind == "script":
143
+ _, steps = script_steps(export, name)
144
+ return dedent(item.xml) + "\n" + dedent(steps)
145
+ return dedent(item.xml)
146
+
147
+
148
+ def dedent(xml: str) -> str:
149
+ lines = xml.strip("\n").split("\n")
150
+ tabs = [len(line) - len(line.lstrip("\t")) for line in lines[1:] if line.strip()]
151
+ cut = max(min(tabs, default=1) - 1, 0)
152
+ return "\n".join([lines[0]] + [line[cut:] if line[:cut].strip() == "" else line for line in lines[1:]])
153
+
154
+
155
+ # ---------- builders ----------
156
+
157
+ def new_uuid() -> str:
158
+ return str(uuid.uuid4()).upper()
159
+
160
+
161
+ def fresh_uuids(xml: str) -> str:
162
+ return re.sub(r"<UUID[^>]*>[^<]*</UUID>", lambda _: f"<UUID>{new_uuid()}</UUID>", xml)
163
+
164
+
165
+ def parse_steps(xml: str, start: int = 0) -> list[str]:
166
+ steps = re.findall(r"<Step\b[\s\S]*?</Step>", xml)
167
+ if not steps:
168
+ raise PatchworkError("No <Step> element found. Use `patchwork show script NAME` or `show step NAME` for examples.")
169
+ out = []
170
+ for offset, step in enumerate(steps):
171
+ head_end = step.index(">")
172
+ head = re.sub(r'\s(hash|index)="[^"]*"', "", step[:head_end])
173
+ head = head.replace("<Step", f'<Step index="{start + offset}"', 1)
174
+ body = step[head_end:]
175
+ if not re.search(r"<UUID[^>]*>", body):
176
+ body = body.replace(">", f">\n\t<UUID>{new_uuid()}</UUID>", 1)
177
+ out.append(fresh_uuids(head + body))
178
+ return out
179
+
180
+
181
+ def steps_action(reference: str, steps: list[str]) -> str:
182
+ return (
183
+ '\t\t\t<StepsForScripts membercount="1">\n\t\t\t\t<Script>\n'
184
+ f"\t\t\t\t\t{reference}\n"
185
+ f'\t\t\t\t\t<ObjectList membercount="{len(steps)}">\n' + "\n".join(steps) + "\n\t\t\t\t\t</ObjectList>\n"
186
+ "\t\t\t\t</Script>\n\t\t\t</StepsForScripts>\n"
187
+ )
188
+
189
+
190
+ def add_script(export: Export, name: str, steps_xml: str) -> str:
191
+ k = KINDS["script"]
192
+ if any(item.name == name for item in items(export, "script")):
193
+ raise PatchworkError(f"A script named {name!r} already exists.")
194
+ script_id, script_uuid = next_id(export, k), new_uuid()
195
+ steps = parse_steps(steps_xml) if steps_xml.strip() else []
196
+ reference = f'<ScriptReference id="{script_id}" name="{escape(name)}" UUID="{script_uuid}"></ScriptReference>'
197
+ action = (
198
+ "\t\t<AddAction>\n"
199
+ f'\t\t\t<ScriptCatalog membercount="1">\n\t\t\t\t<UUID>{catalog_uuid(export, k.catalog)}</UUID>\n\t\t\t\t<TagList></TagList>\n'
200
+ f'\t\t\t\t<Script id="{script_id}" name="{escape(name)}">\n\t\t\t\t\t<UUID>{script_uuid}</UUID>\n'
201
+ '\t\t\t\t\t<Options hidden="False" access="ReadWrite" SiriShortcutVisible="False" runwithfullaccess="False" compatibility="0">0</Options>\n'
202
+ "\t\t\t\t\t<TagList></TagList>\n\t\t\t\t</Script>\n\t\t\t</ScriptCatalog>\n"
203
+ + (steps_action(reference, steps) if steps else "")
204
+ + "\t\t</AddAction>\n"
205
+ )
206
+ return action
207
+
208
+
209
+ def append_steps(export: Export, script: str, steps_xml: str) -> str:
210
+ reference, existing = script_steps(export, script)
211
+ count = len(re.findall(r"<Step\b", existing))
212
+ return "\t\t<AddAction>\n" + steps_action(reference, parse_steps(steps_xml, count)) + "\t\t</AddAction>\n"
213
+
214
+
215
+ def set_steps(export: Export, script: str, steps_xml: str) -> str:
216
+ reference, existing = script_steps(export, script)
217
+ old = re.findall(r"<Step [^>]*>\s*<UUID[^>]*>([^<]+)</UUID>", existing)
218
+ deletes = "".join(
219
+ f'\t\t\t<ItemReference UUID="{uid}" type="ScriptStepReference">\n\t\t\t\t{reference}\n\t\t\t</ItemReference>\n' for uid in old
220
+ )
221
+ delete = f"\t\t<DeleteAction>\n{deletes}\t\t</DeleteAction>\n" if old else ""
222
+ return delete + "\t\t<AddAction>\n" + steps_action(reference, parse_steps(steps_xml)) + "\t\t</AddAction>\n"
223
+
224
+
225
+ def add_object(export: Export, kind_name: str, xml: str) -> str:
226
+ k = kind(kind_name)
227
+ if not k.can_add or k.name == "script":
228
+ raise PatchworkError(f"Adding a {k.name} this way is not supported." + (" Use add-script." if k.name == "script" else ""))
229
+ block = re.search(rf"<{k.tag}\b[\s\S]*</{k.tag}>", xml)
230
+ if not block:
231
+ raise PatchworkError(f"Expected a <{k.tag}> element. Use `patchwork show {k.name} NAME` for an example.")
232
+ obj = block.group(0)
233
+ name = re.search(r'\sname="([^"]*)"', obj[: obj.index(">")])
234
+ if not name:
235
+ raise PatchworkError(f"The <{k.tag}> needs a name attribute.")
236
+ if any(item.name == unescape(name.group(1)) for item in items(export, k.name)):
237
+ raise PatchworkError(f"A {k.name} named {unescape(name.group(1))!r} already exists.")
238
+ head_end = obj.index(">")
239
+ head = re.sub(r'\sid="\d*"', "", obj[:head_end]).replace(f"<{k.tag}", f'<{k.tag} id="{next_id(export, k)}"', 1)
240
+ obj = head + obj[head_end:]
241
+ if not re.search(r"<UUID[^>]*>", obj):
242
+ if k.name == "value-list":
243
+ obj = obj.replace(f"</{k.tag}>", f"\t<UUID>{new_uuid()}</UUID>\n\t<TagList></TagList>\n</{k.tag}>")
244
+ else:
245
+ obj = obj.replace(">", f">\n\t<UUID>{new_uuid()}</UUID>\n\t<TagList></TagList>", 1)
246
+ obj = fresh_uuids(obj)
247
+ inner = f'\t\t\t\t<ObjectList membercount="1">\n{obj}\n\t\t\t\t</ObjectList>\n' if k.object_list else f"{obj}\n"
248
+ return (
249
+ "\t\t<AddAction>\n"
250
+ f'\t\t\t<{k.catalog} membercount="1">\n\t\t\t\t<UUID>{catalog_uuid(export, k.catalog)}</UUID>\n\t\t\t\t<TagList></TagList>\n'
251
+ f"{inner}\t\t\t</{k.catalog}>\n\t\t</AddAction>\n"
252
+ )
253
+
254
+
255
+ def value_list_xml(name: str, values: list[str]) -> str:
256
+ text = "\r".join(values)
257
+ if "]]>" in text:
258
+ raise PatchworkError("Values cannot contain ']]>'.")
259
+ return (
260
+ f'<ValueList name="{escape(name)}">\n\t<Source value="Custom"></Source>\n'
261
+ f"\t<CustomValues>\n\t\t<Text><![CDATA[{text}]]></Text>\n\t</CustomValues>\n</ValueList>"
262
+ )
263
+
264
+
265
+ def custom_function_xml(name: str, params: list[str], calc: str) -> str:
266
+ if "]]>" in calc:
267
+ raise PatchworkError("The calculation cannot contain ']]>'.")
268
+ display = f"{name} ( {' ; '.join(params)} )" if params else name
269
+ parameters = ""
270
+ if params:
271
+ parameters = (f'\t<ObjectList membercount="{len(params)}">\n'
272
+ + "".join(f'\t\t<Parameter name="{escape(p)}"></Parameter>\n' for p in params)
273
+ + "\t</ObjectList>\n")
274
+ return (
275
+ f'<CustomFunction name="{escape(name)}" access="All">\n'
276
+ f"\t<Calculation>\n\t\t<Text><![CDATA[{calc}]]></Text>\n\t</Calculation>\n"
277
+ f"\t<Display>{escape(display)}</Display>\n{parameters}</CustomFunction>"
278
+ )
279
+
280
+
281
+ def replace_object(export: Export, kind_name: str, name: str, xml: str) -> str:
282
+ k = kind(kind_name)
283
+ if not k.can_replace:
284
+ raise PatchworkError(f"Replacing a {k.name} is not supported. Supported: {', '.join(n for n, v in KINDS.items() if v.can_replace)}.")
285
+ item = find(export, k.name, name)
286
+ block = re.search(rf"<{k.tag}\b[\s\S]*</{k.tag}>", xml)
287
+ if not block:
288
+ raise PatchworkError(f"Expected the whole <{k.tag}> element, as printed by `patchwork show {k.name} {name!r}`.")
289
+ if k.name == "theme" and not item.uuid:
290
+ raise PatchworkError("This theme has no UUID. Run `patchwork open` again on the original file.")
291
+ replace_type = "Layout" if k.name == "layout" else k.tag
292
+ return f'\t\t<ReplaceAction>\n\t\t\t<Replace type="{replace_type}" UUID="{item.uuid}">\n{block.group(0)}\n\t\t\t</Replace>\n\t\t</ReplaceAction>\n'
293
+
294
+
295
+ def delete_object(export: Export, kind_name: str, name: str) -> str:
296
+ if kind_name == "field":
297
+ table, field = split_field(name)
298
+ _, base_ref, _ = export.field_catalog(table)
299
+ _, field_uuid = export.field(table, field)
300
+ inner = f'\t\t\t<ItemReference UUID="{field_uuid}" type="FieldReference">\n\t\t\t\t{base_ref}\n\t\t\t</ItemReference>\n'
301
+ else:
302
+ k = kind(kind_name)
303
+ if not k.can_delete:
304
+ raise PatchworkError(f"Deleting a {k.name} is not supported.")
305
+ item = find(export, k.name, name)
306
+ inner = f'\t\t\t<ItemReference UUID="{item.uuid}" type="{k.reference}"></ItemReference>\n'
307
+ return f"\t\t<DeleteAction>\n{inner}\t\t</DeleteAction>\n"
308
+
309
+
310
+ def split_field(qualified: str) -> tuple[str, str]:
311
+ if "::" not in qualified:
312
+ raise PatchworkError(f"Expected a field as Table::Field, got: {qualified}")
313
+ table, name = qualified.split("::", 1)
314
+ return table, name
315
+
316
+
317
+ # ---------- diff ----------
318
+
319
+ def normalize(text: str) -> str:
320
+ text = re.sub(r"<FMSaveAsXML[^>]*>", "", text, count=1)
321
+ return re.sub(r"<UUID [^>]*>", "<UUID>", text)
322
+
323
+
324
+ def inventory(export: Export) -> dict[tuple[str, str], str]:
325
+ found: dict[tuple[str, str], str] = {}
326
+ for name in ("layout", "script", "value-list", "custom-function", "theme", "table"):
327
+ for item in items(export, name):
328
+ found[(name, item.name)] = normalize(item.xml)
329
+ try:
330
+ steps = export.section("StepsForScripts")
331
+ for match in re.finditer(r'<Script>\s*<ScriptReference id="\d+" name="([^"]*)"[\s\S]*?</Script>', steps):
332
+ key = ("script", unescape(match.group(1)))
333
+ found[key] = found.get(key, "") + normalize(match.group(0))
334
+ except PatchworkError:
335
+ pass
336
+ try:
337
+ fields = export.section("FieldsForTables")
338
+ for catalog in re.finditer(r"<FieldCatalog>[\s\S]*?</FieldCatalog>", fields):
339
+ table = re.search(r'<BaseTableReference id="\d+" name="([^"]*)"', catalog.group(0))
340
+ for field in re.finditer(r'<Field id="\d+" name="([^"]*)"[\s\S]*?</Field>', catalog.group(0)):
341
+ found[("field", f"{unescape(table.group(1)) if table else '?'}::{unescape(field.group(1))}")] = normalize(field.group(0))
342
+ except PatchworkError:
343
+ pass
344
+ return found
345
+
346
+
347
+ def diff(before: Export, after: Export) -> list[str]:
348
+ old, new = inventory(before), inventory(after)
349
+ changes = [f"+ {k} {n}" for (k, n) in new if (k, n) not in old]
350
+ changes += [f"- {k} {n}" for (k, n) in old if (k, n) not in new]
351
+ changes += [f"~ {k} {n}" for (k, n), v in new.items() if (k, n) in old and old[(k, n)] != v]
352
+ return sorted(changes, key=lambda c: (c[2:], c[0]))