shortcutxl 0.2.13 → 0.2.14
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/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 +150 -0
- package/xll/modules/shortcut_xl/_log.py +1 -1
- package/xll/modules/shortcut_xl/_managed.py +15 -9
- package/xll/modules/shortcut_xl/_threading.py +4 -3
- package/xll/modules/shortcut_xl/_tracking.py +8 -2
- 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
|
@@ -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,150 @@
|
|
|
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", "cfTxId": "..."} → 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._threading import _run_on_main
|
|
24
|
+
|
|
25
|
+
_MAX_DIFF_JSON_CELLS = 50
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _format_cell_value(v):
|
|
29
|
+
"""Format a cell value for JSON display."""
|
|
30
|
+
if v is None:
|
|
31
|
+
return 'empty'
|
|
32
|
+
if isinstance(v, bool):
|
|
33
|
+
return 'TRUE' if v else 'FALSE'
|
|
34
|
+
if isinstance(v, int):
|
|
35
|
+
return v
|
|
36
|
+
if isinstance(v, str):
|
|
37
|
+
return 'empty' if len(v) == 0 else v
|
|
38
|
+
try:
|
|
39
|
+
f = float(v)
|
|
40
|
+
# Only convert to int if it fits exactly (avoids precision loss for large floats)
|
|
41
|
+
i = int(f)
|
|
42
|
+
return i if f == i and abs(i) <= 2**53 else round(f, 10)
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
return str(v)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_diff(dirty):
|
|
48
|
+
"""Build structured diff payload for the TUI approval component."""
|
|
49
|
+
cells = []
|
|
50
|
+
for c in dirty[:_MAX_DIFF_JSON_CELLS]:
|
|
51
|
+
entry = {
|
|
52
|
+
'cell': f"{c.get('sheet', '')}!{c.get('address', '')}",
|
|
53
|
+
'before': _format_cell_value(c.get('oldValue')),
|
|
54
|
+
'after': _format_cell_value(c.get('value')),
|
|
55
|
+
}
|
|
56
|
+
if c.get('oldFormula'):
|
|
57
|
+
entry['oldFormula'] = c['oldFormula']
|
|
58
|
+
if c.get('formula'):
|
|
59
|
+
entry['formula'] = c['formula']
|
|
60
|
+
cells.append(entry)
|
|
61
|
+
return {'cells': cells, 'total': len(dirty)}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _json_default(obj):
|
|
65
|
+
"""Fallback serializer for non-standard types (e.g. datetime)."""
|
|
66
|
+
try:
|
|
67
|
+
return str(obj)
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dispatch_request(body_json):
|
|
73
|
+
"""Single entry point for all POST /exec requests.
|
|
74
|
+
|
|
75
|
+
Always returns a valid JSON string. C sends this verbatim.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
request = json.loads(body_json)
|
|
79
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
80
|
+
return json.dumps({'ok': False, 'error': f'Invalid JSON: {e}'})
|
|
81
|
+
|
|
82
|
+
if not isinstance(request, dict):
|
|
83
|
+
return json.dumps({'ok': False, 'error': "request must be a JSON object"})
|
|
84
|
+
|
|
85
|
+
if 'code' in request:
|
|
86
|
+
return exec_and_respond(request['code'], auto_approve=request.get('autoApprove', False))
|
|
87
|
+
elif 'control' in request:
|
|
88
|
+
return handle_control(request)
|
|
89
|
+
else:
|
|
90
|
+
return json.dumps({'ok': False, 'error': "request must have 'code' or 'control' field"})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def exec_and_respond(code_str, *, auto_approve=False):
|
|
94
|
+
"""Execute user code and return a JSON response string.
|
|
95
|
+
|
|
96
|
+
Owns stdout/stderr capture, calls run_managed (pure), applies
|
|
97
|
+
CF highlights, and builds the response. Always returns valid JSON.
|
|
98
|
+
|
|
99
|
+
When *auto_approve* is True the caller already knows it will accept
|
|
100
|
+
any changes, so we skip CF highlighting entirely — no highlights to
|
|
101
|
+
apply, no revert data to store, no cleanup round-trip needed.
|
|
102
|
+
"""
|
|
103
|
+
sio = io.StringIO()
|
|
104
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
105
|
+
sys.stdout = sys.stderr = sio
|
|
106
|
+
try:
|
|
107
|
+
dirty = run_managed(code_str)
|
|
108
|
+
output = sio.getvalue()
|
|
109
|
+
|
|
110
|
+
response = {'ok': True, 'output': output}
|
|
111
|
+
if dirty:
|
|
112
|
+
response['diff'] = _build_diff(dirty)
|
|
113
|
+
# Only apply CF highlights when the user may review changes.
|
|
114
|
+
if not auto_approve:
|
|
115
|
+
cf_tx_id = _run_on_main(lambda: highlight_changes(xl_app(), dirty))
|
|
116
|
+
response['cfTxId'] = cf_tx_id
|
|
117
|
+
return json.dumps(response, default=_json_default)
|
|
118
|
+
except Exception:
|
|
119
|
+
output = sio.getvalue()
|
|
120
|
+
tb = traceback.format_exc()
|
|
121
|
+
error = f"{output}{tb}" if output else tb
|
|
122
|
+
return json.dumps({'ok': False, 'error': error})
|
|
123
|
+
finally:
|
|
124
|
+
sys.stdout, sys.stderr = old_out, old_err
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def handle_control(request):
|
|
128
|
+
"""Handle control commands (revert, cleanup).
|
|
129
|
+
|
|
130
|
+
Revert data is stored server-side in _diff_highlight, keyed by cfTxId.
|
|
131
|
+
TS only sends the ID — no dirty cell data round-trips through HTTP.
|
|
132
|
+
"""
|
|
133
|
+
action = request.get('control')
|
|
134
|
+
cf_tx_id = request.get('cfTxId')
|
|
135
|
+
|
|
136
|
+
if action == 'revert':
|
|
137
|
+
if cf_tx_id is None:
|
|
138
|
+
return json.dumps({'ok': False, 'error': "revert requires cfTxId"})
|
|
139
|
+
n = _run_on_main(lambda: revert_by_tx(xl_app(), cf_tx_id))
|
|
140
|
+
if n == 0:
|
|
141
|
+
return json.dumps({'ok': True, 'output': 'Nothing to revert (transaction not found or already cleaned up).'})
|
|
142
|
+
return json.dumps({'ok': True, 'output': f"Reverted {n} cell{'s' if n != 1 else ''}."})
|
|
143
|
+
|
|
144
|
+
elif action == 'cleanup':
|
|
145
|
+
if cf_tx_id is not None:
|
|
146
|
+
_run_on_main(lambda: cleanup_by_tx(cf_tx_id))
|
|
147
|
+
return json.dumps({'ok': True, 'output': ''})
|
|
148
|
+
|
|
149
|
+
else:
|
|
150
|
+
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)
|
|
@@ -6,12 +6,12 @@ This matches the pattern used by PyXLL, Excel-DNA, and xlwings:
|
|
|
6
6
|
background threads enqueue work, a timer on the main thread drains it.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
import threading
|
|
10
|
-
import queue
|
|
11
9
|
import ctypes
|
|
10
|
+
import queue
|
|
11
|
+
import threading
|
|
12
12
|
from ctypes import wintypes
|
|
13
|
-
from shortcut_xl._log import xl_log
|
|
14
13
|
|
|
14
|
+
from shortcut_xl._log import xl_log
|
|
15
15
|
|
|
16
16
|
# COM error code: Excel is busy (VBA_E_IGNORE = 0x800AC472)
|
|
17
17
|
_VBA_E_IGNORE = -2146777998
|
|
@@ -44,6 +44,7 @@ def _is_excel_busy(e):
|
|
|
44
44
|
|
|
45
45
|
# Persist main thread ID in builtins so it survives hot-reload.
|
|
46
46
|
import builtins
|
|
47
|
+
|
|
47
48
|
_main_thread_id = getattr(builtins, '_shortcutxl_main_thread_id', None)
|
|
48
49
|
if _main_thread_id is None:
|
|
49
50
|
_main_thread_id = threading.current_thread().ident
|
|
@@ -7,9 +7,9 @@ sheets) not O(workbook), and the agent gets raw COM objects for everything
|
|
|
7
7
|
below the Worksheet level — zero overhead on Range reads/writes.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from shortcut_xl.api.utils.helpers import col_letter
|
|
11
|
-
from shortcut_xl.api.utils.com_utils import normalize_2d
|
|
12
10
|
from shortcut_xl._log import xl_log
|
|
11
|
+
from shortcut_xl.api.utils.com_utils import normalize_2d
|
|
12
|
+
from shortcut_xl.api.utils.helpers import col_letter
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class _SheetSnapshot:
|
|
@@ -69,6 +69,9 @@ def _diff_snapshots(sheet_name, before, after):
|
|
|
69
69
|
formula = _get_formula(after, ri, ci)
|
|
70
70
|
if formula:
|
|
71
71
|
entry['formula'] = formula
|
|
72
|
+
old_formula = _get_formula(before, ri, ci)
|
|
73
|
+
if old_formula:
|
|
74
|
+
entry['oldFormula'] = old_formula
|
|
72
75
|
changes.append(entry)
|
|
73
76
|
return changes
|
|
74
77
|
|
|
@@ -105,6 +108,9 @@ def _diff_snapshots(sheet_name, before, after):
|
|
|
105
108
|
formula = _get_formula_abs(after, row, col)
|
|
106
109
|
if formula:
|
|
107
110
|
entry['formula'] = formula
|
|
111
|
+
old_formula = _get_formula_abs(before, row, col)
|
|
112
|
+
if old_formula:
|
|
113
|
+
entry['oldFormula'] = old_formula
|
|
108
114
|
changes.append(entry)
|
|
109
115
|
return changes
|
|
110
116
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
"""API layer — Workbook/Worksheet wrappers and supporting modules."""
|
|
2
2
|
|
|
3
|
-
from shortcut_xl.api.workbook import Workbook
|
|
4
|
-
from shortcut_xl.api.worksheet import Worksheet
|
|
3
|
+
from shortcut_xl.api.workbook import Workbook as Workbook
|
|
4
|
+
from shortcut_xl.api.worksheet import Worksheet as Worksheet
|
|
@@ -5,14 +5,19 @@ with Good / Issues breakdown and row sampling.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import re
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
from shortcut_xl.api.categorize import (
|
|
10
|
+
GOOD,
|
|
11
|
+
HARDCODED_NUMBER,
|
|
12
|
+
HARDCODED_NUMBER_IN_FORMULA,
|
|
13
|
+
INVALID_FORMULA,
|
|
14
|
+
LARGE_PERCENTAGE,
|
|
10
15
|
categorize_cells,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
is_com_error, com_error_to_str,
|
|
16
|
+
com_error_to_str,
|
|
17
|
+
is_com_error,
|
|
14
18
|
)
|
|
15
19
|
from shortcut_xl.api.utils.helpers import col_letter
|
|
20
|
+
from shortcut_xl.api.utils.numerical import NUMERIC_TYPES
|
|
16
21
|
|
|
17
22
|
# Display constants
|
|
18
23
|
MAX_ROWS_TO_SHOW = 10
|
|
@@ -165,7 +170,7 @@ def _format_row(sheet, row, entries, indent=2, show_issues=True, show_count=True
|
|
|
165
170
|
lines.append(f'{ind}{sheet}!Row {row + 1} ({col_range}){count_suffix}')
|
|
166
171
|
|
|
167
172
|
samples = _sample_first_last(entries, MAX_SAMPLES_PER_ROW)
|
|
168
|
-
for cell,
|
|
173
|
+
for cell, _issues, _col in samples:
|
|
169
174
|
fmt = cell.get('numberFormat')
|
|
170
175
|
old_str = format_value(cell.get('oldValue'), fmt)
|
|
171
176
|
new_str = _display_value(cell)
|
|
@@ -9,11 +9,11 @@ Replicates the TypeScript processCellRange / RangeFormatter logic:
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from shortcut_xl.api.utils.helpers import index_to_address
|
|
13
12
|
from shortcut_xl.api.style import read_style
|
|
14
|
-
from shortcut_xl.api.utils.
|
|
13
|
+
from shortcut_xl.api.utils.helpers import index_to_address
|
|
14
|
+
from shortcut_xl.api.utils.numerical import NUMERIC_TYPES, is_numerical
|
|
15
15
|
from shortcut_xl.api.utils.ranges import consolidate_ranges
|
|
16
|
-
from shortcut_xl.api.utils.style_utils import
|
|
16
|
+
from shortcut_xl.api.utils.style_utils import get_style_description, get_style_key
|
|
17
17
|
|
|
18
18
|
# ---------------------------------------------------------------------------
|
|
19
19
|
# Constants (match TypeScript range-processing/types.ts)
|
|
@@ -349,8 +349,8 @@ def format_cell_range(
|
|
|
349
349
|
5. Context from cells to the left
|
|
350
350
|
6. Context from cells above
|
|
351
351
|
"""
|
|
352
|
+
from shortcut_xl.api.utils.com_utils import make_range, normalize_2d
|
|
352
353
|
from shortcut_xl.api.utils.helpers import parse_range
|
|
353
|
-
from shortcut_xl.api.utils.com_utils import normalize_2d, make_range
|
|
354
354
|
|
|
355
355
|
sr, sc, er, ec = parse_range(range_addr)
|
|
356
356
|
rows = er - sr + 1
|
|
@@ -5,17 +5,14 @@ Output formats match the SpreadJS equivalents.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from
|
|
9
|
-
|
|
8
|
+
from shortcut_xl.api.categorize import com_error_to_str, is_com_error
|
|
9
|
+
from shortcut_xl.api.named_ranges import get_named_range_info
|
|
10
10
|
from shortcut_xl.api.utils.helpers import (
|
|
11
|
-
|
|
11
|
+
extract_sheet_and_range,
|
|
12
12
|
index_to_address,
|
|
13
13
|
is_error_value,
|
|
14
14
|
parse_range,
|
|
15
|
-
extract_sheet_and_range,
|
|
16
15
|
)
|
|
17
|
-
from shortcut_xl.api.categorize import is_com_error, com_error_to_str
|
|
18
|
-
from shortcut_xl.api.named_ranges import get_named_range_info
|
|
19
16
|
from shortcut_xl.api.worksheet import Worksheet
|
|
20
17
|
|
|
21
18
|
# Error check constants (match SpreadJS error-checker.ts)
|
|
@@ -263,8 +260,6 @@ class Workbook:
|
|
|
263
260
|
rng.Value = com_error_to_str(data)
|
|
264
261
|
return
|
|
265
262
|
# Multi-cell range
|
|
266
|
-
rows = rng.Rows.Count
|
|
267
|
-
cols = rng.Columns.Count
|
|
268
263
|
for r_idx, row in enumerate(data):
|
|
269
264
|
if not isinstance(row, tuple):
|
|
270
265
|
row = (row,)
|