docuhand 0.1.0.dev1__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,202 @@
1
+ """Edit documents that are currently OPEN in a live Word/WPS instance.
2
+
3
+ The signature differentiator: every other engine method works on its own
4
+ hidden ``DispatchEx`` instance. This one ATTACHES to the instance the user
5
+ is running (``Dispatch`` — the running-object-table lookup — vs Launch)
6
+ and drives the visible document through the same STA worker.
7
+
8
+ Safety model:
9
+ - resolve_document matches by absolute path OR exact window title.
10
+ - The document must already exist in the attached instance's Documents
11
+ collection; we never open files silently in the user's session.
12
+ - ``save`` default ``"ask"`` maps to: do NOT save, report dirty state and
13
+ let the agent ask the user. ``"save"`` saves explicitly as the final op.
14
+ - Attach failure (no live instance) is a structured error, not a fallback
15
+ to a hidden instance — silently editing a file the user cannot see
16
+ would betray the "hands on the visible document" contract.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import pythoncom
27
+ import win32com.client
28
+
29
+ from ..errors import (
30
+ DocuhandError,
31
+ FileNotFoundError,
32
+ InternalError,
33
+ )
34
+ from .com_utils import _g, _short_exc
35
+ from .templating import _restore_font, _snapshot_font
36
+
37
+ WD_DO_NOT_SAVE_CHANGES = 0
38
+ WD_SAVE_CHANGES = -1
39
+
40
+
41
+ def _attach_running_app() -> Any:
42
+ """Attach to the user's running Word/WPS instance (ROT lookup)."""
43
+ pythoncom.CoInitialize()
44
+ try:
45
+ return win32com.client.Dispatch("Word.Application")
46
+ except Exception:
47
+ pass
48
+ try:
49
+ return win32com.client.Dispatch("KWPS.Application")
50
+ except Exception as exc:
51
+ raise DocuhandError(
52
+ "No running Word/WPS instance to attach to",
53
+ llm_hint=(
54
+ "edit_open_document works on documents the user has open "
55
+ "right now. If the file is closed, use fill_template / "
56
+ "convert_documents instead; if it should be open, ask the "
57
+ "user to open it first."
58
+ ),
59
+ details={"com_error": _short_exc(exc)},
60
+ ) from None
61
+
62
+
63
+ def _resolve_document(app: Any, path_or_title: str) -> tuple[Any, dict[str, Any]]:
64
+ """Match by absolute path first, then exact/full window title."""
65
+ docs = app.Documents
66
+ count = int(_g(lambda: docs.Count, 0) or 0)
67
+ wanted_path = str(Path(path_or_title).expanduser().resolve()).lower()
68
+ wanted_title = path_or_title.strip().lower()
69
+
70
+ candidates: list[dict[str, Any]] = []
71
+ for i in range(count):
72
+ doc = _g(lambda i=i: docs.Item(i + 1))
73
+ if doc is None:
74
+ continue
75
+ full_name = _g(lambda doc=doc: doc.FullName, "") or ""
76
+ name = _g(lambda doc=doc: doc.Name, "") or ""
77
+ title = _g(lambda doc=doc: doc.ActiveWindow.Caption if doc.Windows.Count else name, "") or name
78
+ candidates.append(
79
+ {
80
+ "doc": doc,
81
+ "full_name": str(full_name),
82
+ "name": str(name),
83
+ "title": str(title),
84
+ "saved": bool(_g(lambda doc=doc: doc.Saved, True)),
85
+ "readonly": bool(_g(lambda doc=doc: doc.ReadOnly, False)),
86
+ }
87
+ )
88
+
89
+ for c in candidates:
90
+ if c["full_name"] and c["full_name"].lower() == wanted_path:
91
+ return c["doc"], c
92
+ for c in candidates:
93
+ if wanted_title and wanted_title in (c["title"].lower(), c["name"].lower()):
94
+ return c["doc"], c
95
+ # one more chance: title minus hidden extension variants
96
+ stem = Path(path_or_title).stem.lower()
97
+ for c in candidates:
98
+ if stem and stem == Path(c["name"]).stem.lower():
99
+ return c["doc"], c
100
+
101
+ raise FileNotFoundError(
102
+ f"Document not found among the {count} open document(s): {path_or_title}"
103
+ )
104
+
105
+
106
+ def _replace_all_two_step(doc: Any, token: str, value: str) -> int:
107
+ """Pitfall #14 recipe against the LIVE document (see templating.py)."""
108
+ n = 0
109
+ while n < 500:
110
+ find = _g(lambda: doc.Content.Find, None)
111
+ if find is None:
112
+ break
113
+ try:
114
+ find.ClearFormatting()
115
+ find.Forward = True
116
+ find.Wrap = 1
117
+ hit = find.Execute(token)
118
+ except Exception:
119
+ break
120
+ if not hit:
121
+ break
122
+ try:
123
+ find.Parent.Text = value
124
+ n += 1
125
+ except Exception:
126
+ break
127
+ return n
128
+
129
+
130
+ def _apply_operations(doc: Any, operations: list[dict[str, Any]]) -> list[dict[str, Any]]:
131
+ results: list[dict[str, Any]] = []
132
+ for i, op in enumerate(operations):
133
+ kind = op["op"]
134
+ if kind == "replace_all":
135
+ n = _replace_all_two_step(doc, op["find"], op["replace"])
136
+ results.append({"index": i, "op": kind, "find": op["find"], "replacements": n, "ok": True})
137
+ elif kind == "insert_text":
138
+ where = op["where"]
139
+ text = op["text"]
140
+ if where == "start":
141
+ rng = doc.Range(0, 0)
142
+ snap = _snapshot_font(_g(lambda rng=rng: rng.Font, {}) or {})
143
+ rng.Text = text
144
+ _restore_font(_g(lambda rng=rng: rng.Font, {}) or {}, snap, text)
145
+ else:
146
+ end = doc.Content.End
147
+ rng = doc.Range(end - 1 if end > 0 else 0, end - 1 if end > 0 else 0)
148
+ snap = _snapshot_font(_g(lambda rng=rng: rng.Font, {}) or {})
149
+ rng.Text = text
150
+ _restore_font(_g(lambda rng=rng: rng.Font, {}) or {}, snap, text)
151
+ results.append({"index": i, "op": kind, "where": where, "chars": len(text), "ok": True})
152
+ elif kind == "save":
153
+ doc.Save()
154
+ results.append({"index": i, "op": kind, "ok": True})
155
+ return results
156
+
157
+
158
+ def edit_open_document(
159
+ path_or_title: str,
160
+ operations: list[dict[str, Any]],
161
+ save: str = "ask",
162
+ ) -> dict[str, Any]:
163
+ """Attach to the user's live instance and edit an OPEN document."""
164
+ t0 = time.perf_counter()
165
+ if save not in ("ask", "save"):
166
+ raise DocuhandError(
167
+ f"save must be 'ask' or 'save' (got {save!r})",
168
+ llm_hint="'ask' leaves the document dirty and reports the state; "
169
+ "'save' writes the changes to disk after the last operation.",
170
+ )
171
+
172
+ app = _attach_running_app()
173
+ doc, meta = _resolve_document(app, path_or_title)
174
+
175
+ if meta.get("readonly"):
176
+ raise DocuhandError(
177
+ f"Document is open READ-ONLY in the user's session: {meta['full_name'] or meta['name']}",
178
+ llm_hint="The user opened it read-only (or it is locked). Ask them to reopen it editable first.",
179
+ details={"document": meta["full_name"] or meta["name"]},
180
+ )
181
+
182
+ op_results = _apply_operations(doc, operations)
183
+
184
+ dirty = not bool(_g(lambda: doc.Saved, True))
185
+ saved_now = False
186
+ if save == "save":
187
+ doc.Save()
188
+ saved_now = True
189
+ dirty = False
190
+
191
+ return {
192
+ "ok": True,
193
+ "tool": "edit_open_document",
194
+ "document": meta["full_name"] or meta["name"],
195
+ "matched_by": "path" if meta["full_name"].lower() == str(Path(path_or_title).expanduser().resolve()).lower() else "title",
196
+ "operations_applied": op_results,
197
+ "saved": saved_now,
198
+ "dirty_after": dirty,
199
+ "engine": _g(lambda: app.Name, "unknown"),
200
+ "engine_version": str(_g(lambda: app.Version, "") or ""),
201
+ "duration_ms": round((time.perf_counter() - t0) * 1000),
202
+ }
@@ -0,0 +1,58 @@
1
+ """Pure planning for merge_documents — output naming, zero COM.
2
+
3
+ One template + N data rows → N filled files. Naming: a pattern with
4
+ ``{i}`` (1-based, zero-padded to 3 for sort order) and any ``{field}``
5
+ tokens from the row data. Everything that can reach a filename is
6
+ sanitized (path separators, reserved characters, length) — a hostile
7
+ data value must not be able to write outside the output directory.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+
15
+ _ILLEGAL = '<>:"/\\|?*'
16
+ _COMPONENT_CAP = 40
17
+ _PAD = 3
18
+
19
+
20
+ def sanitize_component(value: str) -> str:
21
+ """Make one naming component filesystem-safe and short."""
22
+ text = str(value)
23
+ out: list[str] = []
24
+ for ch in text:
25
+ if ch in _ILLEGAL or ord(ch) < 0x20:
26
+ out.append("_")
27
+ else:
28
+ out.append(ch)
29
+ cleaned = re.sub(r"\s+", " ", "".join(out)).strip().strip(".")
30
+ return cleaned[:_COMPONENT_CAP]
31
+
32
+
33
+ def build_name(pattern: str, index: int, row: dict) -> str:
34
+ """Substitute {i} and {field} tokens, then sanitize the whole stem.
35
+
36
+ ``{i}`` renders zero-padded to 3 digits so directory listings sort in
37
+ row order regardless of how the OS sorts numbers.
38
+ """
39
+ text = str(pattern or "")
40
+ text = text.replace("{i}", str(index).zfill(_PAD))
41
+ for key, value in row.items():
42
+ text = text.replace("{" + str(key) + "}", sanitize_component(str(value)))
43
+ # the pattern itself is untrusted input too
44
+ return sanitize_component(text) or str(index).zfill(_PAD)
45
+
46
+
47
+ def resolve_collision(directory: Path, stem: str, extension: str) -> Path:
48
+ """First free '<stem>.<ext>', then '<stem>-2', '-3', ... (cap 999)."""
49
+ candidate = directory / f"{stem}{extension}"
50
+ n = 2
51
+ while candidate.exists() and n <= 999:
52
+ candidate = directory / f"{stem}-{n}{extension}"
53
+ n += 1
54
+ return candidate
55
+
56
+
57
+ def default_output_dir(template: Path) -> Path:
58
+ return template.parent / ("merged_" + template.stem)