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/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("&", "&").replace('"', """).replace("<", "<")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def unescape(value: str) -> str:
|
|
162
|
+
return value.replace("<", "<").replace(""", '"').replace("&", "&")
|