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/ops.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Every workspace operation, shared by the CLI and the MCP server. Each returns plain text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from patchwork import PatchworkError, objects, place, script
|
|
11
|
+
from patchwork.build import FIELD_TYPES, add_field_action, field_xml, image_calc, layouts_patch, palette as theme_palette, theme_replace
|
|
12
|
+
from patchwork.export import Export, unescape
|
|
13
|
+
from patchwork.uses import find_uses
|
|
14
|
+
from patchwork.workspace import Result, Workspace
|
|
15
|
+
|
|
16
|
+
LISTABLE = ("layouts", "scripts", "value-lists", "custom-functions", "themes", "tables", "table-occurrences", "fields")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def open_file(source: str, account: str, no_password: bool = False, project: str | None = None, force: bool = False) -> str:
|
|
20
|
+
ws = Workspace.create(Path(source), account, no_password, Path(project) if project else None, force)
|
|
21
|
+
return f"Opened {Path(source).name} as v000 in {ws.root}\n" + status(ws)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def status(ws: Workspace) -> str:
|
|
25
|
+
export = ws.export
|
|
26
|
+
counts = ", ".join(f"{len(objects.items(export, k))} {k}" for k in ("layouts", "scripts", "value-lists", "custom-functions"))
|
|
27
|
+
tables = objects.items(export, "tables")
|
|
28
|
+
last = ws.log()[-1]
|
|
29
|
+
return (
|
|
30
|
+
f"source {ws.state['source']}\n"
|
|
31
|
+
f"version v{ws.version:03d} ({last['message']}, {last['time']})\n"
|
|
32
|
+
f"account {ws.state['account']}\n"
|
|
33
|
+
f"contents {len(tables)} tables, {counts}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def list_objects(ws: Workspace, kind: str = "", table: str = "") -> str:
|
|
38
|
+
export = ws.export
|
|
39
|
+
if kind in ("", "all"):
|
|
40
|
+
return "\n".join(f"{k}: {len(objects.items(export, k))}" for k in LISTABLE if k != "fields") + "\nfields: use `ls fields TABLE`"
|
|
41
|
+
if kind in ("fields", "field"):
|
|
42
|
+
if not table:
|
|
43
|
+
raise PatchworkError("Name the table: ls fields TABLE")
|
|
44
|
+
_, _, catalog = export.field_catalog(table)
|
|
45
|
+
rows = re.findall(r'<Field id="\d+" name="([^"]*)" fieldtype="([^"]*)" datatype="([^"]*)"', catalog)
|
|
46
|
+
label = {"Binary": "Container", "TimeStamp": "Timestamp"}
|
|
47
|
+
return "\n".join(f"{unescape(name)}\t{label.get(datatype, datatype)}{' (calc)' if ftype == 'Calculated' else ''}"
|
|
48
|
+
for name, ftype, datatype in rows)
|
|
49
|
+
return "\n".join(item.name for item in objects.items(export, kind))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def show(ws: Workspace, kind: str, name: str, xml: bool = False) -> str:
|
|
53
|
+
if kind == "script" and not xml:
|
|
54
|
+
return script.to_text(objects.script_steps(ws.export, name)[1]) or f"{name} has no steps."
|
|
55
|
+
return objects.show(ws.export, kind, name)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def describe(result: Result) -> str:
|
|
59
|
+
lines = [f"v{result.version:03d} {result.message}"] + [f" {c}" for c in result.changes[:40]]
|
|
60
|
+
if len(result.changes) > 40:
|
|
61
|
+
lines.append(f" ... and {len(result.changes) - 40} more")
|
|
62
|
+
return "\n".join(lines)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run(ws: Workspace, actions, message: str, dry_run: bool, expect: str | None = None,
|
|
66
|
+
check: Callable[[Export], str | None] | None = None) -> str:
|
|
67
|
+
result = ws.apply(actions, message, dry_run=dry_run)
|
|
68
|
+
if dry_run:
|
|
69
|
+
return f"Dry run: patch written to {result.patch}"
|
|
70
|
+
if expect and not any(c.startswith(expect) for c in result.changes):
|
|
71
|
+
ws.undo()
|
|
72
|
+
raise PatchworkError(f"FMUpgradeTool reported success but `{expect}` is missing from the result. Version dropped.")
|
|
73
|
+
problem = check(ws.export) if check else None
|
|
74
|
+
if problem:
|
|
75
|
+
ws.undo()
|
|
76
|
+
raise PatchworkError(f"FMUpgradeTool applied the patch but {problem}. Version dropped.")
|
|
77
|
+
return describe(result)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def lines(text: str) -> list[str]:
|
|
81
|
+
return re.split(r"\r\n|\r|\n", text)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_value_list(name: str, values: list[str]) -> Callable[[Export], str | None]:
|
|
85
|
+
def check(export: Export) -> str | None:
|
|
86
|
+
found = re.search(r"<CustomValues>\s*<Text><!\[CDATA\[([\s\S]*?)\]\]>", objects.find(export, "value-list", name).xml)
|
|
87
|
+
stored = lines(found.group(1)) if found else []
|
|
88
|
+
return None if stored == values else f"the value list holds {stored} instead of {values}"
|
|
89
|
+
return check
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_custom_function(name: str, calc: str, params: list[str]) -> Callable[[Export], str | None]:
|
|
93
|
+
def check(export: Export) -> str | None:
|
|
94
|
+
xml = objects.find(export, "custom-function", name).xml
|
|
95
|
+
found = re.search(r"<Calculation>\s*<Text><!\[CDATA\[([\s\S]*?)\]\]>", xml)
|
|
96
|
+
if not found or lines(found.group(1)) != lines(calc):
|
|
97
|
+
return "the formula is missing from the result (FileMaker rejected the calculation)"
|
|
98
|
+
stored = [unescape(p) for p in re.findall(r'<Parameter name="([^"]*)"', xml)]
|
|
99
|
+
return None if stored == params else f"the parameters are {stored} instead of {params}"
|
|
100
|
+
return check
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def add_field(ws: Workspace, table: str, name: str, type: str = "Text", calc: str = "", is_global: bool = False,
|
|
104
|
+
comment: str = "", context: str = "", image: str = "", image_width: int = 0, message: str = "", dry_run: bool = False) -> str:
|
|
105
|
+
if type not in FIELD_TYPES:
|
|
106
|
+
raise PatchworkError(f"Unknown field type: {type}. Choose from {', '.join(FIELD_TYPES)}.")
|
|
107
|
+
if image:
|
|
108
|
+
calc, type, is_global = image_calc(Path(image), image_width or None), "Container", True
|
|
109
|
+
xml = field_xml(ws.export, table, name, type, comment, is_global, calc or None, context or None)
|
|
110
|
+
return run(ws, add_field_action(ws.export, table, xml), message or f"add field {table}::{name}", dry_run, f"+ field {table}::{name}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def add_value_list(ws: Workspace, name: str, values: list[str], message: str = "", dry_run: bool = False) -> str:
|
|
114
|
+
action = objects.add_object(ws.export, "value-list", objects.value_list_xml(name, values))
|
|
115
|
+
return run(ws, action, message or f"add value list {name}", dry_run, f"+ value-list {name}", check_value_list(name, values))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def add_custom_function(ws: Workspace, name: str, calc: str, params: list[str] | None = None, message: str = "", dry_run: bool = False) -> str:
|
|
119
|
+
action = objects.add_object(ws.export, "custom-function", objects.custom_function_xml(name, params or [], calc))
|
|
120
|
+
return run(ws, action, message or f"add custom function {name}", dry_run, f"+ custom-function {name}",
|
|
121
|
+
check_custom_function(name, calc, params or []))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def steps_xml(export: Export, steps: str, existing: str = "") -> str:
|
|
125
|
+
"""<Step> XML from FileMaker script text, or the XML itself."""
|
|
126
|
+
return steps if script.is_xml(steps) else script.build(export, steps, existing)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def check_steps(name: str, want: list[str]) -> Callable[[Export], str | None]:
|
|
130
|
+
def check(export: Export) -> str | None:
|
|
131
|
+
got = script.normalized(objects.script_steps(export, name)[1])
|
|
132
|
+
for i, (w, g) in enumerate(zip(want, got), 1):
|
|
133
|
+
if w != g:
|
|
134
|
+
return f"step {i} came back as `{g}` instead of `{w}` (FileMaker dropped or changed a parameter)"
|
|
135
|
+
if len(want) != len(got):
|
|
136
|
+
return f"the script has {len(got)} steps instead of {len(want)}"
|
|
137
|
+
return None
|
|
138
|
+
return check
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def add_script(ws: Workspace, name: str, steps: str = "", message: str = "", dry_run: bool = False) -> str:
|
|
142
|
+
xml = steps_xml(ws.export, steps) if steps.strip() else ""
|
|
143
|
+
return run(ws, objects.add_script(ws.export, name, xml), message or f"add script {name}", dry_run, f"+ script {name}",
|
|
144
|
+
check_steps(name, script.normalized(xml)))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def add(ws: Workspace, kind: str, xml: str, message: str = "", dry_run: bool = False) -> str:
|
|
148
|
+
action = objects.add_object(ws.export, kind, xml)
|
|
149
|
+
name = re.search(r'\sname="([^"]*)"', xml)
|
|
150
|
+
return run(ws, action, message or f"add {kind} {name.group(1) if name else ''}".strip(), dry_run)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def set_steps(ws: Workspace, name: str, steps: str, message: str = "", dry_run: bool = False) -> str:
|
|
154
|
+
_, existing = objects.script_steps(ws.export, name)
|
|
155
|
+
xml = steps_xml(ws.export, steps, existing)
|
|
156
|
+
return run(ws, objects.set_steps(ws.export, name, xml), message or f"rewrite steps of {name}", dry_run, f"~ script {name}",
|
|
157
|
+
check_steps(name, script.normalized(xml)))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def append_steps(ws: Workspace, name: str, steps: str, message: str = "", dry_run: bool = False) -> str:
|
|
161
|
+
_, existing = objects.script_steps(ws.export, name)
|
|
162
|
+
xml = steps_xml(ws.export, steps)
|
|
163
|
+
return run(ws, objects.append_steps(ws.export, name, xml), message or f"append steps to {name}", dry_run, f"~ script {name}",
|
|
164
|
+
check_steps(name, script.normalized(existing) + script.normalized(xml)))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def replace(ws: Workspace, kind: str, name: str, xml: str, message: str = "", dry_run: bool = False) -> str:
|
|
168
|
+
return run(ws, objects.replace_object(ws.export, kind, name, xml), message or f"edit {kind} {name}", dry_run)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def place_field(ws: Workspace, layout: str, field: str, near: str, label: str | None = None, side: str = "below",
|
|
172
|
+
message: str = "", dry_run: bool = False) -> str:
|
|
173
|
+
xml, field_uuid = place.place_field(ws.export, layout, field, near, label, side)
|
|
174
|
+
|
|
175
|
+
def check(export: Export) -> str | None:
|
|
176
|
+
found = re.search(rf'<FieldReference [^>]*\bUUID="{field_uuid}"', export.layout(layout).xml)
|
|
177
|
+
return None if found else f"{field} is not on {layout!r} in the result"
|
|
178
|
+
|
|
179
|
+
return run(ws, objects.replace_object(ws.export, "layout", layout, xml), message or f"place {field} on {layout}",
|
|
180
|
+
dry_run, f"~ layout {layout}", check)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def uses(ws: Workspace, kind: str, name: str) -> str:
|
|
184
|
+
return find_uses(ws.export, kind, name).report()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def delete(ws: Workspace, kind: str, name: str, message: str = "", dry_run: bool = False, force: bool = False) -> str:
|
|
188
|
+
found = find_uses(ws.export, kind, name)
|
|
189
|
+
if found.references and not force:
|
|
190
|
+
raise PatchworkError(f"{found.report()}\nNot deleted: these would point at nothing. Check them first, "
|
|
191
|
+
"then delete with --force (MCP: force=true). `undo` brings it back either way.")
|
|
192
|
+
result = run(ws, objects.delete_object(ws.export, kind, name), message or f"delete {kind} {name}", dry_run, f"- {kind} {name}")
|
|
193
|
+
if found.references or found.mentions:
|
|
194
|
+
result += f"\nWarning, review these: {found.report()}"
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def retheme(ws: Workspace, theme: str = "", colors: dict | None = None, fonts: dict | None = None, rename: str = "",
|
|
199
|
+
message: str = "", dry_run: bool = False) -> str:
|
|
200
|
+
export = ws.export
|
|
201
|
+
label = theme or export.main_theme()
|
|
202
|
+
action = theme_replace(export, label, rename or None, colors or {}, fonts or {})
|
|
203
|
+
return run(ws, action, message or f"edit theme {export.theme(label).name}", dry_run)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def recolor_layouts(ws: Workspace, colors: dict | None = None, fonts: dict | None = None, swap_field: list[str] | None = None,
|
|
207
|
+
only: list[str] | None = None, message: str = "", dry_run: bool = False) -> str:
|
|
208
|
+
patch, names = layouts_patch(ws.export, only or None, colors or {}, fonts or {}, tuple(swap_field) if swap_field else None)
|
|
209
|
+
if not names:
|
|
210
|
+
raise PatchworkError("No layout would change.")
|
|
211
|
+
return run(ws, patch, message or f"edit {len(names)} layout(s)", dry_run)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def push(ws: Workspace, patch_xml: str, message: str = "", dry_run: bool = False) -> str:
|
|
215
|
+
if "<FMUpgradeToolPatch" not in patch_xml:
|
|
216
|
+
raise PatchworkError("Expected a full patch (root <FMUpgradeToolPatch>).")
|
|
217
|
+
return run(ws, patch_xml, message or "push patch", dry_run)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def palette(ws: Workspace, theme: str = "") -> str:
|
|
221
|
+
block = ws.export.theme(theme or ws.export.main_theme())
|
|
222
|
+
return f"{block.name}\n" + "\n".join(f"{color} {count}x" for color, count in theme_palette(block.xml))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def undo(ws: Workspace) -> str:
|
|
226
|
+
return f"Back to v{ws.undo():03d}"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def log(ws: Workspace) -> str:
|
|
230
|
+
lines = []
|
|
231
|
+
for entry in ws.log():
|
|
232
|
+
lines.append(f"v{entry['n']:03d} {entry['time']} {entry['message']}")
|
|
233
|
+
lines += [f" {c}" for c in entry.get("changes", [])[:10]]
|
|
234
|
+
return "\n".join(lines)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def save(ws: Workspace, dest: str, force: bool = False) -> str:
|
|
238
|
+
return f"Saved v{ws.version:03d} to {ws.save(Path(dest), force)}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_json(value: str | dict | None) -> dict:
|
|
242
|
+
if not value:
|
|
243
|
+
return {}
|
|
244
|
+
if isinstance(value, dict):
|
|
245
|
+
return value
|
|
246
|
+
path = Path(value)
|
|
247
|
+
if path.is_file():
|
|
248
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
249
|
+
return json.loads(value)
|
patchwork/place.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Put a field on a layout by copying one that is already there, with its label.
|
|
2
|
+
|
|
3
|
+
FMUpgradeTool skips a lone LayoutObject add or replace, so the whole layout is replaced.
|
|
4
|
+
Bounds in a Save-as-XML export are absolute (from the top of the layout), and a field
|
|
5
|
+
points at its label with <Accessibility><Label>ID</Label>.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from patchwork import PatchworkError, objects
|
|
14
|
+
from patchwork.export import Export, escape, unescape
|
|
15
|
+
|
|
16
|
+
OBJECT = re.compile(r"(?m)^(\t*)<LayoutObject [^>]*>[\s\S]*?^\1</LayoutObject>")
|
|
17
|
+
BOUNDS = re.compile(r'<Bounds top="(-?[\d.]+)" left="(-?[\d.]+)" bottom="(-?[\d.]+)" right="(-?[\d.]+)"></Bounds>')
|
|
18
|
+
GAP = 10
|
|
19
|
+
SLACK = 4
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Box:
|
|
24
|
+
top: float
|
|
25
|
+
left: float
|
|
26
|
+
bottom: float
|
|
27
|
+
right: float
|
|
28
|
+
|
|
29
|
+
def moved(self, dy: float, dx: float) -> "Box":
|
|
30
|
+
return Box(self.top + dy, self.left + dx, self.bottom + dy, self.right + dx)
|
|
31
|
+
|
|
32
|
+
def grown(self, dy: float) -> "Box":
|
|
33
|
+
return Box(self.top, self.left, self.bottom + dy, self.right)
|
|
34
|
+
|
|
35
|
+
def union(self, other: "Box") -> "Box":
|
|
36
|
+
return Box(min(self.top, other.top), min(self.left, other.left), max(self.bottom, other.bottom), max(self.right, other.right))
|
|
37
|
+
|
|
38
|
+
def overlaps(self, other: "Box") -> bool:
|
|
39
|
+
return self.top < other.bottom and other.top < self.bottom and self.left < other.right and other.left < self.right
|
|
40
|
+
|
|
41
|
+
def tag(self) -> str:
|
|
42
|
+
return f'<Bounds top="{num(self.top)}" left="{num(self.left)}" bottom="{num(self.bottom)}" right="{num(self.right)}"></Bounds>'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Obj:
|
|
47
|
+
start: int
|
|
48
|
+
end: int
|
|
49
|
+
xml: str
|
|
50
|
+
id: str
|
|
51
|
+
box: Box
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def num(value: float) -> str:
|
|
55
|
+
return str(int(value)) if value == int(value) else f"{value:g}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def own_bounds(xml: str) -> Box:
|
|
59
|
+
found = BOUNDS.search(xml)
|
|
60
|
+
if not found:
|
|
61
|
+
raise PatchworkError("Layout object without bounds.")
|
|
62
|
+
return Box(*(float(v) for v in found.groups()))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def top_level(part: str, offset: int) -> list[Obj]:
|
|
66
|
+
"""Objects directly in a part (not inside button bars, portals or groups)."""
|
|
67
|
+
found = []
|
|
68
|
+
depth = None
|
|
69
|
+
for match in OBJECT.finditer(part):
|
|
70
|
+
indent = len(match.group(1))
|
|
71
|
+
depth = indent if depth is None else min(depth, indent)
|
|
72
|
+
for match in OBJECT.finditer(part):
|
|
73
|
+
if len(match.group(1)) == depth:
|
|
74
|
+
xml = match.group(0)
|
|
75
|
+
ident = re.search(r'\sid="(\d+)"', xml[: xml.index(">")])
|
|
76
|
+
found.append(Obj(offset + match.start(), offset + match.end(), xml, ident.group(1) if ident else "", own_bounds(xml)))
|
|
77
|
+
return found
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def field_target(export: Export, field: str) -> tuple[str, str, str, str]:
|
|
81
|
+
"""(field id, field name, field UUID, TableOccurrenceReference) for TO::Field."""
|
|
82
|
+
occurrence, name = objects.split_field(field)
|
|
83
|
+
to = objects.find(export, "table-occurrence", occurrence)
|
|
84
|
+
base = re.search(r'<BaseTableReference [^>]*\bname="([^"]*)"', to.xml)
|
|
85
|
+
if not base:
|
|
86
|
+
raise PatchworkError(f"Table occurrence {occurrence} has no base table in the export.")
|
|
87
|
+
field_id, field_uuid = export.field(unescape(base.group(1)), name)
|
|
88
|
+
return field_id, name, field_uuid, export.table_occurrence(occurrence)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def place_field(export: Export, layout: str, field: str, near: str, label: str | None = None,
|
|
92
|
+
side: str = "below") -> tuple[str, str]:
|
|
93
|
+
"""(whole new layout XML, field UUID). `near` is a field already on the layout, as Name or TO::Name."""
|
|
94
|
+
block = export.layout(layout)
|
|
95
|
+
xml = block.xml
|
|
96
|
+
field_id, field_name, field_uuid, to_ref = field_target(export, field)
|
|
97
|
+
near_name = near.split("::", 1)[-1]
|
|
98
|
+
|
|
99
|
+
parts = list(re.finditer(r"<Part type=\"([^\"]*)\"[\s\S]*?</Part>", xml))
|
|
100
|
+
for part in parts:
|
|
101
|
+
objs = top_level(part.group(0), part.start())
|
|
102
|
+
anchor = next((o for o in objs if re.search(rf'<FieldReference [^>]*\bname="{re.escape(escape(near_name))}"', o.xml)
|
|
103
|
+
and (near_name == near or f'name="{escape(near.split("::")[0])}"' in o.xml)), None)
|
|
104
|
+
if anchor:
|
|
105
|
+
break
|
|
106
|
+
else:
|
|
107
|
+
raise PatchworkError(f"No field {near!r} directly on layout {layout!r}. Name a field that is on it, e.g. one from `patchwork show layout`.")
|
|
108
|
+
if re.search(rf'<FieldReference [^>]*\bUUID="{field_uuid}"', part.group(0)):
|
|
109
|
+
raise PatchworkError(f"{field} is already on {layout!r}.")
|
|
110
|
+
|
|
111
|
+
label_id = re.search(r"<Accessibility>\s*<Label>(\d+)</Label>", anchor.xml)
|
|
112
|
+
caption = next((o for o in objs if label_id and o.id == label_id.group(1) and 'type="Text"' in o.xml[:200]), None)
|
|
113
|
+
group = anchor.box.union(caption.box) if caption else anchor.box
|
|
114
|
+
if side == "below":
|
|
115
|
+
line = group.bottom - SLACK
|
|
116
|
+
under = [o.box.top - group.bottom for o in objs
|
|
117
|
+
if o.box.top >= line and o.box.left < group.right and group.left < o.box.right]
|
|
118
|
+
gap = min(under) if under and 0 <= min(under) <= 40 else GAP
|
|
119
|
+
dy, dx = group.bottom - group.top + gap, 0.0
|
|
120
|
+
elif side == "right":
|
|
121
|
+
line, dy, dx = None, 0.0, group.right - group.left + GAP
|
|
122
|
+
else:
|
|
123
|
+
raise PatchworkError("side is below or right.")
|
|
124
|
+
|
|
125
|
+
placed = group.moved(dy, dx)
|
|
126
|
+
width = float(re.search(r'<Layout [^>]*\bwidth="([\d.]+)"', xml).group(1))
|
|
127
|
+
if placed.right > width:
|
|
128
|
+
raise PatchworkError(f"No room right of {near!r}: it would go past the edge of the layout. Use side=below.")
|
|
129
|
+
staying = [o for o in objs if line is None or o.box.top < line]
|
|
130
|
+
blocking = [o for o in staying if o.box.overlaps(placed) and not stretches(o)]
|
|
131
|
+
if blocking:
|
|
132
|
+
names = [re.search(r'<FieldReference [^>]*\bname="([^"]*)"', o.xml) for o in blocking]
|
|
133
|
+
what = ", ".join(f"field {n.group(1)}" if n else f"object {o.id}" for n, o in zip(names, blocking))
|
|
134
|
+
raise PatchworkError(f"No room {side} {near!r}: it would cover {what}. "
|
|
135
|
+
f"Try side={'right' if side == 'below' else 'below'} or another field.")
|
|
136
|
+
|
|
137
|
+
next_id = max((int(i) for i in re.findall(r'<LayoutObject [^>]*?\bid="(\d+)"', export.section("LayoutCatalog"))), default=0) + 1
|
|
138
|
+
new_field = copy(anchor.xml, str(next_id), dy, dx)
|
|
139
|
+
new_field = re.sub(r"<FieldReference [^>]*>[\s\S]*?</FieldReference>",
|
|
140
|
+
f'<FieldReference id="{field_id}" name="{escape(field_name)}" repetition="1" UUID="{field_uuid}">'
|
|
141
|
+
f"\n{indent_of(anchor.xml, 'TableOccurrenceReference')}{to_ref}\n{indent_of(anchor.xml, 'FieldReference')}</FieldReference>",
|
|
142
|
+
new_field, count=1)
|
|
143
|
+
new_field = re.sub(r"\s*<TabOrder [\s\S]*?</TabOrder>", "", new_field)
|
|
144
|
+
added = [new_field]
|
|
145
|
+
if caption:
|
|
146
|
+
text = escape_cdata(label if label is not None else field_name)
|
|
147
|
+
new_caption = copy(caption.xml, str(next_id + 1), dy, dx)
|
|
148
|
+
new_caption = re.sub(r"<Data><!\[CDATA\[[\s\S]*?\]\]></Data>", lambda _: f"<Data><![CDATA[{text}]]></Data>", new_caption, count=1)
|
|
149
|
+
new_field = new_field.replace(f"<Label>{caption.id}</Label>", f"<Label>{next_id + 1}</Label>")
|
|
150
|
+
added = [new_caption, new_field]
|
|
151
|
+
|
|
152
|
+
edits = [(anchor.end, anchor.end, "".join(f"\n{a}" for a in added))]
|
|
153
|
+
if line is not None:
|
|
154
|
+
for o in objs:
|
|
155
|
+
if o.box.top >= line:
|
|
156
|
+
edits.append((o.start, o.end, shift(o.xml, dy)))
|
|
157
|
+
elif o.box.bottom > line and stretches(o):
|
|
158
|
+
edits.append((o.start, o.end, BOUNDS.sub(lambda m: Box(*(float(v) for v in m.groups())).grown(dy).tag(), o.xml, count=1)))
|
|
159
|
+
definition = re.search(r'<Definition [^>]*\bsize="([\d.]+)"', part.group(0))
|
|
160
|
+
if definition:
|
|
161
|
+
at = part.start() + definition.start(1)
|
|
162
|
+
edits.append((at, at + len(definition.group(1)), num(float(definition.group(1)) + dy)))
|
|
163
|
+
for later in parts[parts.index(part) + 1:]:
|
|
164
|
+
moved = BOUNDS.sub(lambda m: Box(*(float(v) for v in m.groups())).moved(dy, 0).tag(), later.group(0))
|
|
165
|
+
moved = re.sub(r'(<Definition [^>]*\babsolute=")([\d.]+)', lambda m: m.group(1) + num(float(m.group(2)) + dy), moved, count=1)
|
|
166
|
+
edits.append((later.start(), later.end(), moved))
|
|
167
|
+
for start, end, text in sorted(edits, key=lambda e: e[0], reverse=True):
|
|
168
|
+
xml = xml[:start] + text + xml[end:]
|
|
169
|
+
count = re.compile(r'(<ObjectList membercount=")(\d+)(">)')
|
|
170
|
+
first = count.search(xml, part.start())
|
|
171
|
+
xml = xml[: first.start(2)] + str(int(first.group(2)) + len(added)) + xml[first.end(2):]
|
|
172
|
+
return xml, field_uuid
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def stretches(o: Obj) -> bool:
|
|
176
|
+
"""Background shapes grow with the row instead of moving."""
|
|
177
|
+
return bool(re.match(r'\t*<LayoutObject [^>]*\btype="(Rectangle|Rounded Rectangle)"', o.xml))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def shift(xml: str, dy: float) -> str:
|
|
181
|
+
return BOUNDS.sub(lambda m: Box(*(float(v) for v in m.groups())).moved(dy, 0).tag(), xml)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def copy(xml: str, new_id: str, dy: float, dx: float) -> str:
|
|
185
|
+
head_end = xml.index(">")
|
|
186
|
+
head = re.sub(r'\shash="[^"]*"', "", xml[:head_end])
|
|
187
|
+
head = re.sub(r'\sid="\d+"', f' id="{new_id}"', head, count=1)
|
|
188
|
+
body = objects.fresh_uuids(xml[head_end:])
|
|
189
|
+
body = BOUNDS.sub(lambda m: Box(*(float(v) for v in m.groups())).moved(dy, dx).tag(), body)
|
|
190
|
+
return head + body
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def indent_of(xml: str, tag: str) -> str:
|
|
194
|
+
found = re.search(rf"\n(\t*)<{tag}\b", xml)
|
|
195
|
+
return found.group(1) if found else ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def escape_cdata(text: str) -> str:
|
|
199
|
+
if "]]>" in text:
|
|
200
|
+
raise PatchworkError("The label cannot contain ']]>'.")
|
|
201
|
+
return text
|
patchwork/project.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""`patchwork init`: a project folder wired for Claude Code, Cursor, Codex and Google Antigravity.
|
|
2
|
+
|
|
3
|
+
Antigravity and Codex read AGENTS.md; Claude Code reads CLAUDE.md, which imports it.
|
|
4
|
+
Skills go to .cursor/skills, .claude/skills and .agents/skills (Antigravity, Codex).
|
|
5
|
+
Existing files are never overwritten, so `init` can wire an existing folder.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
SKILL_HOMES = (".cursor/skills", ".claude/skills", ".agents/skills")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Init:
|
|
20
|
+
def __init__(self, root: Path):
|
|
21
|
+
self.root = root
|
|
22
|
+
self.written: list[str] = []
|
|
23
|
+
self.kept: list[str] = []
|
|
24
|
+
|
|
25
|
+
def write(self, relative: str, text: str) -> None:
|
|
26
|
+
path = self.root / relative
|
|
27
|
+
if path.exists():
|
|
28
|
+
self.kept.append(relative)
|
|
29
|
+
return
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
path.write_text(text, encoding="utf-8")
|
|
32
|
+
self.written.append(relative)
|
|
33
|
+
|
|
34
|
+
def report(self) -> str:
|
|
35
|
+
lines = [f"Project ready: {self.root}"]
|
|
36
|
+
lines += [f" + {name}" for name in self.written]
|
|
37
|
+
if self.kept:
|
|
38
|
+
lines.append(f" kept {len(self.kept)} existing file(s): {', '.join(self.kept)}")
|
|
39
|
+
return "\n".join(lines)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def init_project(directory: Path, name: str, skills: bool = True) -> Init:
|
|
43
|
+
root = directory.resolve()
|
|
44
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
init = Init(root)
|
|
46
|
+
values = {"NAME": name.strip() or root.name}
|
|
47
|
+
init.write("AGENTS.md", template("AGENTS.md", values))
|
|
48
|
+
init.write("CLAUDE.md", "@AGENTS.md\n")
|
|
49
|
+
init.write("standards/naming.md", template("naming.md", values))
|
|
50
|
+
init.write(".gitignore", template("gitignore", values))
|
|
51
|
+
init.write("patches/.gitkeep", "")
|
|
52
|
+
for relative, text in mcp_configs(root).items():
|
|
53
|
+
init.write(relative, text)
|
|
54
|
+
if skills:
|
|
55
|
+
source = resources.files("patchwork") / "skills"
|
|
56
|
+
for home in SKILL_HOMES:
|
|
57
|
+
for skill in source.iterdir():
|
|
58
|
+
if skill.is_dir():
|
|
59
|
+
init.write(f"{home}/{skill.name}/SKILL.md", (skill / "SKILL.md").read_text(encoding="utf-8"))
|
|
60
|
+
return init
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def server_command() -> tuple[str, list[str]]:
|
|
64
|
+
"""`patchwork` when agents can find it on PATH, otherwise this Python with `-m patchwork`."""
|
|
65
|
+
if shutil.which("patchwork"):
|
|
66
|
+
return "patchwork", []
|
|
67
|
+
return sys.executable, ["-m", "patchwork"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def mcp_configs(root: Path) -> dict[str, str]:
|
|
71
|
+
command, prefix = server_command()
|
|
72
|
+
server = {"command": command, "args": [*prefix, "mcp"]}
|
|
73
|
+
cursor = {"mcpServers": {"patchwork": {**server, "args": [*prefix, "-C", "${workspaceFolder}", "mcp"],
|
|
74
|
+
"env": {"PATCHWORK_PASSWORD": "${env:PATCHWORK_PASSWORD}"}}}}
|
|
75
|
+
claude = {"mcpServers": {"patchwork": {**server, "env": {"PATCHWORK_PASSWORD": "${PATCHWORK_PASSWORD:-}"}}}}
|
|
76
|
+
antigravity = {"mcpServers": {"patchwork": {**server, "cwd": str(root)}}}
|
|
77
|
+
codex = (
|
|
78
|
+
"[mcp_servers.patchwork]\n"
|
|
79
|
+
f"command = {json.dumps(command)}\n"
|
|
80
|
+
f"args = {json.dumps(server['args'])}\n"
|
|
81
|
+
f"cwd = {json.dumps(str(root))}\n"
|
|
82
|
+
'env_vars = ["PATCHWORK_PASSWORD"]\n'
|
|
83
|
+
)
|
|
84
|
+
return {
|
|
85
|
+
".cursor/mcp.json": json.dumps(cursor, indent=2) + "\n",
|
|
86
|
+
".mcp.json": json.dumps(claude, indent=2) + "\n",
|
|
87
|
+
".agents/mcp_config.json": json.dumps(antigravity, indent=2) + "\n",
|
|
88
|
+
".codex/config.toml": codex,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def template(name: str, values: dict[str, str]) -> str:
|
|
93
|
+
text = (resources.files("patchwork") / "templates" / name).read_text(encoding="utf-8")
|
|
94
|
+
for key, value in values.items():
|
|
95
|
+
text = text.replace("{{" + key + "}}", value)
|
|
96
|
+
return text
|