@cteno/sdk-linux-x64 0.0.0-stage → 2.10.13

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.
Files changed (116) hide show
  1. package/LICENSE.daemon +31 -0
  2. package/README.md +4 -2
  3. package/agents/flow-analyzer/AGENT.md +71 -0
  4. package/bin/cteno-host-cli +4 -0
  5. package/bin/cteno-wiki +0 -0
  6. package/bin/rg +0 -0
  7. package/bin/secret-exec +0 -0
  8. package/manifest.json +12 -0
  9. package/package.json +33 -4
  10. package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
  11. package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
  12. package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
  13. package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
  14. package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
  15. package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
  16. package/plugins/cteno.inbox/.cteno-plugin/plugin.json +128 -0
  17. package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
  18. package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
  19. package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
  20. package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
  21. package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
  22. package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
  23. package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
  24. package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
  25. package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
  26. package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
  27. package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
  28. package/plugins/cteno.memory/ui/index.html +822 -0
  29. package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
  30. package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
  31. package/plugins/jev/.cteno-plugin/plugin.json +305 -0
  32. package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
  33. package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
  34. package/skills/agent-create/SKILL.md +86 -0
  35. package/skills/cteno-host-cli/SKILL.md +248 -0
  36. package/skills/docx/LICENSE +23 -0
  37. package/skills/docx/NOTICE.md +8 -0
  38. package/skills/docx/SKILL.md +69 -0
  39. package/skills/docx/references/cjk_typography.md +357 -0
  40. package/skills/docx/references/design_principles.md +819 -0
  41. package/skills/docx/references/revisions-and-comments.md +88 -0
  42. package/skills/docx/references/track_changes_guide.md +200 -0
  43. package/skills/docx/references/typography_guide.md +294 -0
  44. package/skills/docx/scripts/cjk_fonts.py +244 -0
  45. package/skills/docx/scripts/docx_comments.py +289 -0
  46. package/skills/docx/scripts/docx_common.py +92 -0
  47. package/skills/docx/scripts/docx_create.py +259 -0
  48. package/skills/docx/scripts/docx_edit.py +250 -0
  49. package/skills/docx/scripts/docx_read.py +149 -0
  50. package/skills/docx/scripts/docx_revisions.py +146 -0
  51. package/skills/docx/scripts/docx_template.py +70 -0
  52. package/skills/docx/scripts/docx_validate.py +156 -0
  53. package/skills/docx/scripts/render.py +250 -0
  54. package/skills/docx/tests/test_docx_skill.py +525 -0
  55. package/skills/home-devices/SKILL.md +121 -0
  56. package/skills/mcp/SKILL.md +72 -0
  57. package/skills/mcp/scripts/mcp_setup.py +439 -0
  58. package/skills/pdf/LICENSE +23 -0
  59. package/skills/pdf/NOTICE.md +8 -0
  60. package/skills/pdf/SKILL.md +63 -0
  61. package/skills/pdf/references/design-tokens.md +381 -0
  62. package/skills/pdf/references/forms.md +99 -0
  63. package/skills/pdf/references/nano-pdf-editing.md +40 -0
  64. package/skills/pdf/references/ocr-extraction.md +165 -0
  65. package/skills/pdf/scripts/_raster.py +76 -0
  66. package/skills/pdf/scripts/extract_marker.py +88 -0
  67. package/skills/pdf/scripts/extract_pymupdf.py +101 -0
  68. package/skills/pdf/scripts/pdf_create.py +191 -0
  69. package/skills/pdf/scripts/pdf_fill_form.py +97 -0
  70. package/skills/pdf/scripts/pdf_form_layout.py +168 -0
  71. package/skills/pdf/scripts/pdf_make_form.py +145 -0
  72. package/skills/pdf/scripts/pdf_merge.py +50 -0
  73. package/skills/pdf/scripts/pdf_meta.py +115 -0
  74. package/skills/pdf/scripts/pdf_page_image.py +99 -0
  75. package/skills/pdf/scripts/pdf_read.py +153 -0
  76. package/skills/pdf/scripts/pdf_secure.py +71 -0
  77. package/skills/pdf/scripts/pdf_split.py +84 -0
  78. package/skills/pdf/scripts/pdf_stamp.py +143 -0
  79. package/skills/pdf/scripts/pdf_watermark.py +51 -0
  80. package/skills/pdf/scripts/render.py +250 -0
  81. package/skills/pdf/tests/test_pdf_skill.py +415 -0
  82. package/skills/pptx/LICENSE +23 -0
  83. package/skills/pptx/NOTICE.md +8 -0
  84. package/skills/pptx/SKILL.md +62 -0
  85. package/skills/pptx/references/design-system.md +392 -0
  86. package/skills/pptx/references/pptxgenjs.md +420 -0
  87. package/skills/pptx/references/slide-types.md +413 -0
  88. package/skills/pptx/scripts/cjk_fonts.py +244 -0
  89. package/skills/pptx/scripts/pptx_create.py +240 -0
  90. package/skills/pptx/scripts/pptx_edit.py +436 -0
  91. package/skills/pptx/scripts/pptx_from_template.py +88 -0
  92. package/skills/pptx/scripts/pptx_read.py +131 -0
  93. package/skills/pptx/scripts/pptx_render.py +96 -0
  94. package/skills/pptx/scripts/render.py +250 -0
  95. package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
  96. package/skills/skill-create/SKILL.md +114 -0
  97. package/skills/video-generation/SKILL.md +27 -0
  98. package/skills/xlsx/LICENSE +23 -0
  99. package/skills/xlsx/NOTICE.md +8 -0
  100. package/skills/xlsx/SKILL.md +63 -0
  101. package/skills/xlsx/references/format.md +71 -0
  102. package/skills/xlsx/references/restructuring.md +71 -0
  103. package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
  104. package/skills/xlsx/scripts/formula_check.py +428 -0
  105. package/skills/xlsx/scripts/render.py +250 -0
  106. package/skills/xlsx/scripts/xlsx_create.py +265 -0
  107. package/skills/xlsx/scripts/xlsx_edit.py +269 -0
  108. package/skills/xlsx/scripts/xlsx_read.py +166 -0
  109. package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
  110. package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
  111. package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
  112. package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
  113. package/tools/create_goal/TOOL.md +36 -0
  114. package/tools/image_generation/TOOL.md +81 -0
  115. package/tools/skill/TOOL.md +117 -0
  116. package/tools/update_profile_notes/TOOL.md +39 -0
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env python3
2
+ """Read an .xlsx workbook: inventory, JSON/CSV dumps, formula listing.
3
+
4
+ Modes (pick one):
5
+ --sheets JSON inventory: sheet names, dimensions, row/col counts
6
+ --json dump one sheet's rows as a JSON array of arrays
7
+ --csv dump one sheet as CSV to stdout or --out
8
+ --formulas JSON list of formula cells {"cell", "formula", "cached"}
9
+ --notes JSON list of cell notes/comments across sheets
10
+ --names JSON map of workbook defined names
11
+
12
+ Options:
13
+ --sheet NAME sheet to dump (default: active sheet)
14
+ --data-only load cached formula RESULTS instead of formula strings.
15
+ Caveat: openpyxl never computes formulas; cached values
16
+ exist only if the file was last saved by Excel/LibreOffice.
17
+ --encoding ENC encoding for --csv --out files (default utf-8)
18
+ --out PATH write --csv output to a file instead of stdout
19
+
20
+ Usage:
21
+ xlsx_read.py book.xlsx --sheets
22
+ xlsx_read.py book.xlsx --json --sheet Data
23
+ xlsx_read.py book.xlsx --csv --sheet Data --out data.csv
24
+ xlsx_read.py book.xlsx --formulas
25
+ xlsx_read.py book.xlsx --notes
26
+ xlsx_read.py book.xlsx --names
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import csv
32
+ import json
33
+ import sys
34
+ from datetime import date, datetime, time
35
+
36
+ from openpyxl import load_workbook
37
+
38
+
39
+ def jsonable(value):
40
+ if isinstance(value, (datetime, date, time)):
41
+ return value.isoformat()
42
+ return value
43
+
44
+
45
+ def sheet_rows(ws):
46
+ return [[jsonable(c) for c in row] for row in ws.iter_rows(values_only=True)]
47
+
48
+
49
+ def cmd_sheets(wb):
50
+ info = []
51
+ for ws in wb.worksheets:
52
+ info.append({
53
+ "name": ws.title,
54
+ "dimensions": ws.dimensions,
55
+ "max_row": ws.max_row,
56
+ "max_col": ws.max_column,
57
+ "merged": [str(r) for r in ws.merged_cells.ranges],
58
+ "charts": len(getattr(ws, "_charts", [])),
59
+ "freeze_panes": ws.freeze_panes,
60
+ "autofilter": ws.auto_filter.ref,
61
+ "tables": {t.displayName: t.ref for t in ws.tables.values()},
62
+ "protected": bool(ws.protection.sheet),
63
+ })
64
+ names = {name: dn.attr_text for name, dn in wb.defined_names.items()}
65
+ print(json.dumps({"sheets": info, "defined_names": names},
66
+ ensure_ascii=False, indent=2))
67
+
68
+
69
+ def cmd_notes(wb, sheet):
70
+ out = []
71
+ sheets = [sheet] if sheet else wb.sheetnames
72
+ for name in sheets:
73
+ for row in wb[name].iter_rows():
74
+ for cell in row:
75
+ if cell.comment is not None:
76
+ out.append({"sheet": name, "cell": cell.coordinate,
77
+ "text": cell.comment.text,
78
+ "author": cell.comment.author})
79
+ print(json.dumps({"notes": out}, ensure_ascii=False, indent=2))
80
+
81
+
82
+ def cmd_names(wb):
83
+ names = {name: dn.attr_text for name, dn in wb.defined_names.items()}
84
+ print(json.dumps({"defined_names": names}, ensure_ascii=False, indent=2))
85
+
86
+
87
+ def cmd_formulas(path, sheet):
88
+ wb_f = load_workbook(path, data_only=False)
89
+ wb_v = load_workbook(path, data_only=True)
90
+ out = []
91
+ sheets = [sheet] if sheet else wb_f.sheetnames
92
+ for name in sheets:
93
+ ws_f, ws_v = wb_f[name], wb_v[name]
94
+ for row in ws_f.iter_rows():
95
+ for cell in row:
96
+ if isinstance(cell.value, str) and cell.value.startswith("="):
97
+ out.append({
98
+ "sheet": name,
99
+ "cell": cell.coordinate,
100
+ "formula": cell.value,
101
+ "cached": jsonable(ws_v[cell.coordinate].value),
102
+ })
103
+ print(json.dumps({"formulas": out}, ensure_ascii=False, indent=2))
104
+
105
+
106
+ def main(argv=None):
107
+ # Text I/O is UTF-8 regardless of the locale (GBK on Chinese Windows).
108
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
109
+ try:
110
+ stream.reconfigure(encoding="utf-8")
111
+ except Exception:
112
+ pass
113
+ ap = argparse.ArgumentParser(description="Read/inspect an .xlsx workbook.")
114
+ ap.add_argument("file", help="path to .xlsx file")
115
+ mode = ap.add_mutually_exclusive_group(required=True)
116
+ mode.add_argument("--sheets", action="store_true")
117
+ mode.add_argument("--json", action="store_true")
118
+ mode.add_argument("--csv", action="store_true")
119
+ mode.add_argument("--formulas", action="store_true")
120
+ mode.add_argument("--notes", action="store_true")
121
+ mode.add_argument("--names", action="store_true")
122
+ ap.add_argument("--sheet", help="sheet name (default: active)")
123
+ ap.add_argument("--data-only", action="store_true",
124
+ help="return cached formula results (see module docstring)")
125
+ ap.add_argument("--encoding", default="utf-8")
126
+ ap.add_argument("--out", help="output file for --csv")
127
+ args = ap.parse_args(argv)
128
+
129
+ if args.formulas:
130
+ cmd_formulas(args.file, args.sheet)
131
+ return 0
132
+
133
+ wb = load_workbook(args.file, data_only=args.data_only)
134
+ if args.sheets:
135
+ cmd_sheets(wb)
136
+ return 0
137
+ if args.notes:
138
+ cmd_notes(wb, args.sheet)
139
+ return 0
140
+ if args.names:
141
+ cmd_names(wb)
142
+ return 0
143
+
144
+ ws = wb[args.sheet] if args.sheet else wb.active
145
+ rows = sheet_rows(ws)
146
+ if args.json:
147
+ print(json.dumps({"sheet": ws.title, "rows": rows}, ensure_ascii=False))
148
+ else: # --csv
149
+ if args.out:
150
+ with open(args.out, "w", newline="", encoding=args.encoding) as fh:
151
+ csv.writer(fh).writerows(
152
+ [["" if v is None else v for v in r] for r in rows])
153
+ print(json.dumps({"ok": True, "out": args.out, "rows": len(rows)}))
154
+ else:
155
+ w = csv.writer(sys.stdout)
156
+ for r in rows:
157
+ w.writerow(["" if v is None else v for v in r])
158
+ return 0
159
+
160
+
161
+ if __name__ == "__main__":
162
+ try:
163
+ sys.exit(main())
164
+ except Exception as exc:
165
+ print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
166
+ sys.exit(1)
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """Recalculate a workbook's formulas headlessly with LibreOffice.
3
+
4
+ openpyxl never computes formulas. This script shells out to `soffice`
5
+ (LibreOffice) to open the workbook, recalculate, and re-save it, so
6
+ cached formula results become available to `xlsx_read.py --data-only`
7
+ and `--formulas`.
8
+
9
+ Behavior:
10
+ * soffice on PATH: converts the file to .xlsx in a temp dir (which
11
+ recalculates all formulas) and replaces the original (or writes
12
+ --out). Prints {"recalculated": true, ...} and exits 0.
13
+ * soffice absent: prints {"recalculated": false, "reason": ...} with
14
+ installation guidance and STILL exits 0 — callers can branch on the
15
+ JSON instead of the exit code.
16
+
17
+ Note: LibreOffice recalculates .xlsx on load per its default
18
+ calculation settings; conversion re-saves with fresh cached values.
19
+
20
+ Usage:
21
+ xlsx_recalc.py book.xlsx
22
+ xlsx_recalc.py book.xlsx --out recalced.xlsx
23
+ xlsx_recalc.py book.xlsx --timeout 120
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import shutil
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ from pathlib import Path
34
+
35
+
36
+ def count_cached(path):
37
+ """Number of formula cells with a cached value present."""
38
+ from openpyxl import load_workbook
39
+ wb_f = load_workbook(path, data_only=False)
40
+ wb_v = load_workbook(path, data_only=True)
41
+ formulas = cached = 0
42
+ for name in wb_f.sheetnames:
43
+ ws_f, ws_v = wb_f[name], wb_v[name]
44
+ for row in ws_f.iter_rows():
45
+ for cell in row:
46
+ if isinstance(cell.value, str) and cell.value.startswith("="):
47
+ formulas += 1
48
+ if ws_v[cell.coordinate].value is not None:
49
+ cached += 1
50
+ return formulas, cached
51
+
52
+
53
+ def main(argv=None):
54
+ # Text I/O is UTF-8 regardless of the locale (GBK on Chinese Windows).
55
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
56
+ try:
57
+ stream.reconfigure(encoding="utf-8")
58
+ except Exception:
59
+ pass
60
+ ap = argparse.ArgumentParser(
61
+ description="Recalculate .xlsx formulas headlessly via LibreOffice.")
62
+ ap.add_argument("file", help="path to .xlsx file")
63
+ ap.add_argument("--out", help="output path (default: replace input)")
64
+ ap.add_argument("--timeout", type=int, default=180,
65
+ help="seconds to wait for soffice (default 180)")
66
+ args = ap.parse_args(argv)
67
+
68
+ src = Path(args.file).resolve()
69
+ if not src.exists():
70
+ print(json.dumps({"ok": False, "error": f"no such file: {src}"}),
71
+ file=sys.stderr)
72
+ return 1
73
+
74
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
75
+ from render import find_soffice # also finds the macOS/Windows app bundle, not only PATH
76
+ soffice = find_soffice()
77
+ if not soffice:
78
+ print(json.dumps({
79
+ "ok": True, "recalculated": False,
80
+ "reason": "LibreOffice (soffice) not found on PATH",
81
+ "guidance": "Install LibreOffice (e.g. `apt install "
82
+ "libreoffice-calc` or `brew install --cask "
83
+ "libreoffice`), or open the file in Excel/"
84
+ "LibreOffice once and re-save it.",
85
+ }, ensure_ascii=False))
86
+ return 0
87
+
88
+ with tempfile.TemporaryDirectory() as tmp:
89
+ proc = subprocess.run(
90
+ [soffice, "--headless", "--calc", "--convert-to", "xlsx:Calc "
91
+ "MS Excel 2007 XML", "--outdir", tmp, str(src)],
92
+ capture_output=True, text=True, encoding="utf-8",
93
+ timeout=args.timeout,
94
+ env={"HOME": tmp, "PATH": Path(soffice).parent.as_posix()
95
+ + ":/usr/bin:/bin"}, check=False)
96
+ produced = Path(tmp) / (src.stem + ".xlsx")
97
+ if proc.returncode != 0 or not produced.exists():
98
+ print(json.dumps({"ok": False,
99
+ "error": "soffice conversion failed",
100
+ "stderr": proc.stderr.strip()[-500:]}),
101
+ file=sys.stderr)
102
+ return 1
103
+ formulas, cached = count_cached(produced)
104
+ dest = Path(args.out).resolve() if args.out else src
105
+ shutil.copyfile(produced, dest)
106
+
107
+ print(json.dumps({
108
+ "ok": True, "recalculated": True, "output": str(dest),
109
+ "formula_cells": formulas, "with_cached_values": cached,
110
+ }, ensure_ascii=False))
111
+ return 0
112
+
113
+
114
+ if __name__ == "__main__":
115
+ try:
116
+ sys.exit(main())
117
+ except Exception as exc:
118
+ print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
119
+ sys.exit(1)
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env python3
2
+ """Reference-aware row/column insert and delete for .xlsx workbooks.
3
+
4
+ Unlike plain openpyxl insert_rows/delete_cols (and xlsx_edit.py's thin
5
+ wrappers), this script also rewrites everything that points at the moved
6
+ cells:
7
+
8
+ * formula references in ALL sheets, including absolute refs ($B$2),
9
+ ranges (B2:B9), and cross-sheet refs ('My Sheet'!A1 / Data!$B$8).
10
+ References into a deleted region become #REF!.
11
+ * merged-cell ranges (shifted; expanded when they span the insertion
12
+ point; removed when fully deleted)
13
+ * autofilter range, freeze panes, data-validation ranges,
14
+ conditional-formatting applied ranges (sqref)
15
+ * native table (ListObject) refs on the edited sheet
16
+ * workbook-scope defined names that point at the edited sheet
17
+ * row heights / column widths
18
+
19
+ It prints a JSON report of every rewrite it made and lists what it
20
+ could NOT shift (chart anchors, images, conditional-format RULE
21
+ formulas). Full rules and limits: references/restructuring.md.
22
+
23
+ One structural operation per invocation:
24
+
25
+ Usage:
26
+ xlsx_restructure.py book.xlsx --sheet Data --insert-rows 3:2
27
+ xlsx_restructure.py book.xlsx --sheet Data --delete-rows 5
28
+ xlsx_restructure.py book.xlsx --sheet Data --insert-cols B:1 --out new.xlsx
29
+ xlsx_restructure.py book.xlsx --sheet Data --delete-cols 4:2
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import re
36
+ import sys
37
+
38
+ from openpyxl import load_workbook
39
+ from openpyxl.formatting.formatting import ConditionalFormattingList
40
+ from openpyxl.utils import (column_index_from_string, get_column_letter,
41
+ range_boundaries)
42
+
43
+ # A1-style reference, optionally sheet-qualified, optionally a range.
44
+ # Guards: not preceded by a word char/$/. (avoids ABC123 identifiers) and
45
+ # not followed by a word char or "(" (avoids function names like LOG10().
46
+ REF_RE = re.compile(
47
+ r"(?<![\w$.:])"
48
+ r"(?P<sheet>(?:'(?:[^']|'')+'|[A-Za-z_][A-Za-z0-9_.]*)!)?"
49
+ r"(?P<start>\$?[A-Za-z]{1,3}\$?[0-9]{1,7})"
50
+ r"(?::(?P<end>\$?[A-Za-z]{1,3}\$?[0-9]{1,7}))?"
51
+ r"(?![\w(])")
52
+ STRING_RE = re.compile(r'"(?:[^"]|"")*"')
53
+ COORD_RE = re.compile(r"^(\$?)([A-Za-z]{1,3})(\$?)([0-9]+)$")
54
+
55
+
56
+ def shift_point(v, idx, n, delete):
57
+ """New 1-based index for a single row/col, or None if deleted."""
58
+ if delete:
59
+ if v < idx:
60
+ return v
61
+ if v >= idx + n:
62
+ return v - n
63
+ return None
64
+ return v + n if v >= idx else v
65
+
66
+
67
+ def shift_span(a, b, idx, n, delete):
68
+ """New (start, end) for an inclusive span, or None if fully deleted."""
69
+ if delete:
70
+ na = a if a < idx else (a - n if a >= idx + n else idx)
71
+ nb = b if b < idx else (b - n if b >= idx + n else idx - 1)
72
+ return None if na > nb else (na, nb)
73
+ return (a + n if a >= idx else a, b + n if b >= idx else b)
74
+
75
+
76
+ def shift_range(rng, axis, idx, n, delete):
77
+ """Shift an A1 range string (no sheet prefix). None = fully deleted."""
78
+ min_col, min_row, max_col, max_row = range_boundaries(rng)
79
+ if axis == "rows":
80
+ span = shift_span(min_row, max_row, idx, n, delete)
81
+ if span is None:
82
+ return None
83
+ min_row, max_row = span
84
+ else:
85
+ span = shift_span(min_col, max_col, idx, n, delete)
86
+ if span is None:
87
+ return None
88
+ min_col, max_col = span
89
+ start = f"{get_column_letter(min_col)}{min_row}"
90
+ end = f"{get_column_letter(max_col)}{max_row}"
91
+ return start if start == end and ":" not in rng else f"{start}:{end}"
92
+
93
+
94
+ class RefRewriter:
95
+ """Rewrite A1 references in formula-like text for one shift op."""
96
+
97
+ def __init__(self, target_sheet, axis, idx, n, delete):
98
+ self.target = target_sheet.lower()
99
+ self.axis, self.idx, self.n, self.delete = axis, idx, n, delete
100
+
101
+ def _shift_coord(self, coord):
102
+ m = COORD_RE.match(coord)
103
+ col_abs, col, row_abs, row = m.groups()
104
+ ci, ri = column_index_from_string(col.upper()), int(row)
105
+ if self.axis == "rows":
106
+ ri = shift_point(ri, self.idx, self.n, self.delete)
107
+ if ri is None:
108
+ return None
109
+ else:
110
+ ci = shift_point(ci, self.idx, self.n, self.delete)
111
+ if ci is None:
112
+ return None
113
+ return f"{col_abs}{get_column_letter(ci)}{row_abs}{ri}"
114
+
115
+ def _shift_pair(self, start, end):
116
+ """Shift a range preserving $ flags; None = collapsed to #REF!."""
117
+ new_start = self._shift_coord(start)
118
+ new_end = self._shift_coord(end)
119
+ if new_start is None or new_end is None:
120
+ # spans may survive partial deletion: clamp via span math
121
+ s, e = COORD_RE.match(start), COORD_RE.match(end)
122
+ if self.axis == "rows":
123
+ span = shift_span(int(s.group(4)), int(e.group(4)),
124
+ self.idx, self.n, self.delete)
125
+ if span is None:
126
+ return None
127
+ new_start = f"{s.group(1)}{s.group(2)}{s.group(3)}{span[0]}"
128
+ new_end = f"{e.group(1)}{e.group(2)}{e.group(3)}{span[1]}"
129
+ else:
130
+ span = shift_span(column_index_from_string(s.group(2).upper()),
131
+ column_index_from_string(e.group(2).upper()),
132
+ self.idx, self.n, self.delete)
133
+ if span is None:
134
+ return None
135
+ new_start = (f"{s.group(1)}{get_column_letter(span[0])}"
136
+ f"{s.group(3)}{s.group(4)}")
137
+ new_end = (f"{e.group(1)}{get_column_letter(span[1])}"
138
+ f"{e.group(3)}{e.group(4)}")
139
+ return new_start, new_end
140
+
141
+ def _sub(self, match, home_sheet):
142
+ prefix = match.group("sheet") or ""
143
+ if prefix:
144
+ name = prefix[:-1]
145
+ if name.startswith("'"):
146
+ name = name[1:-1].replace("''", "'")
147
+ ref_sheet = name
148
+ else:
149
+ ref_sheet = home_sheet
150
+ if ref_sheet.lower() != self.target:
151
+ return match.group(0)
152
+ start, end = match.group("start"), match.group("end")
153
+ if end is None:
154
+ new = self._shift_coord(start)
155
+ return prefix + ("#REF!" if new is None else new)
156
+ pair = self._shift_pair(start, end)
157
+ return (prefix + "#REF!" if pair is None
158
+ else f"{prefix}{pair[0]}:{pair[1]}")
159
+
160
+ def rewrite(self, text, home_sheet):
161
+ """Rewrite refs outside quoted string literals. Returns new text."""
162
+ out, pos = [], 0
163
+ for lit in STRING_RE.finditer(text):
164
+ out.append(REF_RE.sub(lambda m: self._sub(m, home_sheet),
165
+ text[pos:lit.start()]))
166
+ out.append(lit.group(0))
167
+ pos = lit.end()
168
+ out.append(REF_RE.sub(lambda m: self._sub(m, home_sheet), text[pos:]))
169
+ return "".join(out)
170
+
171
+
172
+ def shift_dimensions(dims, idx, n, delete, is_row):
173
+ """Rebuild a row/column dimensions map with shifted keys."""
174
+ items = list(dims.items())
175
+ saved = {}
176
+ for key, dim in items:
177
+ pos = key if is_row else column_index_from_string(key)
178
+ new = shift_point(pos, idx, n, delete)
179
+ if new is not None and new != pos:
180
+ saved[new if is_row else get_column_letter(new)] = dim
181
+ del dims[key]
182
+ for key, dim in saved.items():
183
+ if is_row:
184
+ dim.index = key
185
+ else:
186
+ dim.index = column_index_from_string(key)
187
+ dims[key] = dim
188
+ return len(saved)
189
+
190
+
191
+ def main(argv=None):
192
+ # Text I/O is UTF-8 regardless of the locale (GBK on Chinese Windows).
193
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
194
+ try:
195
+ stream.reconfigure(encoding="utf-8")
196
+ except Exception:
197
+ pass
198
+ ap = argparse.ArgumentParser(
199
+ description="Insert/delete rows or columns AND rewrite formula "
200
+ "references, merges, filters, validations, tables, and "
201
+ "defined names to match.",
202
+ epilog="Cannot shift: chart anchors, images, conditional-format "
203
+ "rule formulas. See references/restructuring.md.")
204
+ ap.add_argument("file", help="path to .xlsx file")
205
+ ap.add_argument("--sheet", help="target sheet (default: active)")
206
+ ap.add_argument("--out", help="output path (default: edit in place)")
207
+ op = ap.add_mutually_exclusive_group(required=True)
208
+ op.add_argument("--insert-rows", metavar="IDX[:N]")
209
+ op.add_argument("--delete-rows", metavar="IDX[:N]")
210
+ op.add_argument("--insert-cols", metavar="COL[:N]",
211
+ help="COL is a letter (B) or 1-based number")
212
+ op.add_argument("--delete-cols", metavar="COL[:N]")
213
+ args = ap.parse_args(argv)
214
+
215
+ raw = (args.insert_rows or args.delete_rows
216
+ or args.insert_cols or args.delete_cols)
217
+ idx_s, _, n_s = raw.partition(":")
218
+ n = int(n_s) if n_s else 1
219
+ axis = "rows" if (args.insert_rows or args.delete_rows) else "cols"
220
+ delete = bool(args.delete_rows or args.delete_cols)
221
+ if axis == "cols" and idx_s.isalpha():
222
+ idx = column_index_from_string(idx_s.upper())
223
+ else:
224
+ idx = int(idx_s)
225
+
226
+ wb = load_workbook(args.file)
227
+ ws = wb[args.sheet] if args.sheet else wb.active
228
+ rewriter = RefRewriter(ws.title, axis, idx, n, delete)
229
+ report = {"ok": True, "sheet": ws.title, "axis": axis,
230
+ "op": "delete" if delete else "insert", "index": idx, "count": n,
231
+ "formulas": [], "merges": [], "tables": {}, "defined_names": {},
232
+ "validations": [], "conditional_formats": [],
233
+ "not_shifted": ["chart anchors", "images",
234
+ "conditional-format rule formulas"]}
235
+
236
+ # 1. capture merge ranges (openpyxl does not move them), then unmerge
237
+ old_merges = [str(r) for r in list(ws.merged_cells.ranges)]
238
+ for rng in old_merges:
239
+ ws.unmerge_cells(rng)
240
+
241
+ # 2. structural move of cell values/styles/comments
242
+ getattr(ws, f"{report['op']}_{axis}")(idx, n)
243
+
244
+ # 3. formulas everywhere
245
+ for sheet in wb.worksheets:
246
+ for row in sheet.iter_rows():
247
+ for cell in row:
248
+ if isinstance(cell.value, str) and cell.value.startswith("="):
249
+ new = rewriter.rewrite(cell.value, sheet.title)
250
+ if new != cell.value:
251
+ report["formulas"].append(
252
+ {"sheet": sheet.title, "cell": cell.coordinate,
253
+ "from": cell.value, "to": new})
254
+ cell.value = new
255
+
256
+ # 4. merges back, shifted
257
+ for rng in old_merges:
258
+ new = shift_range(rng, axis, idx, n, delete)
259
+ if new is None:
260
+ report["merges"].append({"from": rng, "to": None})
261
+ else:
262
+ ws.merge_cells(new)
263
+ if new != rng:
264
+ report["merges"].append({"from": rng, "to": new})
265
+
266
+ # 5. autofilter + freeze panes
267
+ if ws.auto_filter.ref:
268
+ new = shift_range(ws.auto_filter.ref, axis, idx, n, delete)
269
+ if new != ws.auto_filter.ref:
270
+ report["autofilter"] = {"from": ws.auto_filter.ref, "to": new}
271
+ ws.auto_filter.ref = new
272
+ if ws.freeze_panes:
273
+ m = COORD_RE.match(ws.freeze_panes)
274
+ ci = column_index_from_string(m.group(2).upper())
275
+ ri = int(m.group(4))
276
+ if axis == "rows":
277
+ ri = shift_point(ri, idx, n, delete) or max(idx, 2)
278
+ else:
279
+ ci = shift_point(ci, idx, n, delete) or max(idx, 2)
280
+ new = f"{get_column_letter(ci)}{ri}"
281
+ if new != ws.freeze_panes:
282
+ report["freeze_panes"] = {"from": ws.freeze_panes, "to": new}
283
+ ws.freeze_panes = new
284
+
285
+ # 6. data validations + conditional formatting applied ranges
286
+ for dv in ws.data_validations.dataValidation:
287
+ old = str(dv.sqref)
288
+ parts = [shift_range(p, axis, idx, n, delete) for p in old.split()]
289
+ parts = [p for p in parts if p]
290
+ if parts and " ".join(parts) != old:
291
+ dv.sqref = " ".join(parts)
292
+ report["validations"].append({"from": old, "to": str(dv.sqref)})
293
+ new_cf = ConditionalFormattingList()
294
+ for cf in ws.conditional_formatting:
295
+ old = str(cf.sqref)
296
+ parts = [shift_range(p, axis, idx, n, delete) for p in old.split()]
297
+ parts = [p for p in parts if p]
298
+ if not parts:
299
+ report["conditional_formats"].append({"from": old, "to": None})
300
+ continue
301
+ new = " ".join(parts)
302
+ for rule in cf.rules:
303
+ new_cf.add(new, rule)
304
+ if new != old:
305
+ report["conditional_formats"].append({"from": old, "to": new})
306
+ ws.conditional_formatting = new_cf
307
+
308
+ # 7. native tables on the edited sheet
309
+ for table in ws.tables.values():
310
+ new = shift_range(table.ref, axis, idx, n, delete)
311
+ if new and new != table.ref:
312
+ report["tables"][table.displayName] = {"from": table.ref,
313
+ "to": new}
314
+ table.ref = new
315
+
316
+ # 8. workbook-scope defined names
317
+ for name, dn in wb.defined_names.items():
318
+ if dn.attr_text and "!" in dn.attr_text:
319
+ new = rewriter.rewrite(dn.attr_text, ws.title)
320
+ if new != dn.attr_text:
321
+ report["defined_names"][name] = {"from": dn.attr_text,
322
+ "to": new}
323
+ dn.attr_text = new
324
+
325
+ # 9. row heights / column widths
326
+ if axis == "rows":
327
+ shift_dimensions(ws.row_dimensions, idx, n, delete, is_row=True)
328
+ else:
329
+ shift_dimensions(ws.column_dimensions, idx, n, delete, is_row=False)
330
+
331
+ out = args.out or args.file
332
+ wb.save(out)
333
+ report["output"] = out
334
+ print(json.dumps(report, ensure_ascii=False, indent=2))
335
+ return 0
336
+
337
+
338
+ if __name__ == "__main__":
339
+ try:
340
+ sys.exit(main())
341
+ except Exception as exc:
342
+ print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
343
+ sys.exit(1)