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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fm-standards
|
|
3
|
+
description: >
|
|
4
|
+
Interview the user and write a FileMaker naming-standards file before any
|
|
5
|
+
schema is created. Use when the user mentions standards, naming conventions,
|
|
6
|
+
keys, prefixes, or anchor-buoy.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Naming standards
|
|
10
|
+
|
|
11
|
+
Read `standards/naming.md`. If it says `status: unset`, ask these questions one at a time and wait for each answer:
|
|
12
|
+
|
|
13
|
+
1. Primary keys: serial number, UUID, or a key already in the file.
|
|
14
|
+
2. Table names: singular or plural.
|
|
15
|
+
3. A prefix, or none.
|
|
16
|
+
4. One table for global fields, and its name.
|
|
17
|
+
5. Relationship graph: anchor-buoy, or the layout already used.
|
|
18
|
+
6. Script and layout naming (folders, prefixes).
|
|
19
|
+
|
|
20
|
+
Write the answers to `standards/naming.md` and change `unset` to `set`. Do not invent a missing answer.
|
|
21
|
+
|
|
22
|
+
If the file already has schema, look at it first (`patchwork_list kind=tables`, `kind=fields`, `kind=scripts`), show the conventions that conflict, and ask which to keep.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: patchwork
|
|
3
|
+
description: >
|
|
4
|
+
Edit a FileMaker (.fmp12) file directly: add or change fields, scripts and
|
|
5
|
+
script steps, value lists, custom functions, layouts and themes, through
|
|
6
|
+
Patchwork and Claris FMUpgradeTool, on a versioned working copy. Use when the
|
|
7
|
+
user asks to change, build, fix or explore a FileMaker solution.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Edit FileMaker with Patchwork
|
|
11
|
+
|
|
12
|
+
Use the `patchwork_*` MCP tools when they are available, otherwise the `patchwork` command (same names: `patchwork_add_field` is `patchwork add-field`). Never drive the FileMaker window to change schema.
|
|
13
|
+
|
|
14
|
+
## The loop
|
|
15
|
+
|
|
16
|
+
1. **Open once.** `patchwork_open source=... account=...` (CLI: `patchwork open app.fmp12 --account Admin`). This creates a working copy, v000. The source file is never written.
|
|
17
|
+
2. **Read.** `patchwork_list kind=scripts`, `patchwork_show kind=script name="..."`. Read the object you are about to change, and one similar to what you are about to create.
|
|
18
|
+
3. **Change, one thing at a time,** with a `message`. Each call creates a version, re-exports the file, and lists what changed (`+` added, `~` modified, `-` deleted).
|
|
19
|
+
4. **Check** the change list. If it is not what you meant, `patchwork_undo`.
|
|
20
|
+
5. **Save** with `patchwork_save dest=...` to a new file, and ask the user to open it in FileMaker.
|
|
21
|
+
|
|
22
|
+
## Writing scripts
|
|
23
|
+
|
|
24
|
+
Scripts are text, one step per line, as the Script Workspace shows them. Indentation is optional.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
# Mark the client as Gold
|
|
28
|
+
Set Variable [ $count ; Value: Get ( FoundCount ) ]
|
|
29
|
+
If [ $count = 0 ]
|
|
30
|
+
Show Custom Dialog [ "Nothing to do" ; "No client found." ; Buttons: "OK" ]
|
|
31
|
+
Exit Script [ Text Result: False ]
|
|
32
|
+
End If
|
|
33
|
+
Set Field [ Clients::Status ; "Gold" ]
|
|
34
|
+
Commit Records/Requests [ With dialog: Off ]
|
|
35
|
+
Perform Script [ "Sort Clients" ; Parameter: "gold" ]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- **To edit a script:** `patchwork_show kind=script name=...`, change the lines you need, send the whole text to `patchwork_set_steps`. Lines you did not touch keep their exact step, whatever the step.
|
|
39
|
+
- **New lines** work for: `# comment`, Set Field, Set Variable, If, Else If, Else, End If, Loop, Exit Loop If, End Loop, Go to Layout, Perform Script, Show Custom Dialog, Exit Script, Commit Records/Requests, Delete Record/Request, Set Error Capture, Allow User Abort, Refresh Window, Close Window, Freeze Window, New Record/Request, Show All Records, Halt Script, Beep.
|
|
40
|
+
- Fields are `Occurrence::Field`, scripts and layouts are quoted names. Start a line with `//` to disable the step. In a comment, `¶` is a new line.
|
|
41
|
+
- **Another step** (Perform Find, Sort Records, New Window…): copy a line for it from another script of the file if one exists. Otherwise use XML: `patchwork_show kind=step name="Sort Records"` prints a real one, and the steps parameter also takes `<Step>` elements.
|
|
42
|
+
|
|
43
|
+
After every script change, Patchwork re-reads each step. If FileMaker dropped a parameter, the version is refused and the message names the step.
|
|
44
|
+
|
|
45
|
+
## Putting a field on a layout
|
|
46
|
+
|
|
47
|
+
`patchwork_place_field layout="Clients Details" field="Clients::Status" near="Contact Email"` copies the look and the label of the `near` field and inserts a row below it: what is underneath moves down and the part grows. `side=right` puts it next to `near` instead, if there is room. Add the field first with `patchwork_add_field` if it does not exist.
|
|
48
|
+
|
|
49
|
+
## Editing a layout
|
|
50
|
+
|
|
51
|
+
For anything else, `patchwork_show kind=layout name=...` gives the whole layout. Edit it (bounds, text, styles, objects), then `patchwork_replace kind=layout` with the **whole** `<Layout>` element. FMUpgradeTool skips a lone layout-object replace. `<Bounds>` are absolute, from the top of the layout. Anchoring is in each object's `<Options>`.
|
|
52
|
+
|
|
53
|
+
## Deleting or renaming
|
|
54
|
+
|
|
55
|
+
1. `patchwork_uses kind=... name=...` first. References are certain. Mentions are a name found in calculation text, so check them.
|
|
56
|
+
2. Tell the user what uses the object, and ask before going on.
|
|
57
|
+
3. `patchwork_delete` refuses while references exist. Retry with `force=true` only after the user agreed. Then fix or report each script, layout or calculation from the list.
|
|
58
|
+
|
|
59
|
+
## When a change is refused
|
|
60
|
+
|
|
61
|
+
- "the file did not change: this patch was skipped": FMUpgradeTool ignored it. Do not retry the same call. Check the XML against a real object from `patchwork_show`, or choose another way (for example replace the whole layout).
|
|
62
|
+
- "Not deleted: these would point at nothing": other objects use it. See "Deleting or renaming".
|
|
63
|
+
- Error 803: the file is open in FileMaker. Ask the user to close it.
|
|
64
|
+
- A password error: `PATCHWORK_PASSWORD` is missing or wrong. Ask the user to set it in their environment. Never ask for the password in the chat.
|
|
65
|
+
|
|
66
|
+
Full list of verified behaviors: https://github.com/Abdeltoto/patchwork-filemaker-mcp/blob/main/docs/fmupgradetool-field-guide.md
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# {{NAME}}
|
|
2
|
+
|
|
3
|
+
This is a FileMaker project. You edit the FileMaker file with [Patchwork](https://github.com/Abdeltoto/patchwork-filemaker-mcp), through its MCP tools (`patchwork_*`) or the `patchwork` command. Never drive the FileMaker window to change schema.
|
|
4
|
+
|
|
5
|
+
## How to work
|
|
6
|
+
|
|
7
|
+
1. `patchwork_open` (or `patchwork open file.fmp12 --account NAME`) once. It makes a working copy in `.patchwork/`; the original file is never modified.
|
|
8
|
+
2. **Read before you write.** `patchwork_list` and `patchwork_show` show any object. Scripts come as FileMaker text, one step per line (`Set Field [ Clients::Status ; "Gold" ]`): edit the text and send it back with `patchwork_set_steps`. Everything else comes as XML.
|
|
9
|
+
3. Make one change per call, with a clear `message`. Each change becomes a version, is re-exported and checked. If Patchwork says the change was skipped, read the message and try another way: do not repeat the same call.
|
|
10
|
+
4. `patchwork_log` shows what changed, `patchwork_undo` steps back.
|
|
11
|
+
5. When the user is happy, `patchwork_save` writes the result to a **new** file. Tell the user to open it in FileMaker to check the screens.
|
|
12
|
+
|
|
13
|
+
## What you can change
|
|
14
|
+
|
|
15
|
+
| Object | Add | Edit | Delete |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| Field | `add_field` (text, number, date, container, calculation, global) | | `delete kind=field` |
|
|
18
|
+
| Script | `add_script` with steps as text | `set_steps`, `append_steps` | `delete` |
|
|
19
|
+
| Value list | `add_value_list` | `replace` | `delete` |
|
|
20
|
+
| Custom function | `add_custom_function` | `replace` | `delete` |
|
|
21
|
+
| Layout | `place_field` puts a field on it | `replace` (whole layout), `recolor_layouts` | `delete` |
|
|
22
|
+
| Theme | | `retheme`, `replace` | |
|
|
23
|
+
|
|
24
|
+
## Rules
|
|
25
|
+
|
|
26
|
+
- Never ask for the password and never write it anywhere. It lives in the `PATCHWORK_PASSWORD` environment variable.
|
|
27
|
+
- Before deleting or renaming anything, run `patchwork_uses`, tell the user what uses it, and wait for their go-ahead. Only then retry a refused delete with `force=true`.
|
|
28
|
+
- If a call fails with error 803, the file is open in FileMaker: ask the user to close it.
|
|
29
|
+
- Read `standards/naming.md` before creating schema. If it says `status: unset`, ask for the standards first.
|
|
30
|
+
- Never paste record data from the file into the conversation.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Naming standards - {{NAME}}
|
|
2
|
+
|
|
3
|
+
status: unset
|
|
4
|
+
|
|
5
|
+
Answer before creating any schema. One answer per line. Do not invent a convention that is not here.
|
|
6
|
+
|
|
7
|
+
- Primary keys (serial number, UUID, existing key):
|
|
8
|
+
- Table names (singular or plural):
|
|
9
|
+
- Prefix (or none):
|
|
10
|
+
- Table for global fields:
|
|
11
|
+
- Relationship graph (anchor-buoy, other):
|
|
12
|
+
- Starting theme:
|
patchwork/tools.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Run Claris FMUpgradeTool and FMDeveloperTool safely.
|
|
2
|
+
|
|
3
|
+
- The source file is never overwritten: every command writes a new copy.
|
|
4
|
+
- The password comes from PATCHWORK_PASSWORD (or --no-password) and is never printed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from patchwork import PatchworkError
|
|
16
|
+
|
|
17
|
+
PASSWORD_ENV = "PATCHWORK_PASSWORD"
|
|
18
|
+
KEY_ENV = "PATCHWORK_KEY"
|
|
19
|
+
SECRET_FLAGS = {"-src_pwd", "-src_key", "-patch_key"}
|
|
20
|
+
LOCAL_TOOLS = Path(os.environ.get("LOCALAPPDATA", Path.home() / ".local" / "share")) / "patchwork" / "fms-tools"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def candidates(name: str) -> list[Path]:
|
|
24
|
+
exe = name + (".exe" if platform.system() == "Windows" else "")
|
|
25
|
+
found = []
|
|
26
|
+
override = os.environ.get("FM_UPGRADE_TOOL" if name == "FMUpgradeTool" else "FM_DEVELOPER_TOOL")
|
|
27
|
+
if override:
|
|
28
|
+
found.append(Path(override))
|
|
29
|
+
on_path = shutil.which(name)
|
|
30
|
+
if on_path:
|
|
31
|
+
found.append(Path(on_path))
|
|
32
|
+
program_files = Path(os.environ.get("ProgramFiles", r"C:\Program Files"))
|
|
33
|
+
found += [
|
|
34
|
+
program_files / "FileMaker" / "FileMaker Server" / "Database Server" / exe,
|
|
35
|
+
LOCAL_TOOLS / "PFiles64" / "FileMaker" / "FileMaker Server" / "Database Server" / exe,
|
|
36
|
+
Path("/Library/FileMaker Server/Database Server/bin") / name,
|
|
37
|
+
Path("/opt/FileMaker/FileMaker Server/Database Server/bin") / name,
|
|
38
|
+
]
|
|
39
|
+
return found
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_tool(name: str) -> Path | None:
|
|
43
|
+
for path in candidates(name):
|
|
44
|
+
if path.is_file():
|
|
45
|
+
return path
|
|
46
|
+
if name == "FMDeveloperTool":
|
|
47
|
+
upgrade = find_tool("FMUpgradeTool")
|
|
48
|
+
if upgrade is not None:
|
|
49
|
+
sibling = upgrade.with_name("FMDeveloperTool" + upgrade.suffix)
|
|
50
|
+
if sibling.is_file():
|
|
51
|
+
return sibling
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_tool(name: str) -> Path:
|
|
56
|
+
tool = find_tool(name)
|
|
57
|
+
if tool is None:
|
|
58
|
+
variable = "FM_UPGRADE_TOOL" if name == "FMUpgradeTool" else "FM_DEVELOPER_TOOL"
|
|
59
|
+
raise PatchworkError(
|
|
60
|
+
f"{name} not found. It ships with FileMaker Server (the free trial works). "
|
|
61
|
+
f"See docs/install-fmupgradetool.md, or set {variable} to its path."
|
|
62
|
+
)
|
|
63
|
+
return tool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def password(no_password: bool) -> str:
|
|
67
|
+
if no_password:
|
|
68
|
+
return ""
|
|
69
|
+
value = os.environ.get(PASSWORD_ENV)
|
|
70
|
+
if not value:
|
|
71
|
+
raise PatchworkError(
|
|
72
|
+
f"Set {PASSWORD_ENV} in your shell, or pass --no-password if the account has none. "
|
|
73
|
+
"Never put it in a file or on the command line."
|
|
74
|
+
)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def redact(command: list[str]) -> list[str]:
|
|
79
|
+
shown, hide = [], False
|
|
80
|
+
for part in command:
|
|
81
|
+
shown.append("***" if hide else part)
|
|
82
|
+
hide = part in SECRET_FLAGS and not hide
|
|
83
|
+
return shown
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def same_path(left: Path, right: Path) -> bool:
|
|
87
|
+
return os.path.normcase(str(left.resolve())) == os.path.normcase(str(right.resolve()))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def require_source(src: Path) -> Path:
|
|
91
|
+
src = src.resolve()
|
|
92
|
+
if not src.is_file():
|
|
93
|
+
raise PatchworkError(f"Source file not found: {src}")
|
|
94
|
+
if src.suffix.lower() != ".fmp12":
|
|
95
|
+
raise PatchworkError(f"The source must be a .fmp12 file: {src.name}")
|
|
96
|
+
return src
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def require_copy(src: Path, dest: Path, force: bool) -> Path:
|
|
100
|
+
dest = dest.resolve()
|
|
101
|
+
if same_path(src, dest):
|
|
102
|
+
raise PatchworkError("The destination is the source file. Patchwork always writes a copy.")
|
|
103
|
+
if dest.suffix.lower() != ".fmp12":
|
|
104
|
+
raise PatchworkError("The destination must end in .fmp12.")
|
|
105
|
+
if dest.exists() and not force:
|
|
106
|
+
raise PatchworkError(f"{dest} already exists. Pass --force to replace it.")
|
|
107
|
+
return dest
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def require_root(path: Path, expected: str) -> None:
|
|
111
|
+
if not path.is_file():
|
|
112
|
+
raise PatchworkError(f"File not found: {path}")
|
|
113
|
+
found = root_tag(path)
|
|
114
|
+
if found != expected:
|
|
115
|
+
raise PatchworkError(f"{path.name} has root <{found or 'unreadable'}>, expected <{expected}>.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def root_tag(path: Path) -> str | None:
|
|
119
|
+
raw = path.read_bytes()[:8192]
|
|
120
|
+
if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"):
|
|
121
|
+
text = raw.decode("utf-16", errors="ignore")
|
|
122
|
+
else:
|
|
123
|
+
text = raw.decode("utf-8-sig", errors="ignore")
|
|
124
|
+
for token in text.split("<")[1:]:
|
|
125
|
+
if token.startswith(("?", "!")):
|
|
126
|
+
continue
|
|
127
|
+
name = token.split(">", 1)[0].split()
|
|
128
|
+
return name[0] if name else None
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Runner:
|
|
133
|
+
"""Builds and runs tool commands. `quiet` captures output and shows it only on failure."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, account: str, no_password: bool = False, quiet: bool = False, echo=print) -> None:
|
|
136
|
+
self.account = account
|
|
137
|
+
self.no_password = no_password
|
|
138
|
+
self.quiet = quiet
|
|
139
|
+
self.echo = echo
|
|
140
|
+
|
|
141
|
+
def generate_guids(self, src: Path, dest: Path, force: bool = False) -> None:
|
|
142
|
+
command = [str(require_tool("FMUpgradeTool")), "--generateGUIDs", "-src_path", str(src)]
|
|
143
|
+
command += self.credentials() + ["-dest_path", str(dest)]
|
|
144
|
+
if force:
|
|
145
|
+
command.append("-force")
|
|
146
|
+
self.run(command, dest)
|
|
147
|
+
|
|
148
|
+
def export_xml(self, src: Path, out: Path, force: bool = False) -> None:
|
|
149
|
+
tool = require_tool("FMDeveloperTool")
|
|
150
|
+
command = [str(tool), "--saveAsXML", str(src), self.account, password(self.no_password), "-t", str(out), "-q"]
|
|
151
|
+
if force:
|
|
152
|
+
command.append("-f")
|
|
153
|
+
shown = command.copy()
|
|
154
|
+
shown[4] = "***"
|
|
155
|
+
self.run(command, out, shown)
|
|
156
|
+
|
|
157
|
+
def validate(self, src: Path, patch: Path) -> None:
|
|
158
|
+
self.run(self.upgrade("--validatePatch", src, patch))
|
|
159
|
+
|
|
160
|
+
def apply(self, src: Path, patch: Path, dest: Path, force: bool = False) -> None:
|
|
161
|
+
command = self.upgrade("--update", src, patch) + ["-dest_path", str(dest)]
|
|
162
|
+
if force:
|
|
163
|
+
command.append("-force")
|
|
164
|
+
self.run(command, dest)
|
|
165
|
+
|
|
166
|
+
def new_file(self, xml: Path) -> None:
|
|
167
|
+
self.run([str(require_tool("FMUpgradeTool")), "--generateDBFile", "-src_path", str(xml)])
|
|
168
|
+
|
|
169
|
+
def upgrade(self, action: str, src: Path, patch: Path) -> list[str]:
|
|
170
|
+
command = [str(require_tool("FMUpgradeTool")), action, "-src_path", str(src)]
|
|
171
|
+
command += self.credentials() + ["-patch_path", str(patch), "-v"]
|
|
172
|
+
key = os.environ.get(KEY_ENV)
|
|
173
|
+
if key:
|
|
174
|
+
command += ["-src_key", key]
|
|
175
|
+
return command
|
|
176
|
+
|
|
177
|
+
def credentials(self) -> list[str]:
|
|
178
|
+
return ["-src_account", self.account, "-src_pwd", password(self.no_password)]
|
|
179
|
+
|
|
180
|
+
def run(self, command: list[str], output: Path | None = None, shown: list[str] | None = None) -> None:
|
|
181
|
+
if not self.quiet:
|
|
182
|
+
self.echo("$ " + " ".join(shown or redact(command)))
|
|
183
|
+
completed = subprocess.run(command, check=False, capture_output=self.quiet, text=True, errors="replace")
|
|
184
|
+
failed = completed.returncode != 0 or (output is not None and not output.exists())
|
|
185
|
+
if failed:
|
|
186
|
+
details = ((completed.stdout or "") + (completed.stderr or "")).strip() if self.quiet else ""
|
|
187
|
+
hint = " Is the file open in FileMaker? Close it (error 803)." if "803" in details else ""
|
|
188
|
+
raise PatchworkError(
|
|
189
|
+
f"{Path(command[0]).stem} failed (exit {completed.returncode}).{hint}" + (f"\n{details}" if details else "")
|
|
190
|
+
)
|
patchwork/uses.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Who uses an object: references by UUID (certain) and mentions in calculation text (possible).
|
|
2
|
+
|
|
3
|
+
FileMaker exports point at layouts, scripts, fields, value lists and tables with
|
|
4
|
+
`<…Reference … UUID="…">` elements. Custom functions, and fields used in calculations,
|
|
5
|
+
only appear as text, so those are reported as possible mentions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from patchwork import PatchworkError, objects
|
|
15
|
+
from patchwork.export import Export, unescape
|
|
16
|
+
|
|
17
|
+
CDATA = re.compile(r"<!\[CDATA\[([\s\S]*?)\]\]>")
|
|
18
|
+
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Uses:
|
|
23
|
+
target: str
|
|
24
|
+
references: Counter = field(default_factory=Counter)
|
|
25
|
+
mentions: Counter = field(default_factory=Counter)
|
|
26
|
+
|
|
27
|
+
def report(self) -> str:
|
|
28
|
+
if not self.references and not self.mentions:
|
|
29
|
+
return f"Nothing in the file uses {self.target}."
|
|
30
|
+
lines = []
|
|
31
|
+
if self.references:
|
|
32
|
+
lines.append(f"{self.target} is referenced by {len(self.references)} object(s):")
|
|
33
|
+
lines += [f" {owner} ({count}x)" for owner, count in sorted(self.references.items())]
|
|
34
|
+
if self.mentions:
|
|
35
|
+
lines.append(f"Possible mentions by name in calculations ({len(self.mentions)}):")
|
|
36
|
+
lines += [f" {owner} ({count}x)" for owner, count in sorted(self.mentions.items())]
|
|
37
|
+
return "\n".join(lines)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def owners(export: Export) -> list[tuple[int, int, str]]:
|
|
41
|
+
"""(start, end, label) of every object that can hold a reference, plus each catalog as a fallback."""
|
|
42
|
+
text = export.text
|
|
43
|
+
spans: list[tuple[int, int, str]] = []
|
|
44
|
+
|
|
45
|
+
def section(tag: str) -> tuple[int, int] | None:
|
|
46
|
+
start = text.find(f"<{tag}")
|
|
47
|
+
end = text.find(f"</{tag}>", start)
|
|
48
|
+
return (start, end) if start >= 0 and end >= 0 else None
|
|
49
|
+
|
|
50
|
+
def scan(tag: str, pattern: str, label) -> None:
|
|
51
|
+
bounds = section(tag)
|
|
52
|
+
if bounds:
|
|
53
|
+
for match in re.compile(pattern).finditer(text, *bounds):
|
|
54
|
+
spans.append((match.start(), match.end(), label(match)))
|
|
55
|
+
|
|
56
|
+
for catalog in set(re.findall(r"<(\w+Catalog)[ >]", text)) - {"FieldCatalog"}:
|
|
57
|
+
bounds = section(catalog)
|
|
58
|
+
if bounds:
|
|
59
|
+
words = re.sub(r"(?<!^)(?=[A-Z])", " ", catalog[: -len("Catalog")]).lower()
|
|
60
|
+
spans.append((*bounds, f"{words} catalog"))
|
|
61
|
+
scan("StepsForScripts", r'<Script>\s*<ScriptReference [^>]*\bname="([^"]*)"[\s\S]*?</Script>',
|
|
62
|
+
lambda m: f"script {unescape(m.group(1))}")
|
|
63
|
+
scan("LayoutCatalog", r'<Layout [^>]*\bname="([^"]*)"[^>]*>[\s\S]*?</Layout>', lambda m: f"layout {unescape(m.group(1))}")
|
|
64
|
+
scan("CustomFunctionsCatalog", r'<CustomFunction [^>]*\bname="([^"]*)"[\s\S]*?</CustomFunction>',
|
|
65
|
+
lambda m: f"custom-function {unescape(m.group(1))}")
|
|
66
|
+
scan("TableOccurrenceCatalog", r'<TableOccurrence [^>]*\bname="([^"]*)"[\s\S]*?</TableOccurrence>',
|
|
67
|
+
lambda m: f"table-occurrence {unescape(m.group(1))}")
|
|
68
|
+
scan("ValueListCatalog", r'<ValueList [^>]*\bname="([^"]*)"[\s\S]*?</ValueList>', lambda m: f"value-list {unescape(m.group(1))}")
|
|
69
|
+
bounds = section("FieldsForTables")
|
|
70
|
+
if bounds:
|
|
71
|
+
for catalog in re.compile(r"<FieldCatalog>[\s\S]*?</FieldCatalog>").finditer(text, *bounds):
|
|
72
|
+
table = re.search(r'<BaseTableReference [^>]*\bname="([^"]*)"', catalog.group(0))
|
|
73
|
+
prefix = unescape(table.group(1)) if table else "?"
|
|
74
|
+
spans.append((catalog.start(), catalog.end(), f"table {prefix}"))
|
|
75
|
+
for match in re.compile(r'<Field id="\d+" name="([^"]*)"[\s\S]*?</Field>').finditer(text, catalog.start(), catalog.end()):
|
|
76
|
+
spans.append((match.start(), match.end(), f"field {prefix}::{unescape(match.group(1))}"))
|
|
77
|
+
return spans
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def owner_at(spans: list[tuple[int, int, str]], position: int) -> str:
|
|
81
|
+
inside = [(end - start, label) for start, end, label in spans if start <= position < end]
|
|
82
|
+
return min(inside)[1] if inside else "file"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve(export: Export, kind: str, name: str) -> tuple[str, str, list[re.Pattern], str | None]:
|
|
86
|
+
"""(label, UUID, mention patterns, table whose own field calculations may use the bare name)."""
|
|
87
|
+
if kind == "field":
|
|
88
|
+
table, field_name = objects.split_field(name)
|
|
89
|
+
_, uuid = export.field(table, field_name)
|
|
90
|
+
occurrences = [o.name for o in objects.items(export, "table-occurrences")
|
|
91
|
+
if re.search(rf'<BaseTableReference [^>]*\bname="{re.escape(table)}"', o.xml)]
|
|
92
|
+
patterns = [re.compile(rf"(?<![\w:]){re.escape(o)}::{re.escape(field_name)}(?![\w])") for o in occurrences or [table]]
|
|
93
|
+
return f"field {table}::{field_name}", uuid, patterns, table
|
|
94
|
+
item = objects.find(export, kind, name)
|
|
95
|
+
patterns = [re.compile(rf"(?<![\w.]){re.escape(item.name)}(?![\w])")] if kind == "custom-function" else []
|
|
96
|
+
return f"{kind} {item.name}", item.uuid, patterns, None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_uses(export: Export, kind: str, name: str) -> Uses:
|
|
100
|
+
if kind != "field":
|
|
101
|
+
kind = objects.kind(kind).name
|
|
102
|
+
label, uuid, patterns, table = resolve(export, kind, name)
|
|
103
|
+
spans = owners(export)
|
|
104
|
+
result = Uses(label)
|
|
105
|
+
if uuid:
|
|
106
|
+
for match in re.finditer(rf'<\w+Reference [^>]*\bUUID="{re.escape(uuid)}"', export.text):
|
|
107
|
+
owner = owner_at(spans, match.start())
|
|
108
|
+
if owner != label:
|
|
109
|
+
result.references[owner] += 1
|
|
110
|
+
bare = re.compile(rf"(?<![\w:]){re.escape(name.split('::', 1)[1])}(?![\w:])") if table else None
|
|
111
|
+
for match in CDATA.finditer(export.text):
|
|
112
|
+
calc = STRING.sub('""', match.group(1))
|
|
113
|
+
owner = None
|
|
114
|
+
hits = sum(len(p.findall(calc)) for p in patterns)
|
|
115
|
+
if bare:
|
|
116
|
+
owner = owner_at(spans, match.start())
|
|
117
|
+
if owner.startswith(f"field {table}::"):
|
|
118
|
+
hits += len(bare.findall(calc))
|
|
119
|
+
if hits:
|
|
120
|
+
owner = owner or owner_at(spans, match.start())
|
|
121
|
+
if owner != label and owner not in result.references:
|
|
122
|
+
result.mentions[owner] += hits
|
|
123
|
+
return result
|
patchwork/workspace.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""A versioned working copy of a FileMaker file, kept in <project>/.patchwork/.
|
|
2
|
+
|
|
3
|
+
open copy the source, give every object a UUID, export it -> v000
|
|
4
|
+
apply patch the current version into a new one, export it again -> v001, v002...
|
|
5
|
+
and refuse the version if FMUpgradeTool changed nothing (it skips bad patches silently)
|
|
6
|
+
undo drop the last version
|
|
7
|
+
save copy the current version to a new .fmp12
|
|
8
|
+
|
|
9
|
+
The source file is never written. The password is never stored: it comes from PATCHWORK_PASSWORD.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from patchwork import PatchworkError
|
|
22
|
+
from patchwork.build import wrap
|
|
23
|
+
from patchwork.export import Export
|
|
24
|
+
from patchwork.objects import diff, normalize
|
|
25
|
+
from patchwork.tools import Runner, require_copy, require_source
|
|
26
|
+
|
|
27
|
+
FOLDER = ".patchwork"
|
|
28
|
+
WORKSPACE_ENV = "PATCHWORK_PROJECT"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Result:
|
|
33
|
+
version: int
|
|
34
|
+
message: str
|
|
35
|
+
changes: list[str]
|
|
36
|
+
patch: Path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Workspace:
|
|
40
|
+
def __init__(self, root: Path) -> None:
|
|
41
|
+
self.root = root
|
|
42
|
+
self.state_file = root / "state.json"
|
|
43
|
+
if not self.state_file.is_file():
|
|
44
|
+
raise PatchworkError(f"No Patchwork workspace in {root.parent}. Start with `patchwork open file.fmp12 --account NAME`.")
|
|
45
|
+
self.state = json.loads(self.state_file.read_text(encoding="utf-8"))
|
|
46
|
+
self._export: Export | None = None
|
|
47
|
+
|
|
48
|
+
# ----- locate -----
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def locate(project: Path | None = None) -> "Workspace":
|
|
52
|
+
if project is None and os.environ.get(WORKSPACE_ENV):
|
|
53
|
+
project = Path(os.environ[WORKSPACE_ENV])
|
|
54
|
+
if project is not None:
|
|
55
|
+
return Workspace(project.resolve() / FOLDER)
|
|
56
|
+
here = Path.cwd().resolve()
|
|
57
|
+
for folder in (here, *here.parents):
|
|
58
|
+
if (folder / FOLDER / "state.json").is_file():
|
|
59
|
+
return Workspace(folder / FOLDER)
|
|
60
|
+
raise PatchworkError("No Patchwork workspace here. Start with `patchwork open file.fmp12 --account NAME`.")
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def create(src: Path, account: str, no_password: bool, project: Path | None = None, force: bool = False,
|
|
64
|
+
runner: Runner | None = None) -> "Workspace":
|
|
65
|
+
src = require_source(src)
|
|
66
|
+
project = (project or Path.cwd()).resolve()
|
|
67
|
+
root = project / FOLDER
|
|
68
|
+
if (root / "state.json").is_file():
|
|
69
|
+
if not force:
|
|
70
|
+
current = json.loads((root / "state.json").read_text(encoding="utf-8"))
|
|
71
|
+
raise PatchworkError(
|
|
72
|
+
f"A workspace already exists here for {Path(current['source']).name}. "
|
|
73
|
+
"`patchwork save` your work, then pass --force to start over."
|
|
74
|
+
)
|
|
75
|
+
shutil.rmtree(root)
|
|
76
|
+
root.mkdir(parents=True)
|
|
77
|
+
(root / ".gitignore").write_text("*\n", encoding="utf-8")
|
|
78
|
+
runner = runner or Runner(account, no_password, quiet=True)
|
|
79
|
+
try:
|
|
80
|
+
runner.generate_guids(src, root / "v000.fmp12")
|
|
81
|
+
runner.export_xml(root / "v000.fmp12", root / "v000.xml", force=True)
|
|
82
|
+
except Exception:
|
|
83
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
84
|
+
raise
|
|
85
|
+
state = {
|
|
86
|
+
"source": str(src),
|
|
87
|
+
"account": account,
|
|
88
|
+
"no_password": no_password,
|
|
89
|
+
"versions": [{"n": 0, "message": f"open {src.name}", "time": now(), "changes": []}],
|
|
90
|
+
}
|
|
91
|
+
(root / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
92
|
+
return Workspace(root)
|
|
93
|
+
|
|
94
|
+
# ----- state -----
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def version(self) -> int:
|
|
98
|
+
return self.state["versions"][-1]["n"]
|
|
99
|
+
|
|
100
|
+
def file(self, n: int | None = None) -> Path:
|
|
101
|
+
return self.root / f"v{self.version if n is None else n:03d}.fmp12"
|
|
102
|
+
|
|
103
|
+
def export_path(self, n: int | None = None) -> Path:
|
|
104
|
+
return self.root / f"v{self.version if n is None else n:03d}.xml"
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def export(self) -> Export:
|
|
108
|
+
if self._export is None:
|
|
109
|
+
self._export = Export.load(self.export_path())
|
|
110
|
+
return self._export
|
|
111
|
+
|
|
112
|
+
def runner(self, quiet: bool = True) -> Runner:
|
|
113
|
+
return Runner(self.state["account"], self.state.get("no_password", False), quiet=quiet)
|
|
114
|
+
|
|
115
|
+
def save_state(self) -> None:
|
|
116
|
+
self.state_file.write_text(json.dumps(self.state, indent=2), encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
# ----- operations -----
|
|
119
|
+
|
|
120
|
+
def apply(self, actions: str | list[str], message: str, runner: Runner | None = None, dry_run: bool = False) -> Result:
|
|
121
|
+
"""Apply patch actions (or a full FMUpgradeToolPatch) as a new version."""
|
|
122
|
+
parts = [actions] if isinstance(actions, str) else actions
|
|
123
|
+
text = parts[0] if len(parts) == 1 and "<FMUpgradeToolPatch" in parts[0] else wrap(*parts, version=self.export.version)
|
|
124
|
+
n = self.version + 1
|
|
125
|
+
patch = self.root / f"p{n:03d}.xml"
|
|
126
|
+
patch.write_text(text, encoding="utf-8")
|
|
127
|
+
if dry_run:
|
|
128
|
+
return Result(self.version, message, [], patch)
|
|
129
|
+
runner = runner or self.runner()
|
|
130
|
+
out, xml = self.file(n), self.export_path(n)
|
|
131
|
+
try:
|
|
132
|
+
runner.apply(self.file(), patch, out, force=True)
|
|
133
|
+
runner.export_xml(out, xml, force=True)
|
|
134
|
+
after = Export.load(xml)
|
|
135
|
+
if normalize(after.text) == normalize(self.export.text):
|
|
136
|
+
raise PatchworkError(
|
|
137
|
+
"FMUpgradeTool reported success but the file did not change: this patch was skipped. "
|
|
138
|
+
"See docs/fmupgradetool-field-guide.md. The patch is kept in " + str(patch)
|
|
139
|
+
)
|
|
140
|
+
except Exception:
|
|
141
|
+
for leftover in (out, xml):
|
|
142
|
+
if leftover.exists():
|
|
143
|
+
leftover.unlink()
|
|
144
|
+
raise
|
|
145
|
+
changes = diff(self.export, after)
|
|
146
|
+
self.state["versions"].append({"n": n, "message": message, "time": now(), "changes": changes})
|
|
147
|
+
self.save_state()
|
|
148
|
+
self._export = after
|
|
149
|
+
return Result(n, message, changes, patch)
|
|
150
|
+
|
|
151
|
+
def undo(self) -> int:
|
|
152
|
+
if self.version == 0:
|
|
153
|
+
raise PatchworkError("Nothing to undo: this is the opened file.")
|
|
154
|
+
n = self.version
|
|
155
|
+
for path in (self.file(n), self.export_path(n)):
|
|
156
|
+
if path.exists():
|
|
157
|
+
path.unlink()
|
|
158
|
+
self.state["versions"].pop()
|
|
159
|
+
self.save_state()
|
|
160
|
+
self._export = None
|
|
161
|
+
return self.version
|
|
162
|
+
|
|
163
|
+
def save(self, dest: Path, force: bool = False) -> Path:
|
|
164
|
+
dest = require_copy(Path(self.state["source"]), dest, force)
|
|
165
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
shutil.copy2(self.file(), dest)
|
|
167
|
+
return dest
|
|
168
|
+
|
|
169
|
+
def log(self) -> list[dict]:
|
|
170
|
+
return self.state["versions"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def now() -> str:
|
|
174
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|