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/cli.py ADDED
@@ -0,0 +1,352 @@
1
+ """The `patchwork` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import platform
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from patchwork import PatchworkError, __version__, ops
12
+ from patchwork.build import FIELD_TYPES
13
+ from patchwork.project import init_project
14
+ from patchwork.rebrand import Brand, rebrand
15
+ from patchwork.tools import PASSWORD_ENV, Runner, find_tool, require_copy, require_root, require_source
16
+ from patchwork.workspace import Workspace
17
+
18
+ EPILOG = f"""typical session:
19
+ patchwork open "Invoice.fmp12" --account Admin working copy v000 in .patchwork/
20
+ patchwork ls scripts
21
+ patchwork show script "Close Window"
22
+ patchwork add-field Contacts Status
23
+ patchwork add-value-list "Status" Active Archived
24
+ patchwork log
25
+ patchwork undo
26
+ patchwork save "Invoice v2.fmp12"
27
+
28
+ AI agents: `patchwork mcp` is an MCP server for Claude, Cursor, Codex and Antigravity. See docs/ai-agents.md.
29
+ The password is read from {PASSWORD_ENV}. Docs: https://github.com/Abdeltoto/patchwork-filemaker-mcp"""
30
+
31
+ KINDS_SHOW = ["layout", "script", "value-list", "custom-function", "theme", "field", "step"]
32
+
33
+
34
+ def main(argv: list[str] | None = None) -> int:
35
+ for stream in (sys.stdout, sys.stderr):
36
+ if hasattr(stream, "reconfigure"):
37
+ stream.reconfigure(errors="replace")
38
+ args = build_parser().parse_args(argv)
39
+ try:
40
+ return args.func(args) or 0
41
+ except PatchworkError as exc:
42
+ print(f"patchwork: {exc}", file=sys.stderr)
43
+ return 1
44
+ except KeyboardInterrupt:
45
+ return 130
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="patchwork",
51
+ description="Edit FileMaker files from code or from an AI agent, with Claris FMUpgradeTool. Always on a copy.",
52
+ epilog=EPILOG,
53
+ formatter_class=argparse.RawDescriptionHelpFormatter,
54
+ )
55
+ parser.add_argument("--version", action="version", version=f"patchwork {__version__}")
56
+ parser.add_argument("-C", "--project", type=Path, help="Project folder holding .patchwork/ (default: search upward from here).")
57
+ sub = parser.add_subparsers(dest="command", required=True, metavar="<command>")
58
+
59
+ p = sub.add_parser("open", help="Start a working copy of a closed .fmp12 (never modified).")
60
+ p.add_argument("source", type=Path)
61
+ p.add_argument("--account", required=True, help="Full Access account.")
62
+ p.add_argument("--no-password", action="store_true", help=f"The account has no password. Otherwise {PASSWORD_ENV} is read.")
63
+ p.add_argument("--force", action="store_true", help="Replace an existing workspace.")
64
+ p.set_defaults(func=cmd_open)
65
+
66
+ simple(sub, "status", "Current version and contents.", lambda ws, a: ops.status(ws))
67
+ simple(sub, "log", "Version history.", lambda ws, a: ops.log(ws))
68
+ simple(sub, "undo", "Drop the last version.", lambda ws, a: ops.undo(ws))
69
+
70
+ p = sub.add_parser("save", help="Write the current version to a new .fmp12.")
71
+ p.add_argument("dest", type=Path)
72
+ p.add_argument("--force", action="store_true")
73
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.save(ws, str(a.dest), a.force)))
74
+
75
+ p = sub.add_parser("ls", help="List objects: " + ", ".join(ops.LISTABLE) + ".")
76
+ p.add_argument("kind", nargs="?", default="", choices=["", *ops.LISTABLE], metavar="KIND")
77
+ p.add_argument("table", nargs="?", default="", help="For `ls fields TABLE`.")
78
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.list_objects(ws, a.kind, a.table)))
79
+
80
+ p = sub.add_parser("show", help="Print an object: a script as FileMaker text, anything else as XML (kind step: an example step by name).")
81
+ p.add_argument("kind", choices=KINDS_SHOW)
82
+ p.add_argument("name", help="Object name; Table::Field for a field.")
83
+ p.add_argument("--xml", action="store_true", help="A script as XML instead of text.")
84
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.show(ws, a.kind, a.name, a.xml)))
85
+
86
+ p = sub.add_parser("uses", help="What references an object, and possible mentions by name in calculations.")
87
+ p.add_argument("kind", choices=["layout", "script", "value-list", "custom-function", "theme", "table", "table-occurrence", "field"])
88
+ p.add_argument("name", help="Object name; Table::Field for a field.")
89
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.uses(ws, a.kind, a.name)))
90
+
91
+ p = sub.add_parser("palette", help="Colors a theme uses.")
92
+ p.add_argument("--theme", default="")
93
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.palette(ws, a.theme)))
94
+
95
+ p = edit(sub, "add-field", "Add a field.")
96
+ p.add_argument("table")
97
+ p.add_argument("name")
98
+ p.add_argument("--type", default="Text", choices=list(FIELD_TYPES))
99
+ p.add_argument("--calc", default="", help="Formula: the field becomes a calculation.")
100
+ p.add_argument("--global", dest="is_global", action="store_true")
101
+ p.add_argument("--comment", default="")
102
+ p.add_argument("--context", default="", help="Table occurrence of the calculation.")
103
+ p.add_argument("--image", default="", help="Embed a PNG as a global container calculation.")
104
+ p.add_argument("--image-width", type=int, default=0, help="Resize the PNG first (needs Pillow).")
105
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.add_field(ws, a.table, a.name, a.type, a.calc, a.is_global, a.comment, a.context,
106
+ a.image, a.image_width, a.message, a.dry_run)))
107
+
108
+ p = edit(sub, "add-value-list", "Add a custom value list.")
109
+ p.add_argument("name")
110
+ p.add_argument("values", nargs="+")
111
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.add_value_list(ws, a.name, a.values, a.message, a.dry_run)))
112
+
113
+ p = edit(sub, "add-custom-function", "Add a custom function.")
114
+ p.add_argument("name")
115
+ p.add_argument("--calc", required=True)
116
+ p.add_argument("--param", dest="params", action="append", default=[])
117
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.add_custom_function(ws, a.name, a.calc, a.params, a.message, a.dry_run)))
118
+
119
+ p = edit(sub, "add-script", "Create a script from FileMaker script text or <Step> XML (file, or - for stdin).")
120
+ p.add_argument("name")
121
+ p.add_argument("steps", nargs="?", default="")
122
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.add_script(ws, a.name, read(a.steps) if a.steps else "", a.message, a.dry_run)))
123
+
124
+ p = edit(sub, "set-steps", "Replace all steps of a script with text from `show script`, edited (file, or - for stdin).")
125
+ p.add_argument("script")
126
+ p.add_argument("steps")
127
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.set_steps(ws, a.script, read(a.steps), a.message, a.dry_run)))
128
+
129
+ p = edit(sub, "append-steps", "Add steps at the end of a script.")
130
+ p.add_argument("script")
131
+ p.add_argument("steps")
132
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.append_steps(ws, a.script, read(a.steps), a.message, a.dry_run)))
133
+
134
+ p = edit(sub, "place-field", "Put a field on a layout, copying the look and label of a field already there.")
135
+ p.add_argument("layout")
136
+ p.add_argument("field", help="Occurrence::Field, e.g. Clients::Status")
137
+ p.add_argument("--near", required=True, help="A field already on the layout. The new one goes below it (a row is inserted).")
138
+ p.add_argument("--label", default=None, help="Label text. Default: the field name.")
139
+ p.add_argument("--side", choices=["below", "right"], default="below")
140
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.place_field(ws, a.layout, a.field, a.near, a.label, a.side, a.message, a.dry_run)))
141
+
142
+ p = edit(sub, "add", "Add an object from XML: value-list or custom-function.")
143
+ p.add_argument("kind", choices=["value-list", "custom-function"])
144
+ p.add_argument("xml")
145
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.add(ws, a.kind, read(a.xml), a.message, a.dry_run)))
146
+
147
+ p = edit(sub, "replace", "Replace a whole object with edited XML from `show`.")
148
+ p.add_argument("kind", choices=["layout", "theme", "value-list", "custom-function"])
149
+ p.add_argument("name")
150
+ p.add_argument("xml")
151
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.replace(ws, a.kind, a.name, read(a.xml), a.message, a.dry_run)))
152
+
153
+ p = edit(sub, "delete", "Delete a script, layout, value-list, custom-function or field (Table::Field).")
154
+ p.add_argument("kind", choices=["script", "layout", "value-list", "custom-function", "field"])
155
+ p.add_argument("name")
156
+ p.add_argument("--force", action="store_true", help="Delete even if other objects reference it.")
157
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.delete(ws, a.kind, a.name, a.message, a.dry_run, a.force)))
158
+
159
+ p = edit(sub, "retheme", "Rewrite a theme: colors, fonts, name.")
160
+ p.add_argument("--theme", default="", help="Default: the theme most layouts use.")
161
+ p.add_argument("--colors", help='JSON file or inline map {"0091CE": "2F5D50"}')
162
+ p.add_argument("--fonts", help='JSON file or inline map {"HelveticaNeue,HelveticaNeue": "Georgia,Georgia"}')
163
+ p.add_argument("--rename", default="")
164
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.retheme(ws, a.theme, ops.load_json(a.colors), ops.load_json(a.fonts), a.rename,
165
+ a.message, a.dry_run)))
166
+
167
+ p = edit(sub, "recolor-layouts", "Rewrite layouts: local colors, fonts, a field swapped everywhere.")
168
+ p.add_argument("--colors")
169
+ p.add_argument("--fonts")
170
+ p.add_argument("--swap-field", nargs=2, metavar=("Table::Old", "Table::New"))
171
+ p.add_argument("--only", nargs="+", metavar="LAYOUT")
172
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.recolor_layouts(ws, ops.load_json(a.colors), ops.load_json(a.fonts), a.swap_field,
173
+ a.only, a.message, a.dry_run)))
174
+
175
+ p = edit(sub, "push", "Apply a hand-written FMUpgradeToolPatch file.")
176
+ p.add_argument("patch")
177
+ p.set_defaults(func=ws_cmd(lambda ws, a: ops.push(ws, read(a.patch), a.message, a.dry_run)))
178
+
179
+ p = sub.add_parser("rebrand", help="One-shot: apply a brand.json (colors, fonts, logo, theme name) file to file.")
180
+ p.add_argument("src", type=Path)
181
+ p.add_argument("dest", type=Path)
182
+ p.add_argument("--brand", type=Path, required=True)
183
+ p.add_argument("--account", required=True)
184
+ p.add_argument("--no-password", action="store_true")
185
+ p.add_argument("--force", action="store_true")
186
+ p.add_argument("--keep-work", action="store_true")
187
+ p.set_defaults(func=cmd_rebrand)
188
+
189
+ p = sub.add_parser("mcp", help="Run the MCP server (stdio) for Claude, Cursor, Codex, Antigravity.")
190
+ p.set_defaults(func=cmd_mcp)
191
+
192
+ p = sub.add_parser("init", help="Wire a folder, new or existing, for Claude Code, Cursor, Codex and Antigravity. Never overwrites.")
193
+ p.add_argument("directory", type=Path)
194
+ p.add_argument("--name", default="")
195
+ p.add_argument("--no-skills", action="store_true")
196
+ p.set_defaults(func=cmd_init)
197
+
198
+ p = sub.add_parser("doctor", help="Show which tools Patchwork can find.")
199
+ p.set_defaults(func=cmd_doctor)
200
+
201
+ tool_parser(sub)
202
+ return parser
203
+
204
+
205
+ def simple(sub, name: str, help_text: str, handler) -> None:
206
+ p = sub.add_parser(name, help=help_text)
207
+ p.set_defaults(func=ws_cmd(handler))
208
+
209
+
210
+ def edit(sub, name: str, help_text: str) -> argparse.ArgumentParser:
211
+ p = sub.add_parser(name, help=help_text)
212
+ p.add_argument("-m", "--message", default="", help="Note for the version log.")
213
+ p.add_argument("--dry-run", action="store_true", help="Write the patch to .patchwork/ without applying it.")
214
+ return p
215
+
216
+
217
+ def ws_cmd(handler):
218
+ def run(args: argparse.Namespace) -> int:
219
+ print(handler(Workspace.locate(args.project), args))
220
+ return 0
221
+ return run
222
+
223
+
224
+ def read(source: str) -> str:
225
+ if source == "-":
226
+ return sys.stdin.read()
227
+ path = Path(source)
228
+ if not path.is_file():
229
+ raise PatchworkError(f"File not found: {source}")
230
+ return path.read_text(encoding="utf-8-sig")
231
+
232
+
233
+ def cmd_open(args: argparse.Namespace) -> int:
234
+ print(ops.open_file(str(args.source), args.account, args.no_password, str(args.project) if args.project else None, args.force))
235
+ return 0
236
+
237
+
238
+ def cmd_rebrand(args: argparse.Namespace) -> int:
239
+ brand = Brand.load(args.brand)
240
+ report = rebrand(args.src, args.dest, brand, Runner(args.account, args.no_password, quiet=True), args.force, args.keep_work)
241
+ print(f"\nDone: {args.dest.resolve()}")
242
+ print(f" theme {report.theme}")
243
+ print(f" layouts {len(report.layouts)} rewritten")
244
+ if report.logo_refs is not None:
245
+ print(f" logo on {report.logo_refs} layout object(s)")
246
+ if args.keep_work:
247
+ print(f" work files {report.work}")
248
+ return 0
249
+
250
+
251
+ def cmd_mcp(args: argparse.Namespace) -> int:
252
+ from patchwork.mcp import serve
253
+
254
+ return serve(args.project)
255
+
256
+
257
+ def cmd_init(args: argparse.Namespace) -> int:
258
+ print(init_project(args.directory, args.name, skills=not args.no_skills).report())
259
+ return 0
260
+
261
+
262
+ def cmd_doctor(_args: argparse.Namespace) -> int:
263
+ upgrade, developer = find_tool("FMUpgradeTool"), find_tool("FMDeveloperTool")
264
+ rows = [
265
+ ("patchwork", __version__),
266
+ ("python", platform.python_version()),
267
+ ("FMUpgradeTool", str(upgrade) if upgrade else "missing - see docs/install-fmupgradetool.md"),
268
+ ("FMDeveloperTool", str(developer) if developer else "missing - ships next to FMUpgradeTool"),
269
+ ("Pillow (optional)", "installed" if has_pillow() else "missing - only needed to resize images"),
270
+ (PASSWORD_ENV, "set" if os.environ.get(PASSWORD_ENV) else "not set"),
271
+ ]
272
+ for name, value in rows:
273
+ print(f"{name:18} {value}")
274
+ return 0 if upgrade and developer else 2
275
+
276
+
277
+ def has_pillow() -> bool:
278
+ try:
279
+ import PIL # noqa: F401
280
+ except ImportError:
281
+ return False
282
+ return True
283
+
284
+
285
+ # ----- plumbing: direct access to the Claris tools -----
286
+
287
+ def tool_parser(sub) -> None:
288
+ tool = sub.add_parser("tool", help="Run FMUpgradeTool / FMDeveloperTool directly, file to file.")
289
+ tsub = tool.add_subparsers(dest="tool_command", required=True, metavar="<tool command>")
290
+
291
+ def creds(p: argparse.ArgumentParser) -> None:
292
+ p.add_argument("--account", required=True)
293
+ p.add_argument("--no-password", action="store_true")
294
+ p.add_argument("--force", action="store_true")
295
+
296
+ p = tsub.add_parser("guids", help="FMUpgradeTool --generateGUIDs into a copy.")
297
+ p.add_argument("src", type=Path)
298
+ p.add_argument("dest", type=Path)
299
+ creds(p)
300
+ p.set_defaults(func=lambda a: plumbing(a, "guids"))
301
+
302
+ p = tsub.add_parser("export", help="FMDeveloperTool --saveAsXML.")
303
+ p.add_argument("src", type=Path)
304
+ p.add_argument("out", type=Path)
305
+ creds(p)
306
+ p.set_defaults(func=lambda a: plumbing(a, "export"))
307
+
308
+ p = tsub.add_parser("validate", help="FMUpgradeTool --validatePatch.")
309
+ p.add_argument("src", type=Path)
310
+ p.add_argument("patch", type=Path)
311
+ creds(p)
312
+ p.set_defaults(func=lambda a: plumbing(a, "validate"))
313
+
314
+ p = tsub.add_parser("apply", help="FMUpgradeTool --update into a copy.")
315
+ p.add_argument("src", type=Path)
316
+ p.add_argument("patch", type=Path)
317
+ p.add_argument("dest", type=Path)
318
+ creds(p)
319
+ p.set_defaults(func=lambda a: plumbing(a, "apply"))
320
+
321
+ p = tsub.add_parser("new-file", help="FMUpgradeTool --generateDBFile from a complete FMSaveAsXML.")
322
+ p.add_argument("xml", type=Path)
323
+ p.set_defaults(func=lambda a: plumbing(a, "new-file"))
324
+
325
+
326
+ def plumbing(args: argparse.Namespace, what: str) -> int:
327
+ runner = Runner(getattr(args, "account", ""), getattr(args, "no_password", False))
328
+ if what == "new-file":
329
+ require_root(args.xml, "FMSaveAsXML")
330
+ runner.new_file(args.xml.resolve())
331
+ return 0
332
+ src = require_source(args.src)
333
+ if what == "guids":
334
+ runner.generate_guids(src, require_copy(src, args.dest, args.force), args.force)
335
+ elif what == "export":
336
+ out = args.out.resolve()
337
+ if out.exists() and not args.force:
338
+ raise PatchworkError(f"{out} already exists. Pass --force to replace it.")
339
+ out.parent.mkdir(parents=True, exist_ok=True)
340
+ runner.export_xml(src, out, args.force)
341
+ elif what == "validate":
342
+ require_root(args.patch, "FMUpgradeToolPatch")
343
+ runner.validate(src, args.patch.resolve())
344
+ print("Valid syntax. FMUpgradeTool does not check that the change will apply: use a workspace for that.")
345
+ else:
346
+ require_root(args.patch, "FMUpgradeToolPatch")
347
+ runner.apply(src, args.patch.resolve(), require_copy(src, args.dest, args.force), args.force)
348
+ return 0
349
+
350
+
351
+ if __name__ == "__main__":
352
+ sys.exit(main())
patchwork/export.py ADDED
@@ -0,0 +1,162 @@
1
+ """Read-only view of a FileMaker Save-as-XML export (root element FMSaveAsXML).
2
+
3
+ Every identifier a patch needs (catalog UUID, base table, table occurrence, layout UUID,
4
+ theme UUID, next field id) is read from here, so a patch always matches the file it targets.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections import Counter
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from patchwork import PatchworkError
15
+
16
+ GUIDS_HINT = "Run `patchwork guids` on the file first, then export it again."
17
+
18
+
19
+ @dataclass
20
+ class Block:
21
+ name: str
22
+ uuid: str
23
+ xml: str
24
+
25
+
26
+ class Export:
27
+ def __init__(self, text: str) -> None:
28
+ if "<FMSaveAsXML" not in text[:2000]:
29
+ raise PatchworkError("This is not a FileMaker Save-as-XML export (root FMSaveAsXML).")
30
+ self.text = text
31
+ head = re.search(r"<FMSaveAsXML[^>]*>", text[:2000])
32
+ self.version = (attr(head.group(0), "version") if head else None) or "2.3.0.0"
33
+
34
+ @classmethod
35
+ def load(cls, path: Path) -> "Export":
36
+ if not path.is_file():
37
+ raise PatchworkError(f"Export not found: {path}")
38
+ raw = path.read_bytes()
39
+ if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"):
40
+ return cls(raw.decode("utf-16"))
41
+ return cls(raw.decode("utf-8-sig"))
42
+
43
+ def section(self, tag: str) -> str:
44
+ start = self.text.find(f"<{tag}")
45
+ end = self.text.find(f"</{tag}>", start)
46
+ if start < 0 or end < 0:
47
+ raise PatchworkError(f"The export has no <{tag}> section.")
48
+ return self.text[start:end]
49
+
50
+ def layouts(self) -> list[Block]:
51
+ catalog = self.section("LayoutCatalog")
52
+ found = []
53
+ for match in re.finditer(r'<Layout id="\d+" name="([^"]*)"[^>]*>[\s\S]*?</Layout>', catalog):
54
+ block = match.group(0)
55
+ found.append(Block(unescape(match.group(1)), layout_uuid(block), block))
56
+ return found
57
+
58
+ def layout(self, name: str) -> Block:
59
+ for block in self.layouts():
60
+ if block.name == name:
61
+ return block
62
+ raise PatchworkError(f"Layout not found: {name}")
63
+
64
+ def themes(self) -> list[tuple[str, str, str]]:
65
+ """(display name, internal name, UUID or empty) for each theme in the file."""
66
+ found = []
67
+ for match in re.finditer(r"<Theme [^>]*>[\s\S]*?</Theme>", self.section("ThemeCatalog")):
68
+ block = match.group(0)
69
+ head = block[: block.index(">") + 1]
70
+ uid = re.search(r"<UUID[^>]*>([^<]*)</UUID>", block)
71
+ found.append((attr(head, "Display") or "", attr(head, "name") or "", uid.group(1).strip() if uid else ""))
72
+ return found
73
+
74
+ def theme(self, label: str) -> Block:
75
+ for match in re.finditer(r"<Theme [^>]*>[\s\S]*?</Theme>", self.section("ThemeCatalog")):
76
+ block = match.group(0)
77
+ head = block[: block.index(">") + 1]
78
+ display, internal = attr(head, "Display"), attr(head, "name")
79
+ if label not in (display, internal):
80
+ continue
81
+ found = re.search(r"<UUID[^>]*>([^<]*)</UUID>", block)
82
+ if not found or not found.group(1).strip():
83
+ raise PatchworkError(f"Theme {label} has no UUID. {GUIDS_HINT}")
84
+ return Block(display or internal or label, found.group(1), block)
85
+ raise PatchworkError(f"Theme not found: {label}")
86
+
87
+ def main_theme(self) -> str:
88
+ """Internal name of the theme used by the most layouts."""
89
+ names = re.findall(r'<LayoutThemeReference [^>]*name="([^"]*)"', self.section("LayoutCatalog"))
90
+ if not names:
91
+ raise PatchworkError("No layout references a theme. Pass the theme name explicitly.")
92
+ return Counter(names).most_common(1)[0][0]
93
+
94
+ def field_catalog(self, table: str) -> tuple[str, str, str]:
95
+ """(catalog UUID, BaseTableReference tag, catalog XML) for a base table."""
96
+ fields = self.section("FieldsForTables")
97
+ ref = re.search(
98
+ rf'<BaseTableReference id="\d+" name="{re.escape(escape(table))}" UUID="[^"]+"></BaseTableReference>', fields
99
+ )
100
+ if not ref:
101
+ raise PatchworkError(f"Base table not found: {table}")
102
+ start = fields.rfind("<FieldCatalog", 0, ref.start())
103
+ end = fields.find("</FieldCatalog>", ref.end())
104
+ catalog = fields[start:end]
105
+ found = re.search(r"<UUID[^>]*>([^<]+)</UUID>", catalog)
106
+ if not found:
107
+ raise PatchworkError(f"The field catalog of {table} has no UUID. {GUIDS_HINT}")
108
+ return found.group(1), ref.group(0), catalog
109
+
110
+ def field(self, table: str, name: str) -> tuple[str, str]:
111
+ """(id, UUID) of an existing field."""
112
+ _, _, catalog = self.field_catalog(table)
113
+ found = re.search(
114
+ rf'<Field id="(\d+)" name="{re.escape(escape(name))}"[^>]*>\s*<UUID[^>]*>([^<]+)</UUID>', catalog
115
+ )
116
+ if not found:
117
+ raise PatchworkError(f"Field not found: {table}::{name}")
118
+ return found.group(1), found.group(2)
119
+
120
+ def has_field(self, table: str, name: str) -> bool:
121
+ return f'name="{escape(name)}"' in self.field_catalog(table)[2]
122
+
123
+ def next_field_id(self, table: str) -> int:
124
+ _, _, catalog = self.field_catalog(table)
125
+ ids = [int(i) for i in re.findall(r'<Field id="(\d+)"', catalog)]
126
+ return max(ids, default=0) + 1
127
+
128
+ def table_occurrence(self, name: str) -> str:
129
+ found = re.search(
130
+ rf'<TableOccurrenceReference id="\d+" name="{re.escape(escape(name))}" UUID="[^"]+"></TableOccurrenceReference>',
131
+ self.text,
132
+ )
133
+ if not found:
134
+ raise PatchworkError(f"Table occurrence not found: {name}")
135
+ return found.group(0)
136
+
137
+ def field_references(self, field_uuid: str) -> int:
138
+ return len(re.findall(rf'<FieldReference [^>]*UUID="{re.escape(field_uuid)}"', self.section("LayoutCatalog")))
139
+
140
+
141
+ def attr(tag: str, name: str) -> str | None:
142
+ found = re.search(rf'\s{name}="([^"]*)"', tag)
143
+ return unescape(found.group(1)) if found else None
144
+
145
+
146
+ def layout_uuid(block: str) -> str:
147
+ """The layout's own UUID: the direct child of <Layout>, not the first <UUID> (an object's)."""
148
+ indent = re.search(r"\n(\t+)<", block)
149
+ if not indent:
150
+ raise PatchworkError("Layout block without children.")
151
+ found = re.search(rf"\n{indent.group(1)}<UUID[^>]*>([^<]+)</UUID>", block)
152
+ if not found:
153
+ raise PatchworkError(f"Layout UUID not found. {GUIDS_HINT}")
154
+ return found.group(1)
155
+
156
+
157
+ def escape(value: str) -> str:
158
+ return value.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;")
159
+
160
+
161
+ def unescape(value: str) -> str:
162
+ return value.replace("&lt;", "<").replace("&quot;", '"').replace("&amp;", "&")