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.
@@ -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,9 @@
1
+ # FileMaker files, exports and the working copies stay on this machine.
2
+ *.fmp12
3
+ *.fmp12.bak
4
+ .patchwork/
5
+ raw/
6
+
7
+ # Secrets
8
+ .env
9
+ .env.*
@@ -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")