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/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]))
|