@nomad-e/bluma-cli 0.1.17 → 0.1.19
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 +62 -12
- package/dist/config/native_tools.json +7 -0
- package/dist/config/skills/git-commit/LICENSE.txt +18 -0
- package/dist/config/skills/git-commit/SKILL.md +258 -0
- package/dist/config/skills/git-commit/references/REFERENCE.md +249 -0
- package/dist/config/skills/git-commit/scripts/validate_commit_msg.py +163 -0
- package/dist/config/skills/git-pr/LICENSE.txt +18 -0
- package/dist/config/skills/git-pr/SKILL.md +293 -0
- package/dist/config/skills/git-pr/references/REFERENCE.md +256 -0
- package/dist/config/skills/git-pr/scripts/validate_commits.py +112 -0
- package/dist/config/skills/pdf/LICENSE.txt +26 -0
- package/dist/config/skills/pdf/SKILL.md +327 -0
- package/dist/config/skills/pdf/references/FORMS.md +69 -0
- package/dist/config/skills/pdf/references/REFERENCE.md +52 -0
- package/dist/config/skills/pdf/scripts/create_report.py +59 -0
- package/dist/config/skills/pdf/scripts/merge_pdfs.py +39 -0
- package/dist/config/skills/skill-creator/LICENSE.txt +26 -0
- package/dist/config/skills/skill-creator/SKILL.md +229 -0
- package/dist/config/skills/xlsx/LICENSE.txt +18 -0
- package/dist/config/skills/xlsx/SKILL.md +298 -0
- package/dist/config/skills/xlsx/references/REFERENCE.md +337 -0
- package/dist/config/skills/xlsx/scripts/office/__init__.py +2 -0
- package/dist/config/skills/xlsx/scripts/office/__pycache__/__init__.cpython-312.pyc +0 -0
- package/dist/config/skills/xlsx/scripts/office/__pycache__/pack.cpython-312.pyc +0 -0
- package/dist/config/skills/xlsx/scripts/office/__pycache__/soffice.cpython-312.pyc +0 -0
- package/dist/config/skills/xlsx/scripts/office/__pycache__/unpack.cpython-312.pyc +0 -0
- package/dist/config/skills/xlsx/scripts/office/__pycache__/validate.cpython-312.pyc +0 -0
- package/dist/config/skills/xlsx/scripts/office/pack.py +58 -0
- package/dist/config/skills/xlsx/scripts/office/soffice.py +180 -0
- package/dist/config/skills/xlsx/scripts/office/unpack.py +63 -0
- package/dist/config/skills/xlsx/scripts/office/validate.py +122 -0
- package/dist/config/skills/xlsx/scripts/recalc.py +143 -0
- package/dist/main.js +275 -89
- package/package.json +1 -1
- package/dist/config/example.bluma-mcp.json.txt +0 -14
- package/dist/config/models_config.json +0 -78
- package/dist/skills/git-conventional/LICENSE.txt +0 -3
- package/dist/skills/git-conventional/SKILL.md +0 -83
- package/dist/skills/skill-creator/SKILL.md +0 -495
- package/dist/skills/testing/LICENSE.txt +0 -3
- package/dist/skills/testing/SKILL.md +0 -114
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LibreOffice process management for sandboxed and standard environments.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Finding the soffice binary across platforms
|
|
6
|
+
- Running recalculation macros in headless mode
|
|
7
|
+
- Configuring Unix sockets for restricted environments
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SOFFICE_SEARCH_PATHS = [
|
|
18
|
+
'/usr/bin/soffice',
|
|
19
|
+
'/usr/bin/libreoffice',
|
|
20
|
+
'/usr/lib/libreoffice/program/soffice',
|
|
21
|
+
'/snap/bin/libreoffice',
|
|
22
|
+
'/opt/libreoffice/program/soffice',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
MACOS_PATHS = [
|
|
26
|
+
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
|
|
27
|
+
os.path.expanduser('~/Applications/LibreOffice.app/Contents/MacOS/soffice'),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_soffice() -> str | None:
|
|
32
|
+
"""Find the soffice binary on this system."""
|
|
33
|
+
found = shutil.which('soffice') or shutil.which('libreoffice')
|
|
34
|
+
if found:
|
|
35
|
+
return found
|
|
36
|
+
|
|
37
|
+
search = MACOS_PATHS if platform.system() == 'Darwin' else SOFFICE_SEARCH_PATHS
|
|
38
|
+
|
|
39
|
+
for path in search:
|
|
40
|
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
41
|
+
return path
|
|
42
|
+
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _prepare_user_profile(base_dir: str) -> str:
|
|
47
|
+
"""Create a temporary user profile to avoid lock conflicts."""
|
|
48
|
+
profile_dir = os.path.join(base_dir, 'libreoffice-profile')
|
|
49
|
+
os.makedirs(profile_dir, exist_ok=True)
|
|
50
|
+
return profile_dir
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_macro_content(filepath: str) -> str:
|
|
54
|
+
"""Build a LibreOffice Basic macro that recalculates all sheets."""
|
|
55
|
+
abs_path = os.path.abspath(filepath)
|
|
56
|
+
url = 'file://' + abs_path.replace(' ', '%20')
|
|
57
|
+
|
|
58
|
+
return f"""
|
|
59
|
+
Sub RecalcAndSave
|
|
60
|
+
Dim oDoc As Object
|
|
61
|
+
Dim oSheets As Object
|
|
62
|
+
Dim oSheet As Object
|
|
63
|
+
Dim i As Integer
|
|
64
|
+
|
|
65
|
+
oDoc = StarDesktop.loadComponentFromURL( _
|
|
66
|
+
"{url}", "_blank", 0, _
|
|
67
|
+
Array(MakePropertyValue("Hidden", True), _
|
|
68
|
+
MakePropertyValue("MacroExecutionMode", 4)))
|
|
69
|
+
|
|
70
|
+
If IsNull(oDoc) Or IsEmpty(oDoc) Then
|
|
71
|
+
MsgBox "Failed to open document"
|
|
72
|
+
Exit Sub
|
|
73
|
+
End If
|
|
74
|
+
|
|
75
|
+
oSheets = oDoc.getSheets()
|
|
76
|
+
For i = 0 To oSheets.getCount() - 1
|
|
77
|
+
oSheet = oSheets.getByIndex(i)
|
|
78
|
+
Next i
|
|
79
|
+
|
|
80
|
+
oDoc.calculateAll()
|
|
81
|
+
oDoc.calculateAll()
|
|
82
|
+
|
|
83
|
+
oDoc.store()
|
|
84
|
+
oDoc.close(True)
|
|
85
|
+
End Sub
|
|
86
|
+
|
|
87
|
+
Function MakePropertyValue(sName As String, vValue As Variant) As com.sun.star.beans.PropertyValue
|
|
88
|
+
Dim oProperty As New com.sun.star.beans.PropertyValue
|
|
89
|
+
oProperty.Name = sName
|
|
90
|
+
oProperty.Value = vValue
|
|
91
|
+
MakePropertyValue = oProperty
|
|
92
|
+
End Function
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_soffice_recalc(
|
|
97
|
+
soffice_path: str,
|
|
98
|
+
filepath: str,
|
|
99
|
+
timeout: int = 60,
|
|
100
|
+
) -> tuple[bool, str]:
|
|
101
|
+
"""
|
|
102
|
+
Recalculate an Excel file using LibreOffice headless mode.
|
|
103
|
+
|
|
104
|
+
Returns (success: bool, message: str).
|
|
105
|
+
"""
|
|
106
|
+
abs_path = os.path.abspath(filepath)
|
|
107
|
+
|
|
108
|
+
if not os.path.isfile(abs_path):
|
|
109
|
+
return False, f'File not found: {abs_path}'
|
|
110
|
+
|
|
111
|
+
with tempfile.TemporaryDirectory(prefix='bluma-lo-') as tmp_dir:
|
|
112
|
+
profile_dir = _prepare_user_profile(tmp_dir)
|
|
113
|
+
|
|
114
|
+
macro_content = _build_macro_content(abs_path)
|
|
115
|
+
macro_dir = os.path.join(
|
|
116
|
+
profile_dir, 'user', 'basic', 'Standard'
|
|
117
|
+
)
|
|
118
|
+
os.makedirs(macro_dir, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
macro_file = os.path.join(macro_dir, 'BluMaRecalc.xba')
|
|
121
|
+
with open(macro_file, 'w') as f:
|
|
122
|
+
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
123
|
+
f.write('<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">\n')
|
|
124
|
+
f.write('<script:module xmlns:script="http://openoffice.org/2000/script" ')
|
|
125
|
+
f.write('script:name="BluMaRecalc" script:language="StarBasic">\n')
|
|
126
|
+
f.write(macro_content)
|
|
127
|
+
f.write('\n</script:module>\n')
|
|
128
|
+
|
|
129
|
+
dialog_lc = os.path.join(macro_dir, 'dialog.xlc')
|
|
130
|
+
with open(dialog_lc, 'w') as f:
|
|
131
|
+
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
132
|
+
f.write('<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">\n')
|
|
133
|
+
f.write('<library:library xmlns:library="http://openoffice.org/2000/library" ')
|
|
134
|
+
f.write('library:name="Standard" library:readonly="false" library:passwordprotected="false">\n')
|
|
135
|
+
f.write('</library:library>\n')
|
|
136
|
+
|
|
137
|
+
script_lc = os.path.join(macro_dir, 'script.xlc')
|
|
138
|
+
with open(script_lc, 'w') as f:
|
|
139
|
+
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
140
|
+
f.write('<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">\n')
|
|
141
|
+
f.write('<library:library xmlns:library="http://openoffice.org/2000/library" ')
|
|
142
|
+
f.write('library:name="Standard" library:readonly="false" library:passwordprotected="false">\n')
|
|
143
|
+
f.write(f' <library:element library:name="BluMaRecalc"/>\n')
|
|
144
|
+
f.write('</library:library>\n')
|
|
145
|
+
|
|
146
|
+
env = os.environ.copy()
|
|
147
|
+
env['HOME'] = tmp_dir
|
|
148
|
+
env['SAL_USE_VCLPLUGIN'] = 'gen'
|
|
149
|
+
|
|
150
|
+
cmd = [
|
|
151
|
+
soffice_path,
|
|
152
|
+
'--headless',
|
|
153
|
+
'--invisible',
|
|
154
|
+
'--nologo',
|
|
155
|
+
'--norestore',
|
|
156
|
+
f'-env:UserInstallation=file://{profile_dir}',
|
|
157
|
+
f'macro:///Standard.BluMaRecalc.RecalcAndSave',
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
proc = subprocess.run(
|
|
162
|
+
cmd,
|
|
163
|
+
capture_output=True,
|
|
164
|
+
text=True,
|
|
165
|
+
timeout=timeout,
|
|
166
|
+
env=env,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if proc.returncode != 0:
|
|
170
|
+
stderr = proc.stderr.strip()[:500] if proc.stderr else 'No error output'
|
|
171
|
+
return False, f'LibreOffice exited with code {proc.returncode}: {stderr}'
|
|
172
|
+
|
|
173
|
+
return True, 'Recalculation completed'
|
|
174
|
+
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
return False, f'LibreOffice timed out after {timeout} seconds'
|
|
177
|
+
except FileNotFoundError:
|
|
178
|
+
return False, f'soffice binary not found at: {soffice_path}'
|
|
179
|
+
except Exception as e:
|
|
180
|
+
return False, f'Unexpected error: {str(e)}'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unpack an Office Open XML file (.xlsx/.docx/.pptx) into a directory.
|
|
3
|
+
|
|
4
|
+
This allows direct manipulation of the internal XML structure, which is
|
|
5
|
+
useful for repairs, validation, or modifications not possible through
|
|
6
|
+
standard libraries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import zipfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def unpack(filepath: str, dest_dir: str | None = None) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Extract an Office file into a directory.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
filepath: Path to the .xlsx/.docx/.pptx file.
|
|
19
|
+
dest_dir: Destination directory. If None, creates one next to
|
|
20
|
+
the source file with '_unpacked' suffix.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Absolute path to the unpacked directory.
|
|
24
|
+
"""
|
|
25
|
+
abs_path = os.path.abspath(filepath)
|
|
26
|
+
|
|
27
|
+
if not os.path.isfile(abs_path):
|
|
28
|
+
raise FileNotFoundError(f'File not found: {abs_path}')
|
|
29
|
+
|
|
30
|
+
if dest_dir is None:
|
|
31
|
+
base = os.path.splitext(abs_path)[0]
|
|
32
|
+
dest_dir = base + '_unpacked'
|
|
33
|
+
|
|
34
|
+
abs_dest = os.path.abspath(dest_dir)
|
|
35
|
+
os.makedirs(abs_dest, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
with zipfile.ZipFile(abs_path, 'r') as zf:
|
|
39
|
+
zf.extractall(abs_dest)
|
|
40
|
+
except zipfile.BadZipFile:
|
|
41
|
+
raise ValueError(f'Not a valid Office/ZIP file: {abs_path}')
|
|
42
|
+
|
|
43
|
+
return abs_dest
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def list_contents(filepath: str) -> list[dict]:
|
|
47
|
+
"""
|
|
48
|
+
List the internal structure of an Office file.
|
|
49
|
+
|
|
50
|
+
Returns a list of dicts with 'name', 'size', and 'compressed_size'.
|
|
51
|
+
"""
|
|
52
|
+
abs_path = os.path.abspath(filepath)
|
|
53
|
+
|
|
54
|
+
with zipfile.ZipFile(abs_path, 'r') as zf:
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
'name': info.filename,
|
|
58
|
+
'size': info.file_size,
|
|
59
|
+
'compressed_size': info.compress_size,
|
|
60
|
+
}
|
|
61
|
+
for info in zf.infolist()
|
|
62
|
+
if not info.is_dir()
|
|
63
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validate the internal structure of Office Open XML files.
|
|
3
|
+
|
|
4
|
+
Checks that required parts exist, relationships are consistent,
|
|
5
|
+
and the ZIP structure is well-formed. Does NOT validate against
|
|
6
|
+
full ISO/IEC 29500 schemas (that would require lxml + full schema set).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import zipfile
|
|
11
|
+
|
|
12
|
+
REQUIRED_PARTS_XLSX = [
|
|
13
|
+
'[Content_Types].xml',
|
|
14
|
+
'_rels/.rels',
|
|
15
|
+
'xl/workbook.xml',
|
|
16
|
+
'xl/_rels/workbook.xml.rels',
|
|
17
|
+
'xl/styles.xml',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
REQUIRED_PARTS_DOCX = [
|
|
21
|
+
'[Content_Types].xml',
|
|
22
|
+
'_rels/.rels',
|
|
23
|
+
'word/document.xml',
|
|
24
|
+
'word/_rels/document.xml.rels',
|
|
25
|
+
'word/styles.xml',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
REQUIRED_PARTS_PPTX = [
|
|
29
|
+
'[Content_Types].xml',
|
|
30
|
+
'_rels/.rels',
|
|
31
|
+
'ppt/presentation.xml',
|
|
32
|
+
'ppt/_rels/presentation.xml.rels',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
FILE_TYPE_MAP = {
|
|
36
|
+
'.xlsx': ('xlsx', REQUIRED_PARTS_XLSX),
|
|
37
|
+
'.xlsm': ('xlsx', REQUIRED_PARTS_XLSX),
|
|
38
|
+
'.docx': ('docx', REQUIRED_PARTS_DOCX),
|
|
39
|
+
'.pptx': ('pptx', REQUIRED_PARTS_PPTX),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate(filepath: str) -> dict:
|
|
44
|
+
"""
|
|
45
|
+
Validate an Office file's internal structure.
|
|
46
|
+
|
|
47
|
+
Returns a dict with:
|
|
48
|
+
- valid (bool): Whether the file passes validation
|
|
49
|
+
- file_type (str): Detected type (xlsx/docx/pptx)
|
|
50
|
+
- parts_count (int): Number of parts in the archive
|
|
51
|
+
- missing_parts (list): Required parts that are missing
|
|
52
|
+
- warnings (list): Non-critical issues
|
|
53
|
+
- errors (list): Critical issues
|
|
54
|
+
"""
|
|
55
|
+
abs_path = os.path.abspath(filepath)
|
|
56
|
+
ext = os.path.splitext(abs_path)[1].lower()
|
|
57
|
+
|
|
58
|
+
result = {
|
|
59
|
+
'valid': True,
|
|
60
|
+
'file_type': None,
|
|
61
|
+
'parts_count': 0,
|
|
62
|
+
'missing_parts': [],
|
|
63
|
+
'warnings': [],
|
|
64
|
+
'errors': [],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if not os.path.isfile(abs_path):
|
|
68
|
+
result['valid'] = False
|
|
69
|
+
result['errors'].append(f'File not found: {abs_path}')
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
if ext not in FILE_TYPE_MAP:
|
|
73
|
+
result['valid'] = False
|
|
74
|
+
result['errors'].append(f'Unsupported extension: {ext}')
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
file_type, required_parts = FILE_TYPE_MAP[ext]
|
|
78
|
+
result['file_type'] = file_type
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
with zipfile.ZipFile(abs_path, 'r') as zf:
|
|
82
|
+
names = set(zf.namelist())
|
|
83
|
+
result['parts_count'] = len(names)
|
|
84
|
+
|
|
85
|
+
for part in required_parts:
|
|
86
|
+
if part not in names:
|
|
87
|
+
result['missing_parts'].append(part)
|
|
88
|
+
|
|
89
|
+
corrupt = zf.testzip()
|
|
90
|
+
if corrupt:
|
|
91
|
+
result['errors'].append(f'Corrupt entry: {corrupt}')
|
|
92
|
+
|
|
93
|
+
except zipfile.BadZipFile:
|
|
94
|
+
result['valid'] = False
|
|
95
|
+
result['errors'].append('Not a valid ZIP file')
|
|
96
|
+
return result
|
|
97
|
+
except Exception as e:
|
|
98
|
+
result['valid'] = False
|
|
99
|
+
result['errors'].append(f'Error reading file: {str(e)}')
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
if result['missing_parts']:
|
|
103
|
+
result['valid'] = False
|
|
104
|
+
result['errors'].append(
|
|
105
|
+
f"Missing {len(result['missing_parts'])} required part(s)"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
for name in names:
|
|
109
|
+
if name.startswith('..') or '/../' in name:
|
|
110
|
+
result['valid'] = False
|
|
111
|
+
result['errors'].append(f'Path traversal detected: {name}')
|
|
112
|
+
|
|
113
|
+
if result['parts_count'] == 0:
|
|
114
|
+
result['valid'] = False
|
|
115
|
+
result['errors'].append('Archive is empty')
|
|
116
|
+
|
|
117
|
+
if result['parts_count'] > 10000:
|
|
118
|
+
result['warnings'].append(
|
|
119
|
+
f'Unusually large archive: {result["parts_count"]} entries'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return result
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Recalculate all formulas in an Excel file using LibreOffice and scan
|
|
4
|
+
for formula errors.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
python recalc.py <excel_file> [timeout_seconds]
|
|
8
|
+
|
|
9
|
+
Returns JSON to stdout with recalculation status and any errors found.
|
|
10
|
+
Requires LibreOffice installed and openpyxl for error scanning.
|
|
11
|
+
|
|
12
|
+
Exit codes:
|
|
13
|
+
0 Recalculated successfully (may still have formula errors)
|
|
14
|
+
1 Recalculation failed
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
24
|
+
|
|
25
|
+
from office.soffice import find_soffice, run_soffice_recalc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ERROR_VALUES = frozenset({
|
|
29
|
+
'#REF!', '#DIV/0!', '#VALUE!', '#N/A', '#NAME?',
|
|
30
|
+
'#NULL!', '#NUM!', '#GETTING_DATA', '#SPILL!', '#CALC!',
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def scan_errors_fast(filepath: str) -> dict:
|
|
35
|
+
"""Optimized error scanning — loads formula workbook only once."""
|
|
36
|
+
try:
|
|
37
|
+
from openpyxl import load_workbook
|
|
38
|
+
except ImportError:
|
|
39
|
+
return {
|
|
40
|
+
'status': 'error',
|
|
41
|
+
'message': 'openpyxl not installed — cannot scan for formula errors',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
wb_data = load_workbook(filepath, data_only=True)
|
|
45
|
+
wb_formula = load_workbook(filepath)
|
|
46
|
+
|
|
47
|
+
errors = {}
|
|
48
|
+
total_formulas = 0
|
|
49
|
+
sheets_processed = []
|
|
50
|
+
|
|
51
|
+
for sheet_name in wb_data.sheetnames:
|
|
52
|
+
ws_data = wb_data[sheet_name]
|
|
53
|
+
ws_form = wb_formula[sheet_name]
|
|
54
|
+
sheets_processed.append(sheet_name)
|
|
55
|
+
|
|
56
|
+
for row_data, row_form in zip(ws_data.iter_rows(), ws_form.iter_rows()):
|
|
57
|
+
for cell_data, cell_form in zip(row_data, row_form):
|
|
58
|
+
if isinstance(cell_form.value, str) and cell_form.value.startswith('='):
|
|
59
|
+
total_formulas += 1
|
|
60
|
+
|
|
61
|
+
if isinstance(cell_data.value, str) and cell_data.value in ERROR_VALUES:
|
|
62
|
+
err_type = cell_data.value
|
|
63
|
+
if err_type not in errors:
|
|
64
|
+
errors[err_type] = {'count': 0, 'locations': []}
|
|
65
|
+
errors[err_type]['count'] += 1
|
|
66
|
+
loc = f"{sheet_name}!{cell_data.coordinate}"
|
|
67
|
+
if len(errors[err_type]['locations']) < 20:
|
|
68
|
+
errors[err_type]['locations'].append(loc)
|
|
69
|
+
|
|
70
|
+
wb_data.close()
|
|
71
|
+
wb_formula.close()
|
|
72
|
+
|
|
73
|
+
total_errors = sum(e['count'] for e in errors.values())
|
|
74
|
+
|
|
75
|
+
result = {
|
|
76
|
+
'total_formulas': total_formulas,
|
|
77
|
+
'total_errors': total_errors,
|
|
78
|
+
'sheets_processed': sheets_processed,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if total_errors > 0:
|
|
82
|
+
result['status'] = 'errors_found'
|
|
83
|
+
result['error_summary'] = errors
|
|
84
|
+
else:
|
|
85
|
+
result['status'] = 'success'
|
|
86
|
+
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main():
|
|
91
|
+
if len(sys.argv) < 2:
|
|
92
|
+
print(json.dumps({
|
|
93
|
+
'status': 'error',
|
|
94
|
+
'message': 'Usage: python recalc.py <excel_file> [timeout_seconds]',
|
|
95
|
+
}))
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
|
|
98
|
+
filepath = os.path.abspath(sys.argv[1])
|
|
99
|
+
timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 60
|
|
100
|
+
|
|
101
|
+
if not os.path.isfile(filepath):
|
|
102
|
+
print(json.dumps({
|
|
103
|
+
'status': 'error',
|
|
104
|
+
'message': f'File not found: {filepath}',
|
|
105
|
+
}))
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
soffice_path = find_soffice()
|
|
109
|
+
if not soffice_path:
|
|
110
|
+
print(json.dumps({
|
|
111
|
+
'status': 'error',
|
|
112
|
+
'message': (
|
|
113
|
+
'LibreOffice not found. Install it with: '
|
|
114
|
+
'sudo apt install libreoffice-calc (Linux) or '
|
|
115
|
+
'brew install --cask libreoffice (macOS)'
|
|
116
|
+
),
|
|
117
|
+
}))
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
start = time.time()
|
|
121
|
+
|
|
122
|
+
success, message = run_soffice_recalc(soffice_path, filepath, timeout)
|
|
123
|
+
|
|
124
|
+
elapsed = round(time.time() - start, 2)
|
|
125
|
+
|
|
126
|
+
if not success:
|
|
127
|
+
print(json.dumps({
|
|
128
|
+
'status': 'error',
|
|
129
|
+
'message': message,
|
|
130
|
+
'elapsed_seconds': elapsed,
|
|
131
|
+
}))
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
result = scan_errors_fast(filepath)
|
|
135
|
+
result['elapsed_seconds'] = elapsed
|
|
136
|
+
result['recalc_engine'] = 'LibreOffice'
|
|
137
|
+
|
|
138
|
+
print(json.dumps(result, indent=2))
|
|
139
|
+
sys.exit(0)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == '__main__':
|
|
143
|
+
main()
|