shortcutxl 0.2.13 → 0.2.15
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.
- package/README.md +22 -7
- package/dist/core/keybindings.js +3 -1
- package/dist/core/settings-manager.js +29 -0
- package/dist/custom/install-utils.js +0 -3
- package/dist/custom/prompts/api.js +1 -1
- package/dist/custom/providers/provider-ids.js +1 -0
- package/dist/custom/tools/excel-approval.js +180 -0
- package/dist/custom/tools/excel-exec.js +66 -11
- package/dist/custom/tools/task/render.js +1 -1
- package/dist/main.js +12 -1
- package/dist/modes/interactive/components/footer.js +10 -0
- package/dist/modes/interactive/components/settings-selector.js +11 -0
- package/dist/modes/interactive/interactive-mode.js +29 -5
- package/package.json +5 -3
- package/xll/ShortcutXL.xll +0 -0
- package/xll/modules/shortcut_xl/__init__.py +29 -14
- package/xll/modules/shortcut_xl/_com.py +1 -0
- package/xll/modules/shortcut_xl/_diff_highlight.py +133 -91
- package/xll/modules/shortcut_xl/_exec_entry.py +191 -0
- package/xll/modules/shortcut_xl/_log.py +1 -1
- package/xll/modules/shortcut_xl/_managed.py +15 -9
- package/xll/modules/shortcut_xl/_navigate.py +115 -0
- package/xll/modules/shortcut_xl/_threading.py +4 -3
- package/xll/modules/shortcut_xl/_tracking.py +15 -3
- package/xll/modules/shortcut_xl/api/__init__.py +2 -2
- package/xll/modules/shortcut_xl/api/format.py +10 -5
- package/xll/modules/shortcut_xl/api/range_formatter.py +4 -4
- package/xll/modules/shortcut_xl/api/workbook.py +3 -8
- package/xll/modules/shortcut_xl/api/worksheet.py +7 -7
- package/xll/modules/shortcut_xl/api-reference.py +3 -0
- /package/skills/{COM-advanced-api → com-advanced-api}/SKILL.md +0 -0
- /package/skills/{COM-advanced-api → com-advanced-api}/excel-type-library.py +0 -0
- /package/skills/{COM-advanced-api → com-advanced-api}/office-type-library.py +0 -0
|
@@ -21,19 +21,34 @@ hex_to_bgr — convert "#RRGGBB" to BGR int for COM
|
|
|
21
21
|
index_to_address, address_to_index, parse_range, range_to_excel, col_letter, col_index
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
|
-
from shortcut_xl.
|
|
25
|
-
from shortcut_xl.
|
|
26
|
-
from shortcut_xl.
|
|
27
|
-
from shortcut_xl._managed import
|
|
28
|
-
from shortcut_xl.
|
|
29
|
-
from shortcut_xl.
|
|
30
|
-
from shortcut_xl.
|
|
24
|
+
from shortcut_xl._com import xl_app as xl_app
|
|
25
|
+
from shortcut_xl._log import xl_log as xl_log
|
|
26
|
+
from shortcut_xl._managed import run_managed as run_managed
|
|
27
|
+
from shortcut_xl._managed import schedule_call as schedule_call
|
|
28
|
+
from shortcut_xl._managed import xl_batch as xl_batch
|
|
29
|
+
from shortcut_xl._registry import _registry as _registry
|
|
30
|
+
from shortcut_xl._registry import xl_func as xl_func
|
|
31
|
+
from shortcut_xl.api.format import format_cell_diff as format_cell_diff
|
|
32
|
+
from shortcut_xl.api.utils.helpers import ( # noqa: I001
|
|
33
|
+
address_to_index as address_to_index,
|
|
34
|
+
)
|
|
35
|
+
from shortcut_xl.api.utils.helpers import (
|
|
36
|
+
col_index as col_index,
|
|
37
|
+
)
|
|
38
|
+
from shortcut_xl.api.utils.helpers import (
|
|
39
|
+
col_letter as col_letter,
|
|
40
|
+
)
|
|
41
|
+
from shortcut_xl.api.utils.helpers import (
|
|
42
|
+
hex_to_bgr as hex_to_bgr,
|
|
43
|
+
)
|
|
44
|
+
from shortcut_xl.api.utils.helpers import (
|
|
45
|
+
index_to_address as index_to_address,
|
|
46
|
+
)
|
|
47
|
+
from shortcut_xl.api.utils.helpers import (
|
|
48
|
+
parse_range as parse_range,
|
|
49
|
+
)
|
|
31
50
|
from shortcut_xl.api.utils.helpers import (
|
|
32
|
-
|
|
33
|
-
index_to_address,
|
|
34
|
-
address_to_index,
|
|
35
|
-
parse_range,
|
|
36
|
-
range_to_excel,
|
|
37
|
-
col_letter,
|
|
38
|
-
col_index,
|
|
51
|
+
range_to_excel as range_to_excel,
|
|
39
52
|
)
|
|
53
|
+
from shortcut_xl.api.workbook import Workbook as Workbook
|
|
54
|
+
from shortcut_xl.api.worksheet import Worksheet as Worksheet
|
|
@@ -1,37 +1,47 @@
|
|
|
1
|
-
"""Diff highlighting — CF overlay
|
|
1
|
+
"""Diff highlighting — CF overlay for cell changes + revert.
|
|
2
2
|
|
|
3
3
|
After user code executes and dirty cells are collected, this module:
|
|
4
4
|
1. Applies Conditional Formatting to highlight changed cells (yellow)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
4. Always cleans up CF rules before returning
|
|
5
|
+
— skipped for >10K changes (too many COM calls)
|
|
6
|
+
2. Stores CF state and revert data keyed by transaction ID
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
TS only receives the cfTxId. Revert data stays server-side — no
|
|
9
|
+
round-trip through HTTP/JSON. Cleanup and revert are triggered by
|
|
10
|
+
the TS layer via /exec control requests referencing the cfTxId.
|
|
11
|
+
|
|
12
|
+
Thread safety: all public functions are called via _run_on_main
|
|
13
|
+
(serialized on the main thread) AND s_exec_lock in C serializes
|
|
14
|
+
all /exec requests. No concurrent access to module-level state.
|
|
12
15
|
"""
|
|
13
16
|
|
|
14
|
-
import ctypes
|
|
15
17
|
from shortcut_xl._log import xl_log
|
|
16
18
|
|
|
17
|
-
# Win32 MessageBox constants
|
|
18
|
-
_MB_YESNO = 0x04
|
|
19
|
-
_MB_ICONQUESTION = 0x20
|
|
20
|
-
_IDYES = 6
|
|
21
|
-
|
|
22
19
|
# Excel constants
|
|
23
20
|
_XL_EXPRESSION = 2 # xlExpression CF type
|
|
24
21
|
_XL_CALC_MANUAL = -4135 # xlCalculationManual
|
|
25
22
|
_HIGHLIGHT_COLOR = 0x00FFFF # yellow (BGR for #FFFF00) — Track Changes color
|
|
26
23
|
|
|
27
|
-
#
|
|
28
|
-
|
|
24
|
+
# Thresholds
|
|
25
|
+
_MAX_HIGHLIGHT_CELLS = 10_000 # skip CF above this — too many COM calls
|
|
26
|
+
_ADDR_BATCH_LIMIT = 255 # max chars per comma-separated address string
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Transaction state keyed by ID
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
_next_tx_id = 0
|
|
32
|
+
_cf_state = {} # tx_id -> list of CF rule COM objects
|
|
33
|
+
_revert_data = {} # tx_id -> list of dirty cell dicts (for server-side revert)
|
|
34
|
+
_MAX_TX_ENTRIES = 10 # evict oldest if exceeded (guards against leaked entries)
|
|
29
35
|
|
|
30
36
|
|
|
31
37
|
def _group_by_sheet(dirty_cells):
|
|
32
38
|
by_sheet = {}
|
|
33
39
|
for cell in dirty_cells:
|
|
34
|
-
|
|
40
|
+
sheet = cell.get('sheet', '')
|
|
41
|
+
if not sheet:
|
|
42
|
+
xl_log(f"_group_by_sheet: skipping cell with no sheet key: {cell}")
|
|
43
|
+
continue
|
|
44
|
+
by_sheet.setdefault(sheet, []).append(cell)
|
|
35
45
|
return by_sheet
|
|
36
46
|
|
|
37
47
|
|
|
@@ -58,40 +68,116 @@ def _build_union(app, ws, addresses):
|
|
|
58
68
|
|
|
59
69
|
|
|
60
70
|
def _apply_cf_highlights(app, by_sheet):
|
|
61
|
-
"""Apply CF rules to highlight changed cells. Returns
|
|
62
|
-
|
|
71
|
+
"""Apply CF rules to highlight changed cells. Returns list of rule COM objects."""
|
|
72
|
+
rules = []
|
|
63
73
|
for sheet_name, cells in by_sheet.items():
|
|
64
74
|
try:
|
|
65
75
|
ws = app.Worksheets(sheet_name)
|
|
66
|
-
count_before = ws.Cells.FormatConditions.Count
|
|
67
76
|
rng = _build_union(app, ws, [c['address'] for c in cells])
|
|
68
77
|
if rng is None:
|
|
69
78
|
continue
|
|
70
|
-
rng.FormatConditions.Add(
|
|
71
|
-
rule = rng.FormatConditions(rng.FormatConditions.Count)
|
|
79
|
+
rule = rng.FormatConditions.Add(_XL_EXPRESSION, None, "=TRUE")
|
|
72
80
|
rule.Interior.Color = _HIGHLIGHT_COLOR
|
|
73
|
-
|
|
81
|
+
rules.append(rule)
|
|
74
82
|
except Exception as e:
|
|
75
83
|
xl_log(f"_apply_cf_highlights({sheet_name}): {e}")
|
|
76
|
-
return
|
|
84
|
+
return rules
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def highlight_changes(app, dirty_cells):
|
|
88
|
+
"""Apply CF highlights and store revert data for changed cells.
|
|
89
|
+
|
|
90
|
+
Returns a transaction ID (string) for later cleanup/revert via
|
|
91
|
+
cleanup_by_tx or revert_by_tx. Always returns an ID, even if no
|
|
92
|
+
highlights were applied (for consistent API).
|
|
93
|
+
"""
|
|
94
|
+
global _next_tx_id
|
|
95
|
+
|
|
96
|
+
tx_id = str(_next_tx_id)
|
|
97
|
+
_next_tx_id += 1
|
|
98
|
+
|
|
99
|
+
# Evict oldest entries if we're at the cap (guards against leaks
|
|
100
|
+
# when TS crashes before sending cleanup).
|
|
101
|
+
while len(_revert_data) >= _MAX_TX_ENTRIES:
|
|
102
|
+
oldest = next(iter(_revert_data))
|
|
103
|
+
_evict_tx(oldest)
|
|
104
|
+
|
|
105
|
+
# Always store revert data (even above highlight threshold).
|
|
106
|
+
if dirty_cells:
|
|
107
|
+
_revert_data[tx_id] = dirty_cells
|
|
108
|
+
|
|
109
|
+
if not dirty_cells or len(dirty_cells) > _MAX_HIGHLIGHT_CELLS:
|
|
110
|
+
return tx_id
|
|
111
|
+
|
|
112
|
+
by_sheet = _group_by_sheet(dirty_cells)
|
|
113
|
+
try:
|
|
114
|
+
app.ScreenUpdating = False
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
77
117
|
|
|
118
|
+
rules = _apply_cf_highlights(app, by_sheet)
|
|
119
|
+
if rules:
|
|
120
|
+
_cf_state[tx_id] = rules
|
|
78
121
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
122
|
+
try:
|
|
123
|
+
app.ScreenUpdating = True
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
return tx_id
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _evict_tx(tx_id):
|
|
131
|
+
"""Remove all state for a transaction (CF rules + revert data)."""
|
|
132
|
+
_revert_data.pop(tx_id, None)
|
|
133
|
+
for rule in _cf_state.pop(tx_id, []):
|
|
134
|
+
try:
|
|
135
|
+
rule.Delete()
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cleanup_by_tx(tx_id):
|
|
141
|
+
"""Remove CF highlights and revert data for a transaction.
|
|
142
|
+
|
|
143
|
+
Called after user accepts changes — clears both CF rules and stored
|
|
144
|
+
revert data so the transaction is fully released.
|
|
145
|
+
"""
|
|
146
|
+
tx_id = str(tx_id)
|
|
147
|
+
_revert_data.pop(tx_id, None)
|
|
148
|
+
rules = _cf_state.pop(tx_id, [])
|
|
149
|
+
for rule in rules:
|
|
82
150
|
try:
|
|
83
|
-
|
|
84
|
-
ws.Cells.FormatConditions(
|
|
85
|
-
ws.Cells.FormatConditions.Count).Delete()
|
|
151
|
+
rule.Delete()
|
|
86
152
|
except Exception as e:
|
|
87
|
-
xl_log(f"
|
|
153
|
+
xl_log(f"cleanup_by_tx: {e}")
|
|
154
|
+
|
|
88
155
|
|
|
156
|
+
def revert_by_tx(app, tx_id):
|
|
157
|
+
"""Look up revert data by transaction ID and restore old values.
|
|
89
158
|
|
|
90
|
-
|
|
159
|
+
Returns the number of cells reverted (0 if tx_id not found).
|
|
160
|
+
Also cleans up CF highlights for the transaction.
|
|
161
|
+
"""
|
|
162
|
+
tx_id = str(tx_id)
|
|
163
|
+
dirty_cells = _revert_data.pop(tx_id, None)
|
|
164
|
+
# Clean up CF highlights too.
|
|
165
|
+
for rule in _cf_state.pop(tx_id, []):
|
|
166
|
+
try:
|
|
167
|
+
rule.Delete()
|
|
168
|
+
except Exception as e:
|
|
169
|
+
xl_log(f"revert_by_tx cleanup: {e}")
|
|
170
|
+
if not dirty_cells:
|
|
171
|
+
return 0
|
|
172
|
+
revert_cells(app, dirty_cells)
|
|
173
|
+
return len(dirty_cells)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def revert_cells(app, dirty_cells):
|
|
91
177
|
"""Restore old values/formulas for rejected changes.
|
|
92
178
|
|
|
93
|
-
|
|
94
|
-
|
|
179
|
+
Takes an explicit list of dirty cell dicts. All data stays in-process
|
|
180
|
+
(no JSON round-trip) — datetime and other Python types are preserved.
|
|
95
181
|
"""
|
|
96
182
|
orig_calc = None
|
|
97
183
|
try:
|
|
@@ -99,78 +185,34 @@ def _revert_cells(app, dirty_cells):
|
|
|
99
185
|
app.Calculation = _XL_CALC_MANUAL
|
|
100
186
|
except Exception:
|
|
101
187
|
pass
|
|
188
|
+
try:
|
|
189
|
+
app.ScreenUpdating = False
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
102
192
|
|
|
103
193
|
for sheet_name, cells in _group_by_sheet(dirty_cells).items():
|
|
104
194
|
try:
|
|
105
195
|
ws = app.Worksheets(sheet_name)
|
|
106
196
|
for cell in cells:
|
|
107
|
-
addr = cell
|
|
197
|
+
addr = cell.get('address', '')
|
|
198
|
+
if not addr:
|
|
199
|
+
continue
|
|
108
200
|
try:
|
|
109
201
|
if cell.get('oldFormula'):
|
|
110
202
|
ws.Range(addr).Formula = cell['oldFormula']
|
|
111
203
|
else:
|
|
112
|
-
ws.Range(addr).Value = cell
|
|
204
|
+
ws.Range(addr).Value = cell.get('oldValue')
|
|
113
205
|
except Exception as e:
|
|
114
|
-
xl_log(f"
|
|
206
|
+
xl_log(f"revert_cells({sheet_name}!{addr}): {e}")
|
|
115
207
|
except Exception as e:
|
|
116
|
-
xl_log(f"
|
|
208
|
+
xl_log(f"revert_cells({sheet_name}): {e}")
|
|
117
209
|
|
|
210
|
+
try:
|
|
211
|
+
app.ScreenUpdating = True
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
118
214
|
if orig_calc is not None:
|
|
119
215
|
try:
|
|
120
216
|
app.Calculation = orig_calc
|
|
121
217
|
except Exception:
|
|
122
218
|
pass
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def highlight_and_approve(app, dirty_cells):
|
|
126
|
-
"""Highlight changed cells with CF, show MessageBox for user approval.
|
|
127
|
-
|
|
128
|
-
Returns True if approved, False if rejected.
|
|
129
|
-
On rejection, all changes are reverted from dirty_cells' oldValue/oldFormula.
|
|
130
|
-
CF highlighting is always cleaned up before returning.
|
|
131
|
-
"""
|
|
132
|
-
if not dirty_cells:
|
|
133
|
-
return True
|
|
134
|
-
|
|
135
|
-
by_sheet = _group_by_sheet(dirty_cells)
|
|
136
|
-
cf_state = []
|
|
137
|
-
|
|
138
|
-
try:
|
|
139
|
-
# Phase 1: Apply CF highlights
|
|
140
|
-
try:
|
|
141
|
-
app.ScreenUpdating = False
|
|
142
|
-
except Exception:
|
|
143
|
-
pass
|
|
144
|
-
cf_state = _apply_cf_highlights(app, by_sheet)
|
|
145
|
-
try:
|
|
146
|
-
app.ScreenUpdating = True
|
|
147
|
-
except Exception:
|
|
148
|
-
pass
|
|
149
|
-
|
|
150
|
-
# Phase 2: MessageBox — modal to Excel window
|
|
151
|
-
n = len(dirty_cells)
|
|
152
|
-
sheet_summary = ", ".join(
|
|
153
|
-
f"{name} ({len(cells)})" for name, cells in by_sheet.items()
|
|
154
|
-
)
|
|
155
|
-
msg = (
|
|
156
|
-
f"ShortcutXL modified {n} cell{'s' if n != 1 else ''} "
|
|
157
|
-
f"in: {sheet_summary}\n\n"
|
|
158
|
-
"Keep these changes?"
|
|
159
|
-
)
|
|
160
|
-
try:
|
|
161
|
-
hwnd = app.Hwnd
|
|
162
|
-
except Exception:
|
|
163
|
-
hwnd = 0
|
|
164
|
-
result = ctypes.windll.user32.MessageBoxW(
|
|
165
|
-
hwnd, msg, "ShortcutXL", _MB_YESNO | _MB_ICONQUESTION
|
|
166
|
-
)
|
|
167
|
-
approved = (result == _IDYES)
|
|
168
|
-
|
|
169
|
-
# Phase 3: Revert if rejected
|
|
170
|
-
if not approved:
|
|
171
|
-
_revert_cells(app, dirty_cells)
|
|
172
|
-
|
|
173
|
-
return approved
|
|
174
|
-
|
|
175
|
-
finally:
|
|
176
|
-
_cleanup_cf(cf_state)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""HTTP entry point — bridges run_managed to the C HTTP server.
|
|
2
|
+
|
|
3
|
+
C calls dispatch_request(body_json) for every POST /exec.
|
|
4
|
+
This function always returns valid JSON. C never builds JSON.
|
|
5
|
+
|
|
6
|
+
Two request shapes:
|
|
7
|
+
{"code": "..."} → exec_and_respond
|
|
8
|
+
{"control": "revert"|"cleanup"|"navigate", ...} → handle_control
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import traceback
|
|
15
|
+
|
|
16
|
+
from shortcut_xl._com import xl_app
|
|
17
|
+
from shortcut_xl._diff_highlight import (
|
|
18
|
+
cleanup_by_tx,
|
|
19
|
+
highlight_changes,
|
|
20
|
+
revert_by_tx,
|
|
21
|
+
)
|
|
22
|
+
from shortcut_xl._managed import run_managed
|
|
23
|
+
from shortcut_xl._navigate import clear_navigate_cursor, navigate_to_cell
|
|
24
|
+
from shortcut_xl._threading import _run_on_main
|
|
25
|
+
from shortcut_xl.api.utils.helpers import address_to_index
|
|
26
|
+
|
|
27
|
+
_MAX_DIFF_JSON_CELLS = 50
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _format_cell_value(v):
|
|
31
|
+
"""Format a cell value for JSON display."""
|
|
32
|
+
if v is None:
|
|
33
|
+
return 'empty'
|
|
34
|
+
if isinstance(v, bool):
|
|
35
|
+
return 'TRUE' if v else 'FALSE'
|
|
36
|
+
if isinstance(v, int):
|
|
37
|
+
return v
|
|
38
|
+
if isinstance(v, str):
|
|
39
|
+
return 'empty' if len(v) == 0 else v
|
|
40
|
+
try:
|
|
41
|
+
f = float(v)
|
|
42
|
+
# Only convert to int if it fits exactly (avoids precision loss for large floats)
|
|
43
|
+
i = int(f)
|
|
44
|
+
return i if f == i and abs(i) <= 2**53 else round(f, 10)
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
return str(v)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cell_sort_key(c):
|
|
50
|
+
"""Sort key: (sheet, row, col) using address_to_index."""
|
|
51
|
+
addr = c.get('address', '')
|
|
52
|
+
try:
|
|
53
|
+
row, col = address_to_index(addr)
|
|
54
|
+
except (ValueError, AttributeError):
|
|
55
|
+
row, col = 0, 0
|
|
56
|
+
return (c.get('sheet', ''), row, col)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sort_dirty(dirty):
|
|
60
|
+
"""Sort dirty cells by (sheet, row, column) for row-wise display.
|
|
61
|
+
|
|
62
|
+
Applied once before both _build_diff and highlight_changes so the
|
|
63
|
+
entire pipeline sees a consistent order.
|
|
64
|
+
"""
|
|
65
|
+
return sorted(dirty, key=_cell_sort_key)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cell_ref(c):
|
|
69
|
+
"""Build a full cell reference string from a dirty cell dict."""
|
|
70
|
+
wb = c.get('workbook', '')
|
|
71
|
+
prefix = f"[{wb}]" if wb else ''
|
|
72
|
+
return f"{prefix}{c.get('sheet', '')}!{c.get('address', '')}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_diff(dirty):
|
|
76
|
+
"""Build structured diff payload for the TUI approval component.
|
|
77
|
+
|
|
78
|
+
Expects *dirty* to be pre-sorted by _sort_dirty. One row per cell.
|
|
79
|
+
"""
|
|
80
|
+
cells = []
|
|
81
|
+
for c in dirty[:_MAX_DIFF_JSON_CELLS]:
|
|
82
|
+
entry = {
|
|
83
|
+
'address': _cell_ref(c),
|
|
84
|
+
'before': _format_cell_value(c.get('oldValue')),
|
|
85
|
+
'after': _format_cell_value(c.get('value')),
|
|
86
|
+
}
|
|
87
|
+
if c.get('oldFormula'):
|
|
88
|
+
entry['oldFormula'] = c['oldFormula']
|
|
89
|
+
if c.get('formula'):
|
|
90
|
+
entry['formula'] = c['formula']
|
|
91
|
+
cells.append(entry)
|
|
92
|
+
return {'cells': cells, 'total': len(dirty)}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _json_default(obj):
|
|
96
|
+
"""Fallback serializer for non-standard types (e.g. datetime)."""
|
|
97
|
+
try:
|
|
98
|
+
return str(obj)
|
|
99
|
+
except Exception:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def dispatch_request(body_json):
|
|
104
|
+
"""Single entry point for all POST /exec requests.
|
|
105
|
+
|
|
106
|
+
Always returns a valid JSON string. C sends this verbatim.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
request = json.loads(body_json)
|
|
110
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
111
|
+
return json.dumps({'ok': False, 'error': f'Invalid JSON: {e}'})
|
|
112
|
+
|
|
113
|
+
if not isinstance(request, dict):
|
|
114
|
+
return json.dumps({'ok': False, 'error': "request must be a JSON object"})
|
|
115
|
+
|
|
116
|
+
if 'code' in request:
|
|
117
|
+
return exec_and_respond(request['code'], auto_approve=request.get('autoApprove', False))
|
|
118
|
+
elif 'control' in request:
|
|
119
|
+
return handle_control(request)
|
|
120
|
+
else:
|
|
121
|
+
return json.dumps({'ok': False, 'error': "request must have 'code' or 'control' field"})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def exec_and_respond(code_str, *, auto_approve=False):
|
|
125
|
+
"""Execute user code and return a JSON response string.
|
|
126
|
+
|
|
127
|
+
Owns stdout/stderr capture, calls run_managed (pure), applies
|
|
128
|
+
CF highlights, and builds the response. Always returns valid JSON.
|
|
129
|
+
|
|
130
|
+
When *auto_approve* is True the caller already knows it will accept
|
|
131
|
+
any changes, so we skip CF highlighting entirely — no highlights to
|
|
132
|
+
apply, no revert data to store, no cleanup round-trip needed.
|
|
133
|
+
"""
|
|
134
|
+
sio = io.StringIO()
|
|
135
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
136
|
+
sys.stdout = sys.stderr = sio
|
|
137
|
+
try:
|
|
138
|
+
dirty = run_managed(code_str)
|
|
139
|
+
output = sio.getvalue()
|
|
140
|
+
|
|
141
|
+
response = {'ok': True, 'output': output}
|
|
142
|
+
if dirty:
|
|
143
|
+
dirty = _sort_dirty(dirty)
|
|
144
|
+
response['diff'] = _build_diff(dirty)
|
|
145
|
+
# Only apply CF highlights when the user may review changes.
|
|
146
|
+
if not auto_approve:
|
|
147
|
+
cf_tx_id = _run_on_main(lambda: highlight_changes(xl_app(), dirty))
|
|
148
|
+
response['cfTxId'] = cf_tx_id
|
|
149
|
+
return json.dumps(response, default=_json_default)
|
|
150
|
+
except Exception:
|
|
151
|
+
output = sio.getvalue()
|
|
152
|
+
tb = traceback.format_exc()
|
|
153
|
+
error = f"{output}{tb}" if output else tb
|
|
154
|
+
return json.dumps({'ok': False, 'error': error})
|
|
155
|
+
finally:
|
|
156
|
+
sys.stdout, sys.stderr = old_out, old_err
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def handle_control(request):
|
|
160
|
+
"""Handle control commands (revert, cleanup).
|
|
161
|
+
|
|
162
|
+
Revert data is stored server-side in _diff_highlight, keyed by cfTxId.
|
|
163
|
+
TS only sends the ID — no dirty cell data round-trips through HTTP.
|
|
164
|
+
"""
|
|
165
|
+
action = request.get('control')
|
|
166
|
+
cf_tx_id = request.get('cfTxId')
|
|
167
|
+
|
|
168
|
+
if action == 'revert':
|
|
169
|
+
if cf_tx_id is None:
|
|
170
|
+
return json.dumps({'ok': False, 'error': "revert requires cfTxId"})
|
|
171
|
+
_run_on_main(clear_navigate_cursor)
|
|
172
|
+
n = _run_on_main(lambda: revert_by_tx(xl_app(), cf_tx_id))
|
|
173
|
+
if n == 0:
|
|
174
|
+
return json.dumps({'ok': True, 'output': 'Nothing to revert (transaction not found or already cleaned up).'})
|
|
175
|
+
return json.dumps({'ok': True, 'output': f"Reverted {n} cell{'s' if n != 1 else ''}."})
|
|
176
|
+
|
|
177
|
+
elif action == 'navigate':
|
|
178
|
+
cell_ref = request.get('cellRef', '')
|
|
179
|
+
if not cell_ref:
|
|
180
|
+
return json.dumps({'ok': False, 'error': "navigate requires cellRef"})
|
|
181
|
+
ok = _run_on_main(lambda: navigate_to_cell(xl_app(), cell_ref))
|
|
182
|
+
return json.dumps({'ok': ok, 'output': '' if ok else f"Could not navigate to {cell_ref}"})
|
|
183
|
+
|
|
184
|
+
elif action == 'cleanup':
|
|
185
|
+
_run_on_main(clear_navigate_cursor)
|
|
186
|
+
if cf_tx_id is not None:
|
|
187
|
+
_run_on_main(lambda: cleanup_by_tx(cf_tx_id))
|
|
188
|
+
return json.dumps({'ok': True, 'output': ''})
|
|
189
|
+
|
|
190
|
+
else:
|
|
191
|
+
return json.dumps({'ok': False, 'error': f"Unknown control action: {action}"})
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
"""run_managed — execute user code against Excel on the main thread."""
|
|
2
2
|
|
|
3
3
|
import threading
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
from shortcut_xl._com import xl_app
|
|
6
|
+
from shortcut_xl._log import xl_log
|
|
6
7
|
from shortcut_xl._threading import _run_on_main
|
|
7
8
|
from shortcut_xl._tracking import DirtyTracker, TrackedApp
|
|
8
9
|
from shortcut_xl.api.format import format_cell_diff
|
|
9
|
-
from shortcut_xl.api.workbook import Workbook
|
|
10
|
-
from shortcut_xl.api.worksheet import Worksheet
|
|
11
10
|
from shortcut_xl.api.utils.helpers import (
|
|
11
|
+
address_to_index,
|
|
12
|
+
col_index,
|
|
13
|
+
col_letter,
|
|
12
14
|
hex_to_bgr,
|
|
13
|
-
index_to_address,
|
|
15
|
+
index_to_address,
|
|
14
16
|
)
|
|
17
|
+
from shortcut_xl.api.workbook import Workbook
|
|
18
|
+
from shortcut_xl.api.worksheet import Worksheet
|
|
15
19
|
|
|
16
20
|
# Excel xlCalculation enum values
|
|
17
21
|
_XL_CALCULATION_MANUAL = -4135
|
|
@@ -34,10 +38,11 @@ def run_managed(code_str):
|
|
|
34
38
|
|
|
35
39
|
Suspends Calculation/Events/Alerts, runs code with a tracking
|
|
36
40
|
proxy, collects dirty cells, then restores Excel state.
|
|
37
|
-
Prints a human-readable diff summary of any cell changes to stdout.
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
Returns the list of dirty cells. Caller (_exec_entry) is responsible
|
|
43
|
+
for CF highlighting, JSON response building, and stdout capture.
|
|
44
|
+
The text summary is still printed to stdout (captured by _exec_entry)
|
|
45
|
+
so the agent sees what changed in the tool result output.
|
|
41
46
|
"""
|
|
42
47
|
code = compile(code_str, '<excel_exec>', 'exec')
|
|
43
48
|
|
|
@@ -92,13 +97,14 @@ def run_managed(code_str):
|
|
|
92
97
|
app.Calculation = _XL_CALCULATION_AUTOMATIC
|
|
93
98
|
except Exception:
|
|
94
99
|
pass
|
|
100
|
+
|
|
95
101
|
return dirty
|
|
96
102
|
|
|
97
103
|
try:
|
|
98
104
|
dirty = _run_on_main(_work)
|
|
99
105
|
except Exception as e:
|
|
100
|
-
# Strip infrastructure traceback
|
|
101
|
-
#
|
|
106
|
+
# Strip infrastructure traceback — _exec_entry formats it via
|
|
107
|
+
# traceback.format_exc(), so the extra frames are noise.
|
|
102
108
|
e.__traceback__ = None
|
|
103
109
|
raise
|
|
104
110
|
summary = format_cell_diff(dirty)
|