@cteno/sdk-linux-x64 0.0.0-stage → 2.10.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/LICENSE.daemon +31 -0
- package/README.md +4 -2
- package/agents/flow-analyzer/AGENT.md +71 -0
- package/bin/cteno-host-cli +4 -0
- package/bin/cteno-wiki +0 -0
- package/bin/rg +0 -0
- package/bin/secret-exec +0 -0
- package/manifest.json +12 -0
- package/package.json +33 -4
- package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
- package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
- package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
- package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
- package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
- package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
- package/plugins/cteno.inbox/.cteno-plugin/plugin.json +132 -0
- package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
- package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
- package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
- package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
- package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
- package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
- package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
- package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
- package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
- package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
- package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
- package/plugins/cteno.memory/ui/index.html +822 -0
- package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
- package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
- package/plugins/jev/.cteno-plugin/plugin.json +309 -0
- package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
- package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
- package/skills/agent-create/SKILL.md +86 -0
- package/skills/cteno-host-cli/SKILL.md +256 -0
- package/skills/docx/LICENSE +23 -0
- package/skills/docx/NOTICE.md +8 -0
- package/skills/docx/SKILL.md +69 -0
- package/skills/docx/references/cjk_typography.md +357 -0
- package/skills/docx/references/design_principles.md +819 -0
- package/skills/docx/references/revisions-and-comments.md +88 -0
- package/skills/docx/references/track_changes_guide.md +200 -0
- package/skills/docx/references/typography_guide.md +294 -0
- package/skills/docx/scripts/cjk_fonts.py +244 -0
- package/skills/docx/scripts/docx_comments.py +289 -0
- package/skills/docx/scripts/docx_common.py +92 -0
- package/skills/docx/scripts/docx_create.py +259 -0
- package/skills/docx/scripts/docx_edit.py +250 -0
- package/skills/docx/scripts/docx_read.py +149 -0
- package/skills/docx/scripts/docx_revisions.py +146 -0
- package/skills/docx/scripts/docx_template.py +70 -0
- package/skills/docx/scripts/docx_validate.py +156 -0
- package/skills/docx/scripts/render.py +250 -0
- package/skills/docx/tests/test_docx_skill.py +525 -0
- package/skills/home-devices/SKILL.md +121 -0
- package/skills/mcp/SKILL.md +72 -0
- package/skills/mcp/scripts/mcp_setup.py +439 -0
- package/skills/pdf/LICENSE +23 -0
- package/skills/pdf/NOTICE.md +8 -0
- package/skills/pdf/SKILL.md +63 -0
- package/skills/pdf/references/design-tokens.md +381 -0
- package/skills/pdf/references/forms.md +99 -0
- package/skills/pdf/references/nano-pdf-editing.md +40 -0
- package/skills/pdf/references/ocr-extraction.md +165 -0
- package/skills/pdf/scripts/_raster.py +76 -0
- package/skills/pdf/scripts/extract_marker.py +88 -0
- package/skills/pdf/scripts/extract_pymupdf.py +101 -0
- package/skills/pdf/scripts/pdf_create.py +191 -0
- package/skills/pdf/scripts/pdf_fill_form.py +97 -0
- package/skills/pdf/scripts/pdf_form_layout.py +168 -0
- package/skills/pdf/scripts/pdf_make_form.py +145 -0
- package/skills/pdf/scripts/pdf_merge.py +50 -0
- package/skills/pdf/scripts/pdf_meta.py +115 -0
- package/skills/pdf/scripts/pdf_page_image.py +99 -0
- package/skills/pdf/scripts/pdf_read.py +153 -0
- package/skills/pdf/scripts/pdf_secure.py +71 -0
- package/skills/pdf/scripts/pdf_split.py +84 -0
- package/skills/pdf/scripts/pdf_stamp.py +143 -0
- package/skills/pdf/scripts/pdf_watermark.py +51 -0
- package/skills/pdf/scripts/render.py +250 -0
- package/skills/pdf/tests/test_pdf_skill.py +415 -0
- package/skills/pptx/LICENSE +23 -0
- package/skills/pptx/NOTICE.md +8 -0
- package/skills/pptx/SKILL.md +62 -0
- package/skills/pptx/references/design-system.md +392 -0
- package/skills/pptx/references/pptxgenjs.md +420 -0
- package/skills/pptx/references/slide-types.md +413 -0
- package/skills/pptx/scripts/cjk_fonts.py +244 -0
- package/skills/pptx/scripts/pptx_create.py +240 -0
- package/skills/pptx/scripts/pptx_edit.py +436 -0
- package/skills/pptx/scripts/pptx_from_template.py +88 -0
- package/skills/pptx/scripts/pptx_read.py +131 -0
- package/skills/pptx/scripts/pptx_render.py +96 -0
- package/skills/pptx/scripts/render.py +250 -0
- package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
- package/skills/skill-create/SKILL.md +114 -0
- package/skills/video-generation/SKILL.md +27 -0
- package/skills/xlsx/LICENSE +23 -0
- package/skills/xlsx/NOTICE.md +8 -0
- package/skills/xlsx/SKILL.md +63 -0
- package/skills/xlsx/references/format.md +71 -0
- package/skills/xlsx/references/restructuring.md +71 -0
- package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
- package/skills/xlsx/scripts/formula_check.py +428 -0
- package/skills/xlsx/scripts/render.py +250 -0
- package/skills/xlsx/scripts/xlsx_create.py +265 -0
- package/skills/xlsx/scripts/xlsx_edit.py +269 -0
- package/skills/xlsx/scripts/xlsx_read.py +166 -0
- package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
- package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
- package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
- package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
- package/tools/create_goal/TOOL.md +36 -0
- package/tools/image_generation/TOOL.md +81 -0
- package/tools/skill/TOOL.md +117 -0
- package/tools/update_profile_notes/TOOL.md +39 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Export one sheet of an .xlsx workbook to CSV.
|
|
3
|
+
|
|
4
|
+
Dates/datetimes are written in ISO format; None becomes an empty field.
|
|
5
|
+
With --data-only, formula cells yield their cached results (present only
|
|
6
|
+
if the file was last saved by Excel/LibreOffice; openpyxl never computes).
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
xlsx_to_csv.py book.xlsx out.csv
|
|
10
|
+
xlsx_to_csv.py book.xlsx out.csv --sheet Data --encoding utf-8-sig
|
|
11
|
+
xlsx_to_csv.py book.xlsx out.csv --delimiter ';' --data-only
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import csv
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from datetime import date, datetime, time
|
|
20
|
+
|
|
21
|
+
from openpyxl import load_workbook
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def to_text(value):
|
|
25
|
+
if value is None:
|
|
26
|
+
return ""
|
|
27
|
+
if isinstance(value, datetime):
|
|
28
|
+
# Excel stores pure dates as midnight datetimes; emit a bare date.
|
|
29
|
+
if value.time() == time(0, 0):
|
|
30
|
+
return value.date().isoformat()
|
|
31
|
+
return value.isoformat()
|
|
32
|
+
if isinstance(value, (date, time)):
|
|
33
|
+
return value.isoformat()
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv=None):
|
|
38
|
+
# Text I/O is UTF-8 regardless of the locale (GBK on Chinese Windows).
|
|
39
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
40
|
+
try:
|
|
41
|
+
stream.reconfigure(encoding="utf-8")
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
ap = argparse.ArgumentParser(description=".xlsx sheet -> CSV exporter.")
|
|
45
|
+
ap.add_argument("file", help="input .xlsx path")
|
|
46
|
+
ap.add_argument("output", help="output CSV path")
|
|
47
|
+
ap.add_argument("--sheet", help="sheet name (default: active)")
|
|
48
|
+
ap.add_argument("--encoding", default="utf-8",
|
|
49
|
+
help="CSV output encoding (default utf-8)")
|
|
50
|
+
ap.add_argument("--delimiter", default=",")
|
|
51
|
+
ap.add_argument("--data-only", action="store_true",
|
|
52
|
+
help="cached formula results instead of formula strings")
|
|
53
|
+
args = ap.parse_args(argv)
|
|
54
|
+
|
|
55
|
+
wb = load_workbook(args.file, data_only=args.data_only)
|
|
56
|
+
ws = wb[args.sheet] if args.sheet else wb.active
|
|
57
|
+
|
|
58
|
+
with open(args.output, "w", newline="", encoding=args.encoding) as fh:
|
|
59
|
+
writer = csv.writer(fh, delimiter=args.delimiter)
|
|
60
|
+
count = 0
|
|
61
|
+
for row in ws.iter_rows(values_only=True):
|
|
62
|
+
writer.writerow([to_text(v) for v in row])
|
|
63
|
+
count += 1
|
|
64
|
+
|
|
65
|
+
print(json.dumps({"ok": True, "output": args.output, "sheet": ws.title,
|
|
66
|
+
"rows": count}, ensure_ascii=False))
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
try:
|
|
72
|
+
sys.exit(main())
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
|
|
75
|
+
sys.exit(1)
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""End-to-end tests for the xlsx skill helper scripts.
|
|
2
|
+
|
|
3
|
+
Runs each script as a subprocess under LC_ALL=C to prove all text I/O
|
|
4
|
+
uses explicit UTF-8 rather than locale defaults. No network access.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import date
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
from openpyxl import load_workbook
|
|
19
|
+
|
|
20
|
+
SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(script, *args, expect_ok=True):
|
|
24
|
+
env = dict(os.environ, LC_ALL="C", LANG="C")
|
|
25
|
+
env.pop("PYTHONIOENCODING", None)
|
|
26
|
+
proc = subprocess.run(
|
|
27
|
+
[sys.executable, str(SCRIPTS / script), *map(str, args)],
|
|
28
|
+
capture_output=True, text=True, env=env, encoding="utf-8", check=False)
|
|
29
|
+
if expect_ok:
|
|
30
|
+
assert proc.returncode == 0, f"{script} failed: {proc.stderr}"
|
|
31
|
+
return proc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SPEC = {
|
|
35
|
+
"full_calc_on_load": True,
|
|
36
|
+
"sheets": [
|
|
37
|
+
{
|
|
38
|
+
"name": "Data",
|
|
39
|
+
"rows": [
|
|
40
|
+
[
|
|
41
|
+
{"value": "Region", "bold": True, "fill": "DDEBF7",
|
|
42
|
+
"border": "thin", "align": "center", "valign": "center"},
|
|
43
|
+
{"value": "Sales", "bold": True, "fill": "DDEBF7"},
|
|
44
|
+
{"value": "Growth", "bold": True},
|
|
45
|
+
{"value": "Audited", "bold": True},
|
|
46
|
+
{"value": "Closed", "bold": True},
|
|
47
|
+
{"value": "Status", "bold": True},
|
|
48
|
+
],
|
|
49
|
+
["North", 1500.5, {"value": 0.125, "format": "0.0%"}, True,
|
|
50
|
+
{"value": "2026-01-31", "type": "date",
|
|
51
|
+
"format": "yyyy-mm-dd"}, "Yes"],
|
|
52
|
+
["South", 900, {"value": -0.03, "format": "0.0%"}, False,
|
|
53
|
+
{"value": "2026-02-28", "type": "date",
|
|
54
|
+
"format": "yyyy-mm-dd"}, "No"],
|
|
55
|
+
["East", 2100, {"value": 0.4, "format": "0.0%"}, True,
|
|
56
|
+
{"value": "2026-03-31", "type": "date",
|
|
57
|
+
"format": "yyyy-mm-dd"}, "Yes"],
|
|
58
|
+
],
|
|
59
|
+
"cells": {
|
|
60
|
+
"A6": {"value": "Total", "bold": True, "italic": True,
|
|
61
|
+
"font_size": 12, "font_color": "1F4E78"},
|
|
62
|
+
"B6": {"formula": "SUM(B2:B4)", "format": "$#,##0.00"},
|
|
63
|
+
},
|
|
64
|
+
"column_widths": {"A": 18, "B": 14},
|
|
65
|
+
"row_heights": {"1": 24},
|
|
66
|
+
"merges": ["A8:C8"],
|
|
67
|
+
"freeze_panes": "A2",
|
|
68
|
+
"autofilter": "A1:F4",
|
|
69
|
+
"conditional_formats": [
|
|
70
|
+
{"range": "B2:B4", "type": "cell_is",
|
|
71
|
+
"operator": "greaterThan", "formula": ["1000"],
|
|
72
|
+
"fill": "C6EFCE"},
|
|
73
|
+
{"range": "C2:C4", "type": "color_scale"},
|
|
74
|
+
],
|
|
75
|
+
"charts": [
|
|
76
|
+
{"type": "bar", "title": "Sales by region", "anchor": "H2",
|
|
77
|
+
"data": "B1:B4", "categories": "A2:A4"},
|
|
78
|
+
{"type": "line", "title": "Growth", "anchor": "H18",
|
|
79
|
+
"data": "C1:C4", "categories": "A2:A4"},
|
|
80
|
+
{"type": "pie", "title": "Share", "anchor": "P2",
|
|
81
|
+
"data": "B2:B4", "categories": "A2:A4",
|
|
82
|
+
"titles_from_data": False},
|
|
83
|
+
],
|
|
84
|
+
"validations": [
|
|
85
|
+
{"range": "F2:F10", "type": "list",
|
|
86
|
+
"formula1": '"Yes,No,Maybe"'},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{"name": "Notes", "rows": [["Zürich", "Фамилия", "12,5%"]]},
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.fixture
|
|
95
|
+
def workbook(tmp_path):
|
|
96
|
+
spec_path = tmp_path / "spec.json"
|
|
97
|
+
spec_path.write_text(json.dumps(SPEC), encoding="utf-8")
|
|
98
|
+
out = tmp_path / "report.xlsx"
|
|
99
|
+
proc = run("xlsx_create.py", spec_path, out)
|
|
100
|
+
summary = json.loads(proc.stdout)
|
|
101
|
+
assert summary["ok"] and summary["sheets"] == ["Data", "Notes"]
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_create_features_roundtrip(workbook):
|
|
106
|
+
wb = load_workbook(workbook)
|
|
107
|
+
ws = wb["Data"]
|
|
108
|
+
# typed values
|
|
109
|
+
assert ws["B2"].value == 1500.5
|
|
110
|
+
assert ws["D2"].value is True
|
|
111
|
+
e2 = ws["E2"].value
|
|
112
|
+
assert (e2.date() if hasattr(e2, "date") else e2) == date(2026, 1, 31)
|
|
113
|
+
# formula + number formats
|
|
114
|
+
assert ws["B6"].value == "=SUM(B2:B4)"
|
|
115
|
+
assert ws["B6"].number_format == "$#,##0.00"
|
|
116
|
+
assert ws["C2"].number_format == "0.0%"
|
|
117
|
+
assert ws["E2"].number_format == "yyyy-mm-dd"
|
|
118
|
+
# styling
|
|
119
|
+
assert ws["A1"].font.bold is True
|
|
120
|
+
assert ws["A1"].fill.fgColor.rgb.endswith("DDEBF7")
|
|
121
|
+
assert ws["A1"].border.left.style == "thin"
|
|
122
|
+
assert ws["A1"].alignment.horizontal == "center"
|
|
123
|
+
assert ws["A6"].font.italic is True and ws["A6"].font.size == 12
|
|
124
|
+
# dimensions
|
|
125
|
+
assert ws.column_dimensions["A"].width == 18
|
|
126
|
+
assert ws.row_dimensions[1].height == 24
|
|
127
|
+
# merges / freeze / autofilter
|
|
128
|
+
assert "A8:C8" in [str(r) for r in ws.merged_cells.ranges]
|
|
129
|
+
assert ws.freeze_panes == "A2"
|
|
130
|
+
assert ws.auto_filter.ref == "A1:F4"
|
|
131
|
+
# conditional formatting, charts, validation
|
|
132
|
+
assert len(list(ws.conditional_formatting)) == 2
|
|
133
|
+
assert len(ws._charts) == 3
|
|
134
|
+
types = {type(c).__name__ for c in ws._charts}
|
|
135
|
+
assert types == {"BarChart", "LineChart", "PieChart"}
|
|
136
|
+
assert len(ws.data_validations.dataValidation) == 1
|
|
137
|
+
# recalc flag
|
|
138
|
+
assert wb.calculation.fullCalcOnLoad is True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_read_sheets_json_formulas(workbook, tmp_path):
|
|
142
|
+
inv = json.loads(run("xlsx_read.py", workbook, "--sheets").stdout)
|
|
143
|
+
names = [s["name"] for s in inv["sheets"]]
|
|
144
|
+
assert names == ["Data", "Notes"]
|
|
145
|
+
data_info = inv["sheets"][0]
|
|
146
|
+
assert data_info["charts"] == 3
|
|
147
|
+
assert "A8:C8" in data_info["merged"]
|
|
148
|
+
assert data_info["freeze_panes"] == "A2"
|
|
149
|
+
|
|
150
|
+
dump = json.loads(
|
|
151
|
+
run("xlsx_read.py", workbook, "--json", "--sheet", "Data").stdout)
|
|
152
|
+
assert dump["rows"][1][0] == "North"
|
|
153
|
+
assert dump["rows"][1][4] == "2026-01-31T00:00:00"
|
|
154
|
+
|
|
155
|
+
notes = json.loads(
|
|
156
|
+
run("xlsx_read.py", workbook, "--json", "--sheet", "Notes").stdout)
|
|
157
|
+
assert notes["rows"][0] == ["Zürich", "Фамилия", "12,5%"]
|
|
158
|
+
|
|
159
|
+
formulas = json.loads(run("xlsx_read.py", workbook, "--formulas").stdout)
|
|
160
|
+
entry = next(f for f in formulas["formulas"] if f["cell"] == "B6")
|
|
161
|
+
assert entry["formula"] == "=SUM(B2:B4)"
|
|
162
|
+
# openpyxl never computes: cached value absent on a fresh file
|
|
163
|
+
assert entry["cached"] is None
|
|
164
|
+
|
|
165
|
+
csv_out = tmp_path / "data.csv"
|
|
166
|
+
run("xlsx_read.py", workbook, "--csv", "--sheet", "Notes",
|
|
167
|
+
"--out", csv_out)
|
|
168
|
+
text = csv_out.read_text(encoding="utf-8")
|
|
169
|
+
assert "Zürich" in text and "Фамилия" in text
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_csv_roundtrip_nonascii(tmp_path):
|
|
173
|
+
src = tmp_path / "src.csv"
|
|
174
|
+
with open(src, "w", newline="", encoding="utf-8") as fh:
|
|
175
|
+
w = csv.writer(fh)
|
|
176
|
+
w.writerow(["City", "Share", "Surname", "Active", "When"])
|
|
177
|
+
w.writerow(["Zürich", "12,5%", "Фамилия", "true", "2026-05-01"])
|
|
178
|
+
w.writerow(["Oslo", "7", "Ås", "false", "2026-06-01"])
|
|
179
|
+
xlsx = tmp_path / "conv.xlsx"
|
|
180
|
+
run("csv_to_xlsx.py", src, xlsx, "--sheet-name", "Import")
|
|
181
|
+
|
|
182
|
+
wb = load_workbook(xlsx)
|
|
183
|
+
ws = wb["Import"]
|
|
184
|
+
assert ws["A2"].value == "Zürich"
|
|
185
|
+
assert ws["B2"].value == "12,5%" # decimal comma stays a string
|
|
186
|
+
assert ws["C2"].value == "Фамилия"
|
|
187
|
+
assert ws["D2"].value is True # bool inferred
|
|
188
|
+
assert ws["E2"].value.date() == date(2026, 5, 1) # date inferred
|
|
189
|
+
assert ws["B3"].value == 7 # int inferred
|
|
190
|
+
assert ws["A1"].font.bold is True # styled header
|
|
191
|
+
assert ws.freeze_panes == "A2"
|
|
192
|
+
|
|
193
|
+
back = tmp_path / "back.csv"
|
|
194
|
+
run("xlsx_to_csv.py", xlsx, back, "--sheet", "Import")
|
|
195
|
+
with open(back, newline="", encoding="utf-8") as fh:
|
|
196
|
+
rows = list(csv.reader(fh))
|
|
197
|
+
assert rows[1][0] == "Zürich"
|
|
198
|
+
assert rows[1][2] == "Фамилия"
|
|
199
|
+
assert rows[1][3] == "True"
|
|
200
|
+
assert rows[1][4] == "2026-05-01"
|
|
201
|
+
|
|
202
|
+
# encoding override
|
|
203
|
+
latin = tmp_path / "latin.csv"
|
|
204
|
+
run("xlsx_to_csv.py", xlsx, latin, "--sheet", "Import",
|
|
205
|
+
"--encoding", "utf-8-sig")
|
|
206
|
+
assert latin.read_bytes().startswith(b"\xef\xbb\xbf")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_edit_existing(workbook, tmp_path):
|
|
210
|
+
edited = tmp_path / "edited.xlsx"
|
|
211
|
+
proc = run("xlsx_edit.py", workbook, "--sheet", "Notes",
|
|
212
|
+
"--out", edited,
|
|
213
|
+
"--copy-sheet", "Notes:Backup",
|
|
214
|
+
"--rename-sheet", "Data:Main",
|
|
215
|
+
"--set", "B1=Änderung",
|
|
216
|
+
"--set", "C1=99.5",
|
|
217
|
+
"--set", "D1=2026-12-24",
|
|
218
|
+
"--set", "E1==SUM(C1:C1)",
|
|
219
|
+
"--append", '["appended", 1, false]',
|
|
220
|
+
"--insert-rows", "1:1",
|
|
221
|
+
"--recalc")
|
|
222
|
+
result = json.loads(proc.stdout)
|
|
223
|
+
assert result["ok"]
|
|
224
|
+
|
|
225
|
+
wb = load_workbook(edited)
|
|
226
|
+
assert set(wb.sheetnames) == {"Main", "Notes", "Backup"}
|
|
227
|
+
ws = wb["Notes"]
|
|
228
|
+
# insert-rows ran before --set per documented order, so row 1 is blank
|
|
229
|
+
# and original data moved to row 2... check documented ordering:
|
|
230
|
+
# structural ops run before --set, so B1 etc. were written after insert.
|
|
231
|
+
assert ws["B1"].value == "Änderung"
|
|
232
|
+
assert ws["C1"].value == 99.5
|
|
233
|
+
assert ws["D1"].value.date() == date(2026, 12, 24)
|
|
234
|
+
assert ws["E1"].value == "=SUM(C1:C1)"
|
|
235
|
+
assert wb.calculation.fullCalcOnLoad is True
|
|
236
|
+
# appended row present
|
|
237
|
+
found = [r for r in ws.iter_rows(values_only=True)
|
|
238
|
+
if r and r[0] == "appended"]
|
|
239
|
+
assert found and found[0][1] == 1 and found[0][2] is False
|
|
240
|
+
# copy preserved data
|
|
241
|
+
assert wb["Backup"]["A1"].value == "Zürich"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_help_and_errors():
|
|
245
|
+
for script in ["xlsx_create.py", "xlsx_read.py", "xlsx_edit.py",
|
|
246
|
+
"csv_to_xlsx.py", "xlsx_to_csv.py",
|
|
247
|
+
"xlsx_restructure.py", "xlsx_recalc.py"]:
|
|
248
|
+
proc = run(script, "--help")
|
|
249
|
+
assert "usage" in proc.stdout.lower()
|
|
250
|
+
bad = run("xlsx_read.py", "/nonexistent.xlsx", "--sheets",
|
|
251
|
+
expect_ok=False)
|
|
252
|
+
assert bad.returncode != 0
|
|
253
|
+
assert json.loads(bad.stderr)["ok"] is False
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Reference-aware restructuring (xlsx_restructure.py)
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
RESTRUCTURE_SPEC = {
|
|
261
|
+
"defined_names": {"SalesRange": "'Data'!$B$2:$B$4"},
|
|
262
|
+
"sheets": [
|
|
263
|
+
{
|
|
264
|
+
"name": "Data",
|
|
265
|
+
"rows": [
|
|
266
|
+
["Region", "Sales", "Weight"],
|
|
267
|
+
["North", 100, 0.5],
|
|
268
|
+
["South", 200, 0.3],
|
|
269
|
+
["East", 300, 0.2],
|
|
270
|
+
[None, None, None],
|
|
271
|
+
["Total", None, None],
|
|
272
|
+
],
|
|
273
|
+
"cells": {
|
|
274
|
+
"B6": {"formula": "SUM(B2:B4)"},
|
|
275
|
+
"C6": {"formula": "$B$2*C2"},
|
|
276
|
+
"D6": {"formula": "LOG10(B4)"},
|
|
277
|
+
"E6": {"formula": "SUM(B:B)"},
|
|
278
|
+
"F6": {"formula": '"row B2: "&B2'},
|
|
279
|
+
},
|
|
280
|
+
"merges": ["E2:E4", "A7:B7"],
|
|
281
|
+
"freeze_panes": "A2",
|
|
282
|
+
"autofilter": "A1:C4",
|
|
283
|
+
"conditional_formats": [
|
|
284
|
+
{"range": "B2:B4", "type": "cell_is",
|
|
285
|
+
"operator": "greaterThan", "formula": ["150"],
|
|
286
|
+
"fill": "C6EFCE"},
|
|
287
|
+
],
|
|
288
|
+
"validations": [
|
|
289
|
+
{"range": "C2:C4", "type": "list",
|
|
290
|
+
"formula1": '"0.2,0.3,0.5"'},
|
|
291
|
+
],
|
|
292
|
+
"tables": [
|
|
293
|
+
{"name": "SalesTbl", "range": "A1:C4"},
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"name": "Summary",
|
|
298
|
+
"rows": [["Grand total"]],
|
|
299
|
+
"cells": {
|
|
300
|
+
"B1": {"formula": "SUM(Data!B2:B4)"},
|
|
301
|
+
"B2": {"formula": "'Data'!$B$3"},
|
|
302
|
+
"B3": {"formula": "SUM(A1:A1)"},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@pytest.fixture
|
|
310
|
+
def restructure_book(tmp_path):
|
|
311
|
+
spec_path = tmp_path / "rspec.json"
|
|
312
|
+
spec_path.write_text(json.dumps(RESTRUCTURE_SPEC), encoding="utf-8")
|
|
313
|
+
out = tmp_path / "restructure.xlsx"
|
|
314
|
+
run("xlsx_create.py", spec_path, out)
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_restructure_insert_rows_shifts_everything(restructure_book):
|
|
319
|
+
# merge A6:C6 gets pushed down; A1:A1 merge is before the insert point
|
|
320
|
+
proc = run("xlsx_restructure.py", restructure_book,
|
|
321
|
+
"--sheet", "Data", "--insert-rows", "3:2")
|
|
322
|
+
report = json.loads(proc.stdout)
|
|
323
|
+
assert report["ok"] and report["op"] == "insert"
|
|
324
|
+
|
|
325
|
+
wb = load_workbook(restructure_book)
|
|
326
|
+
data, summary = wb["Data"], wb["Summary"]
|
|
327
|
+
# values physically moved
|
|
328
|
+
assert data["A2"].value == "North"
|
|
329
|
+
assert data["A5"].value == "South" # was row 3
|
|
330
|
+
assert data["A8"].value == "Total" # was row 6
|
|
331
|
+
# same-sheet formulas rewritten (range expanded across insert point)
|
|
332
|
+
assert data["B8"].value == "=SUM(B2:B6)"
|
|
333
|
+
# absolute ref before insert point unchanged; relative arm shifted
|
|
334
|
+
assert data["C8"].value == "=$B$2*C2"
|
|
335
|
+
# function names, whole-column refs, string literals untouched
|
|
336
|
+
assert data["D8"].value == "=LOG10(B6)"
|
|
337
|
+
assert data["E8"].value == "=SUM(B:B)"
|
|
338
|
+
assert data["F8"].value == '="row B2: "&B2'
|
|
339
|
+
# cross-sheet formulas on the OTHER sheet rewritten
|
|
340
|
+
assert summary["B1"].value == "=SUM(Data!B2:B6)"
|
|
341
|
+
assert summary["B2"].value == "='Data'!$B$5"
|
|
342
|
+
# Summary-local refs not confused with Data refs
|
|
343
|
+
assert summary["B3"].value == "=SUM(A1:A1)"
|
|
344
|
+
# merges: E2:E4 spans the insert point -> expanded; A7:B7 -> shifted
|
|
345
|
+
merged = [str(r) for r in data.merged_cells.ranges]
|
|
346
|
+
assert "E2:E6" in merged and "A9:B9" in merged
|
|
347
|
+
# autofilter expanded, freeze panes intact
|
|
348
|
+
assert data.auto_filter.ref == "A1:C6"
|
|
349
|
+
assert data.freeze_panes == "A2"
|
|
350
|
+
# validation + conditional format ranges shifted
|
|
351
|
+
dv = data.data_validations.dataValidation[0]
|
|
352
|
+
assert str(dv.sqref) == "C2:C6"
|
|
353
|
+
cf = next(iter(data.conditional_formatting))
|
|
354
|
+
assert str(cf.sqref) == "B2:B6"
|
|
355
|
+
# native table expanded
|
|
356
|
+
assert data.tables["SalesTbl"].ref == "A1:C6"
|
|
357
|
+
# defined name rewritten
|
|
358
|
+
assert wb.defined_names["SalesRange"].attr_text == "'Data'!$B$2:$B$6"
|
|
359
|
+
# report is honest about limits
|
|
360
|
+
assert "chart anchors" in report["not_shifted"]
|
|
361
|
+
assert any(f["cell"] == "B1" and f["sheet"] == "Summary"
|
|
362
|
+
for f in report["formulas"])
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_restructure_delete_rows_and_ref_errors(restructure_book):
|
|
366
|
+
run("xlsx_restructure.py", restructure_book,
|
|
367
|
+
"--sheet", "Data", "--delete-rows", "3")
|
|
368
|
+
wb = load_workbook(restructure_book)
|
|
369
|
+
data, summary = wb["Data"], wb["Summary"]
|
|
370
|
+
assert data["A3"].value == "East" # South deleted
|
|
371
|
+
assert data["B5"].value == "=SUM(B2:B3)" # range clamped
|
|
372
|
+
# single-cell ref into the deleted row becomes #REF!
|
|
373
|
+
assert summary["B2"].value == "='Data'!#REF!"
|
|
374
|
+
assert summary["B1"].value == "=SUM(Data!B2:B3)"
|
|
375
|
+
assert data.tables["SalesTbl"].ref == "A1:C3"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_restructure_insert_cols(restructure_book):
|
|
379
|
+
proc = run("xlsx_restructure.py", restructure_book,
|
|
380
|
+
"--sheet", "Data", "--insert-cols", "B:1")
|
|
381
|
+
report = json.loads(proc.stdout)
|
|
382
|
+
assert report["axis"] == "cols" and report["index"] == 2
|
|
383
|
+
wb = load_workbook(restructure_book)
|
|
384
|
+
data, summary = wb["Data"], wb["Summary"]
|
|
385
|
+
assert data["C2"].value == 100 # Sales moved B->C
|
|
386
|
+
assert data["C6"].value == "=SUM(C2:C4)"
|
|
387
|
+
assert data["D6"].value == "=$C$2*D2"
|
|
388
|
+
assert summary["B1"].value == "=SUM(Data!C2:C4)"
|
|
389
|
+
assert wb.defined_names["SalesRange"].attr_text == "'Data'!$C$2:$C$4"
|
|
390
|
+
merged = [str(r) for r in data.merged_cells.ranges]
|
|
391
|
+
assert "F2:F4" in merged # merge shifted right
|
|
392
|
+
assert "A7:C7" in merged # merge expanded across col B
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# Tables, defined names, hyperlinks, notes, protection (edit + read paths)
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def test_tables_create_append_list(tmp_path):
|
|
400
|
+
spec = {"sheets": [{"name": "T",
|
|
401
|
+
"rows": [["Item", "Qty"], ["a", 1], ["b", 2]],
|
|
402
|
+
"tables": [{"name": "Stock", "range": "A1:B3",
|
|
403
|
+
"style": "TableStyleLight1"}]}]}
|
|
404
|
+
spec_path = tmp_path / "tspec.json"
|
|
405
|
+
spec_path.write_text(json.dumps(spec), encoding="utf-8")
|
|
406
|
+
book = tmp_path / "tables.xlsx"
|
|
407
|
+
run("xlsx_create.py", spec_path, book)
|
|
408
|
+
|
|
409
|
+
wb = load_workbook(book)
|
|
410
|
+
tbl = wb["T"].tables["Stock"]
|
|
411
|
+
assert tbl.ref == "A1:B3"
|
|
412
|
+
assert tbl.tableStyleInfo.name == "TableStyleLight1"
|
|
413
|
+
|
|
414
|
+
# --add-table + --table-append auto-extends the range
|
|
415
|
+
run("xlsx_edit.py", book, "--sheet", "T",
|
|
416
|
+
"--add-table", "Extra:D1:E2",
|
|
417
|
+
"--table-append", 'Stock=["c", 3]')
|
|
418
|
+
wb = load_workbook(book)
|
|
419
|
+
ws = wb["T"]
|
|
420
|
+
assert ws.tables["Stock"].ref == "A1:B4"
|
|
421
|
+
assert ws["A4"].value == "c" and ws["B4"].value == 3
|
|
422
|
+
assert ws.tables["Extra"].ref == "D1:E2"
|
|
423
|
+
|
|
424
|
+
listing = json.loads(
|
|
425
|
+
run("xlsx_edit.py", book, "--sheet", "T", "--list-tables").stdout)
|
|
426
|
+
assert listing["tables"]["Stock"]["ref"] == "A1:B4"
|
|
427
|
+
assert set(listing["tables"]) == {"Stock", "Extra"}
|
|
428
|
+
# tables also appear in the read inventory
|
|
429
|
+
inv = json.loads(run("xlsx_read.py", book, "--sheets").stdout)
|
|
430
|
+
assert inv["sheets"][0]["tables"]["Stock"] == "A1:B4"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_names_hyperlinks_notes(tmp_path):
|
|
434
|
+
spec = {
|
|
435
|
+
"defined_names": {"Rate": "'D'!$B$1"},
|
|
436
|
+
"sheets": [{"name": "D", "cells": {
|
|
437
|
+
"A1": {"value": "docs",
|
|
438
|
+
"hyperlink": "https://example.com/docs"},
|
|
439
|
+
"B1": {"value": 0.07, "note": "quarterly rate"},
|
|
440
|
+
"C1": {"value": 1, "note": {"text": "check", "author": "QA"}},
|
|
441
|
+
}}],
|
|
442
|
+
}
|
|
443
|
+
spec_path = tmp_path / "nspec.json"
|
|
444
|
+
spec_path.write_text(json.dumps(spec), encoding="utf-8")
|
|
445
|
+
book = tmp_path / "names.xlsx"
|
|
446
|
+
run("xlsx_create.py", spec_path, book)
|
|
447
|
+
|
|
448
|
+
wb = load_workbook(book)
|
|
449
|
+
ws = wb["D"]
|
|
450
|
+
assert ws["A1"].hyperlink.target == "https://example.com/docs"
|
|
451
|
+
assert ws["B1"].comment.text == "quarterly rate"
|
|
452
|
+
assert ws["C1"].comment.author == "QA"
|
|
453
|
+
assert wb.defined_names["Rate"].attr_text == "'D'!$B$1"
|
|
454
|
+
|
|
455
|
+
# edit path: add/delete names, hyperlink, note, clear note
|
|
456
|
+
run("xlsx_edit.py", book, "--sheet", "D",
|
|
457
|
+
"--define-name", "Extra='D'!$C$1",
|
|
458
|
+
"--delete-name", "Rate",
|
|
459
|
+
"--hyperlink", "D1=https://example.com/more|More",
|
|
460
|
+
"--note", "D1=see more|Reviewer",
|
|
461
|
+
"--clear-note", "B1")
|
|
462
|
+
wb = load_workbook(book)
|
|
463
|
+
ws = wb["D"]
|
|
464
|
+
assert "Rate" not in wb.defined_names
|
|
465
|
+
assert wb.defined_names["Extra"].attr_text == "'D'!$C$1"
|
|
466
|
+
assert ws["D1"].hyperlink.target == "https://example.com/more"
|
|
467
|
+
assert ws["D1"].value == "More"
|
|
468
|
+
assert ws["D1"].comment.author == "Reviewer"
|
|
469
|
+
assert ws["B1"].comment is None
|
|
470
|
+
|
|
471
|
+
# read path: --notes and --names JSON output
|
|
472
|
+
notes = json.loads(run("xlsx_read.py", book, "--notes").stdout)["notes"]
|
|
473
|
+
coords = {(n["cell"], n["author"]) for n in notes}
|
|
474
|
+
assert ("D1", "Reviewer") in coords and ("C1", "QA") in coords
|
|
475
|
+
names = json.loads(run("xlsx_read.py", book, "--names").stdout)
|
|
476
|
+
assert names["defined_names"] == {"Extra": "'D'!$C$1"}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_sheet_protection(tmp_path):
|
|
480
|
+
spec = {"sheets": [{"name": "P", "rows": [["locked", "open"]],
|
|
481
|
+
"protection": {"password": "your-password",
|
|
482
|
+
"unlock": ["B1:B1"]}}]}
|
|
483
|
+
spec_path = tmp_path / "pspec.json"
|
|
484
|
+
spec_path.write_text(json.dumps(spec), encoding="utf-8")
|
|
485
|
+
book = tmp_path / "prot.xlsx"
|
|
486
|
+
run("xlsx_create.py", spec_path, book)
|
|
487
|
+
|
|
488
|
+
wb = load_workbook(book)
|
|
489
|
+
ws = wb["P"]
|
|
490
|
+
assert ws.protection.sheet is True
|
|
491
|
+
assert ws.protection.password # hash stored
|
|
492
|
+
assert ws["B1"].protection.locked is False
|
|
493
|
+
assert ws["A1"].protection.locked is not False
|
|
494
|
+
inv = json.loads(run("xlsx_read.py", book, "--sheets").stdout)
|
|
495
|
+
assert inv["sheets"][0]["protected"] is True
|
|
496
|
+
|
|
497
|
+
# edit path on a fresh unprotected sheet
|
|
498
|
+
plain = tmp_path / "plain.xlsx"
|
|
499
|
+
spec_path.write_text(json.dumps(
|
|
500
|
+
{"sheets": [{"name": "P", "rows": [["a", "b"]]}]}), encoding="utf-8")
|
|
501
|
+
run("xlsx_create.py", spec_path, plain)
|
|
502
|
+
run("xlsx_edit.py", plain, "--sheet", "P",
|
|
503
|
+
"--protect", "your-password", "--unlock", "B1:B1")
|
|
504
|
+
ws = load_workbook(plain)["P"]
|
|
505
|
+
assert ws.protection.sheet is True and ws["B1"].protection.locked is False
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
# Headless recalculation (xlsx_recalc.py) — branches on soffice presence
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def test_recalc_reports_json_both_ways(tmp_path):
|
|
513
|
+
spec = {"sheets": [{"name": "R", "rows": [[2], [3]],
|
|
514
|
+
"cells": {"A3": {"formula": "SUM(A1:A2)"}}}]}
|
|
515
|
+
spec_path = tmp_path / "cspec.json"
|
|
516
|
+
spec_path.write_text(json.dumps(spec), encoding="utf-8")
|
|
517
|
+
book = tmp_path / "calc.xlsx"
|
|
518
|
+
run("xlsx_create.py", spec_path, book)
|
|
519
|
+
|
|
520
|
+
# absent-soffice branch is always testable by hiding PATH
|
|
521
|
+
env = dict(os.environ, LC_ALL="C", LANG="C", PATH=str(tmp_path), SOFFICE="")
|
|
522
|
+
proc = subprocess.run(
|
|
523
|
+
[sys.executable, str(SCRIPTS / "xlsx_recalc.py"), str(book)],
|
|
524
|
+
capture_output=True, text=True, env=env, encoding="utf-8", check=False)
|
|
525
|
+
assert proc.returncode == 0
|
|
526
|
+
absent = json.loads(proc.stdout)
|
|
527
|
+
assert absent["recalculated"] is False and "soffice" in absent["reason"]
|
|
528
|
+
assert "guidance" in absent
|
|
529
|
+
|
|
530
|
+
sys.path.insert(0, str(SCRIPTS))
|
|
531
|
+
from render import find_soffice
|
|
532
|
+
if not find_soffice():
|
|
533
|
+
pytest.skip("LibreOffice not installed; absent branch covered above")
|
|
534
|
+
|
|
535
|
+
out = tmp_path / "calced.xlsx"
|
|
536
|
+
proc = run("xlsx_recalc.py", book, "--out", out, "--timeout", "300")
|
|
537
|
+
result = json.loads(proc.stdout)
|
|
538
|
+
assert result["recalculated"] is True
|
|
539
|
+
assert result["formula_cells"] == 1
|
|
540
|
+
assert result["with_cached_values"] == 1
|
|
541
|
+
# cached value now visible to --formulas
|
|
542
|
+
formulas = json.loads(run("xlsx_read.py", out, "--formulas").stdout)
|
|
543
|
+
entry = formulas["formulas"][0]
|
|
544
|
+
assert entry["formula"] == "=SUM(A1:A2)" and entry["cached"] == 5
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: "create_goal"
|
|
3
|
+
name: "Create Goal"
|
|
4
|
+
description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. Set token_budget only when an explicit positive token budget is requested; omit it or pass null for an unlimited/no-budget goal. Fails if a goal exists; use update_goal only for status."
|
|
5
|
+
category: "system"
|
|
6
|
+
version: "1.0.0"
|
|
7
|
+
supports_background: false
|
|
8
|
+
always_load: true
|
|
9
|
+
input_schema:
|
|
10
|
+
type: object
|
|
11
|
+
properties:
|
|
12
|
+
objective:
|
|
13
|
+
type: string
|
|
14
|
+
description: "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails."
|
|
15
|
+
token_budget:
|
|
16
|
+
type:
|
|
17
|
+
- integer
|
|
18
|
+
- "null"
|
|
19
|
+
description: "Optional positive token budget for the new active goal. Set only when the user explicitly requested a budget; null or omission means unlimited/no token budget."
|
|
20
|
+
required:
|
|
21
|
+
- objective
|
|
22
|
+
is_read_only: false
|
|
23
|
+
is_concurrency_safe: false
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
# Create Goal
|
|
27
|
+
|
|
28
|
+
Start a persisted goal only when the user explicitly asks for goal mode.
|
|
29
|
+
|
|
30
|
+
## Budget contract
|
|
31
|
+
|
|
32
|
+
- Omit `token_budget`, or pass `null`, for an unlimited goal with no token budget.
|
|
33
|
+
- Pass a positive integer only when the user explicitly requested that budget.
|
|
34
|
+
- Never invent a default token budget.
|
|
35
|
+
|
|
36
|
+
The runtime implementation and schema are authoritative. A Rust consistency test compares this declaration with the runtime registration so the documentation cannot silently drift.
|