dsh-office-kit 0.2.0 → 0.3.0
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/CAPABILITY.md +147 -68
- package/README.md +3 -1
- package/assets/skills/excel-live-control/SKILL.md +234 -0
- package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
- package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
- package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
- package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
- package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
- package/assets/skills/excel-live-control/references/office-automation.md +207 -0
- package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
- package/assets/skills/excel-live-control/style_guidelines.md +87 -0
- package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
- package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
- package/assets/skills/office-documents/SKILL.md +90 -77
- package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
- package/assets/skills/office-documents/ooxml/comments.md +40 -0
- package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
- package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
- package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
- package/assets/skills/office-documents/references/docx-structure.md +83 -0
- package/assets/skills/office-documents/references/template-elicitation.md +49 -0
- package/assets/skills/office-documents/references/writing-quality.md +49 -0
- package/assets/skills/office-documents/render_docx.py +92 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
- package/assets/skills/office-documents/scripts/comments_add.py +136 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
- package/assets/skills/office-documents/scripts/content_controls.py +192 -0
- package/assets/skills/office-documents/scripts/core_properties.py +124 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
- package/assets/skills/office-documents/scripts/fields_report.py +59 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
- package/assets/skills/office-documents/scripts/images_audit.py +80 -0
- package/assets/skills/office-documents/scripts/insert_note.py +126 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
- package/assets/skills/office-documents/scripts/replace_text.py +154 -0
- package/assets/skills/office-documents/scripts/section_audit.py +91 -0
- package/assets/skills/office-documents/scripts/set_protection.py +92 -0
- package/assets/skills/office-documents/scripts/style_lint.py +111 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
- package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
- package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
- package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
- package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
- package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
- package/assets/skills/office-documents/tasks/create_edit.md +107 -0
- package/assets/skills/office-documents/tasks/equations.md +59 -0
- package/assets/skills/office-documents/tasks/fields_update.md +44 -0
- package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
- package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
- package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
- package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
- package/assets/skills/office-documents/tasks/images_figures.md +63 -0
- package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
- package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
- package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
- package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
- package/assets/skills/office-documents/tasks/read_review.md +50 -0
- package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
- package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
- package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
- package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
- package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
- package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
- package/assets/skills/office-documents/tasks/verify_render.md +50 -0
- package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
- package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
- package/assets/skills/office-pdf/SKILL.md +73 -56
- package/assets/skills/office-pdf/references/form-fields.md +56 -0
- package/assets/skills/office-pdf/references/geometry.md +44 -0
- package/assets/skills/office-pdf/references/layout-quality.md +41 -0
- package/assets/skills/office-pdf/references/limits.md +58 -0
- package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
- package/assets/skills/office-pdf/scripts/forms.py +556 -0
- package/assets/skills/office-pdf/scripts/metadata.py +161 -0
- package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
- package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
- package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
- package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
- package/assets/skills/office-pdf/tasks/annotations.md +98 -0
- package/assets/skills/office-pdf/tasks/create.md +75 -0
- package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
- package/assets/skills/office-pdf/tasks/forms.md +67 -0
- package/assets/skills/office-pdf/tasks/read.md +62 -0
- package/assets/skills/office-pdf/tasks/redact.md +72 -0
- package/assets/skills/office-pdf/tasks/stamp.md +60 -0
- package/assets/skills/office-presentations/SKILL.md +141 -87
- package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
- package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
- package/assets/skills/office-presentations/recipes/README.md +71 -0
- package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
- package/assets/skills/office-presentations/recipes/connectors.md +83 -0
- package/assets/skills/office-presentations/recipes/images.md +80 -0
- package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
- package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
- package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
- package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
- package/assets/skills/office-presentations/recipes/tables.md +88 -0
- package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
- package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
- package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
- package/assets/skills/office-presentations/references/finalization.md +63 -0
- package/assets/skills/office-presentations/references/native-bullets.md +78 -0
- package/assets/skills/office-presentations/references/native-evidence.md +67 -0
- package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
- package/assets/skills/office-presentations/references/templates.md +76 -0
- package/assets/skills/office-presentations/routing/google_slides.md +45 -0
- package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
- package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
- package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
- package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
- package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
- package/assets/skills/office-presentations/tasks/template-following.md +132 -0
- package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
- package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
- package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
- package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
- package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
- package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
- package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
- package/assets/skills/office-spreadsheets/references/charts.md +63 -0
- package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
- package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
- package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
- package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
- package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
- package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
- package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
- package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
- package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
- package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
- package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
- package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
- package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
- package/assets/skills/shared/annotation-recipes.md +252 -0
- package/assets/skills/shared/check_runtime.py +253 -0
- package/assets/skills/shared/common-workflow.md +139 -0
- package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/index.js +239 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +171 -246
|
@@ -0,0 +1,1589 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Change a workbook that is already open in Excel or WPS Spreadsheets.
|
|
3
|
+
|
|
4
|
+
The file on disk is not the target here: the user is looking at a live
|
|
5
|
+
document, possibly with unsaved edits. This tool attaches to the running
|
|
6
|
+
application through COM and issues commands against the workbook window, so
|
|
7
|
+
what the user sees after the call is what the script changed.
|
|
8
|
+
|
|
9
|
+
Mechanism: `powershell.exe` (Windows PowerShell 5.1) is started with a script
|
|
10
|
+
on stdin. That script calls
|
|
11
|
+
`[Runtime.InteropServices.Marshal]::GetActiveObject(...)` for 'Excel.Application'
|
|
12
|
+
and then 'Ket.Application' (WPS Spreadsheets) and reports one JSON object.
|
|
13
|
+
pywin32 and comtypes are not used and are not required.
|
|
14
|
+
|
|
15
|
+
Normal operation never starts a spreadsheet application. If neither ProgID is
|
|
16
|
+
in the running-object table the call fails and prints what the user should do
|
|
17
|
+
instead. `--launch-hidden` is the one exception and exists for self-tests.
|
|
18
|
+
|
|
19
|
+
Every mutating operation writes through `Range.Value2`, `Range.Formula` or a
|
|
20
|
+
named COM object (chart, table). None of them is transactional: a failure part
|
|
21
|
+
way through leaves what already landed in place, so a retry must be preceded by
|
|
22
|
+
a read, not by a repeat of the same call.
|
|
23
|
+
|
|
24
|
+
Exit codes:
|
|
25
|
+
0 success: one JSON object on stdout
|
|
26
|
+
1 the operation failed (attach, COM, validation); a JSON error object is
|
|
27
|
+
printed on stdout and stderr
|
|
28
|
+
2 usage error (argparse)
|
|
29
|
+
3 powershell.exe is unavailable or timed out; a JSON error object is printed
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import base64
|
|
36
|
+
import json
|
|
37
|
+
import locale
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import shutil
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
import time
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
47
|
+
# JSON is UTF-8 by definition; a cp936 console would mangle sheet names.
|
|
48
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
49
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
50
|
+
|
|
51
|
+
CHART_TYPES = {
|
|
52
|
+
"column": 51,
|
|
53
|
+
"column-stacked": 52,
|
|
54
|
+
"bar": 57,
|
|
55
|
+
"bar-stacked": 58,
|
|
56
|
+
"line": 4,
|
|
57
|
+
"line-markers": 65,
|
|
58
|
+
"area": 1,
|
|
59
|
+
"area-stacked": 76,
|
|
60
|
+
"pie": 5,
|
|
61
|
+
"doughnut": -4120,
|
|
62
|
+
"scatter": -4169,
|
|
63
|
+
"scatter-lines": 74,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ALIGN_H = {"left": -4131, "center": -4108, "right": -4152, "general": 1, "justify": -4130}
|
|
67
|
+
ALIGN_V = {"top": -4160, "center": -4108, "bottom": -4104}
|
|
68
|
+
|
|
69
|
+
STYLE_KEYS = {
|
|
70
|
+
"font_name",
|
|
71
|
+
"font_size",
|
|
72
|
+
"bold",
|
|
73
|
+
"italic",
|
|
74
|
+
"color",
|
|
75
|
+
"fill",
|
|
76
|
+
"number_format",
|
|
77
|
+
"h_align",
|
|
78
|
+
"v_align",
|
|
79
|
+
"wrap",
|
|
80
|
+
"indent",
|
|
81
|
+
"border",
|
|
82
|
+
"column_width",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
CELL = re.compile(r"^([A-Za-z]{1,3})([0-9]{1,7})$")
|
|
86
|
+
HEX = re.compile(r"^#?[0-9A-Fa-f]{6}$")
|
|
87
|
+
|
|
88
|
+
ATTACH_REMEDIATION = (
|
|
89
|
+
"Open the workbook in Excel or WPS Spreadsheets and keep it open, then run the "
|
|
90
|
+
"command again. Nothing was started: this tool never creates a hidden instance "
|
|
91
|
+
"for normal work (--launch-hidden exists only for self-tests)."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ToolError(Exception):
|
|
96
|
+
"""A failure this script can explain, with the stage it happened in."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, message: str, stage: str = "request", remediation: str | None = None):
|
|
99
|
+
super().__init__(message)
|
|
100
|
+
self.message = message
|
|
101
|
+
self.stage = stage
|
|
102
|
+
self.remediation = remediation
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Arguments
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
111
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
112
|
+
common.add_argument(
|
|
113
|
+
"--workbook",
|
|
114
|
+
metavar="NAME|PATH",
|
|
115
|
+
help="which open workbook to drive (default: the only one open)",
|
|
116
|
+
)
|
|
117
|
+
common.add_argument(
|
|
118
|
+
"--launch-hidden",
|
|
119
|
+
action="store_true",
|
|
120
|
+
help="start a hidden spreadsheet application when none is running "
|
|
121
|
+
"(self-tests only; normal use must attach to the user's window)",
|
|
122
|
+
)
|
|
123
|
+
common.add_argument(
|
|
124
|
+
"--timeout",
|
|
125
|
+
type=int,
|
|
126
|
+
default=180,
|
|
127
|
+
metavar="SECONDS",
|
|
128
|
+
help="how long to wait for the COM call (default 180)",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
parser = argparse.ArgumentParser(
|
|
132
|
+
prog="live_excel.py",
|
|
133
|
+
description="Drive a workbook that is already open in Excel or WPS "
|
|
134
|
+
"Spreadsheets: read it, write values and formulas, style ranges, add "
|
|
135
|
+
"charts and tables, recalculate, save and close.",
|
|
136
|
+
)
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
"--diagnose",
|
|
139
|
+
action="store_true",
|
|
140
|
+
help="report the attach mechanism, application, workbooks and sheets; "
|
|
141
|
+
"no operation is run",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
commands = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
145
|
+
|
|
146
|
+
def add(name: str, help_text: str) -> argparse.ArgumentParser:
|
|
147
|
+
return commands.add_parser(name, parents=[common], help=help_text, description=help_text)
|
|
148
|
+
|
|
149
|
+
add("info", "list the open workbooks and the structure of the target workbook")
|
|
150
|
+
add(
|
|
151
|
+
"read_range",
|
|
152
|
+
"read values (and optionally formulas or displayed text) from one range",
|
|
153
|
+
).add_argument("--sheet", required=True)
|
|
154
|
+
read = commands.choices["read_range"]
|
|
155
|
+
read.add_argument("--range", required=True, help="A1 range, or 'used' for the used range")
|
|
156
|
+
read.add_argument("--formulas", action="store_true", help="also return the formula grid")
|
|
157
|
+
read.add_argument(
|
|
158
|
+
"--display",
|
|
159
|
+
action="store_true",
|
|
160
|
+
help="also return the displayed text of every cell (max 500 cells)",
|
|
161
|
+
)
|
|
162
|
+
read.add_argument("--max-cells", type=int, default=5000, help="refuse ranges above this size")
|
|
163
|
+
|
|
164
|
+
write = add("write_range", "write a rectangular block of values into one range")
|
|
165
|
+
write.add_argument("--sheet", required=True)
|
|
166
|
+
write.add_argument("--range", required=True, help="rectangular A1 range, for example A1:B3")
|
|
167
|
+
write.add_argument(
|
|
168
|
+
"--data",
|
|
169
|
+
help='JSON 2D array, for example \'[[1,2],[3,4]]\'; one row per spreadsheet row',
|
|
170
|
+
)
|
|
171
|
+
write.add_argument("--data-file", help="path to a JSON file holding the 2D array")
|
|
172
|
+
write.add_argument(
|
|
173
|
+
"--clear",
|
|
174
|
+
action="store_true",
|
|
175
|
+
help="clear the contents of the range instead of writing (formats survive)",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
formula = add("set_formula", "write formulas into one range or into named cells")
|
|
179
|
+
formula.add_argument("--sheet", required=True)
|
|
180
|
+
formula.add_argument("--range", help="range the --formula applies to")
|
|
181
|
+
formula.add_argument("--formula", help="formula text starting with '='")
|
|
182
|
+
formula.add_argument(
|
|
183
|
+
"--cells",
|
|
184
|
+
help='JSON object mapping addresses to formulas: \'{"C1":"=A1+B1"}\'',
|
|
185
|
+
)
|
|
186
|
+
formula.add_argument("--cells-file", help="path to a JSON file holding that object")
|
|
187
|
+
formula.add_argument(
|
|
188
|
+
"--r1c1",
|
|
189
|
+
action="store_true",
|
|
190
|
+
help="treat the formulas as R1C1 notation (FormulaR1C1)",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
style = add("style_range", "apply font, fill, number format, alignment or borders")
|
|
194
|
+
style.add_argument("--sheet", required=True)
|
|
195
|
+
style.add_argument("--range", required=True)
|
|
196
|
+
style.add_argument("--style", help="JSON style object, for example '{\"bold\":true}'")
|
|
197
|
+
style.add_argument("--style-file", help="path to a JSON file holding the style object")
|
|
198
|
+
style.add_argument(
|
|
199
|
+
"--merge",
|
|
200
|
+
action="store_true",
|
|
201
|
+
help="merge the range after styling it (one value survives)",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
chart = add("add_chart", "embed a chart on a sheet and point it at a range")
|
|
205
|
+
chart.add_argument("--sheet", required=True)
|
|
206
|
+
chart.add_argument(
|
|
207
|
+
"--type",
|
|
208
|
+
required=True,
|
|
209
|
+
choices=sorted(CHART_TYPES),
|
|
210
|
+
help="chart type",
|
|
211
|
+
)
|
|
212
|
+
chart.add_argument(
|
|
213
|
+
"--data",
|
|
214
|
+
required=True,
|
|
215
|
+
help="source range on --sheet, or 'OtherSheet!A1:B10' for another sheet",
|
|
216
|
+
)
|
|
217
|
+
chart.add_argument("--anchor", required=True, help="top-left cell the chart is placed at")
|
|
218
|
+
chart.add_argument("--title", help="chart title")
|
|
219
|
+
chart.add_argument("--width", type=float, default=420.0, help="points (default 420)")
|
|
220
|
+
chart.add_argument("--height", type=float, default=260.0, help="points (default 260)")
|
|
221
|
+
|
|
222
|
+
table = add("add_table", "turn a range into a native Excel/WPS table (ListObject)")
|
|
223
|
+
table.add_argument("--sheet", required=True)
|
|
224
|
+
table.add_argument("--range", required=True)
|
|
225
|
+
table.add_argument("--name", help="table name (letters, digits, underscore)")
|
|
226
|
+
table.add_argument("--style", help="table style name, for example TableStyleMedium2")
|
|
227
|
+
table.add_argument(
|
|
228
|
+
"--no-headers",
|
|
229
|
+
action="store_true",
|
|
230
|
+
help="treat the first row as data and generate header names",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
add("recalc", "recalculate every open workbook (CalculateFull)")
|
|
234
|
+
|
|
235
|
+
save = add("save", "save the workbook over its own file, or save a copy elsewhere")
|
|
236
|
+
save.add_argument("--path", help="save a copy here instead of saving in place")
|
|
237
|
+
|
|
238
|
+
close = add("close", "close the target workbook, optionally quitting the application")
|
|
239
|
+
close.add_argument("--save", action="store_true", help="save before closing")
|
|
240
|
+
close.add_argument(
|
|
241
|
+
"--discard",
|
|
242
|
+
action="store_true",
|
|
243
|
+
help="close without saving, discarding unsaved changes",
|
|
244
|
+
)
|
|
245
|
+
close.add_argument(
|
|
246
|
+
"--quit-app",
|
|
247
|
+
action="store_true",
|
|
248
|
+
help="quit the application after closing (refused while other workbooks are open)",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return parser
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _require_sheet(args: argparse.Namespace) -> str:
|
|
255
|
+
if not args.sheet or not args.sheet.strip():
|
|
256
|
+
raise ToolError("--sheet is required: this tool never falls back to the active sheet", "usage")
|
|
257
|
+
return args.sheet
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _json_payload(inline: str | None, path: str | None, name: str):
|
|
261
|
+
if inline and path:
|
|
262
|
+
raise ToolError(f"pass either {name} or the matching file flag, not both", "usage")
|
|
263
|
+
if path:
|
|
264
|
+
source = Path(path).expanduser()
|
|
265
|
+
if not source.is_file():
|
|
266
|
+
raise ToolError(f"{name}: file not found: {source}", "usage")
|
|
267
|
+
try:
|
|
268
|
+
return json.loads(source.read_text(encoding="utf-8-sig"))
|
|
269
|
+
except json.JSONDecodeError as error:
|
|
270
|
+
raise ToolError(f"{name}: {source} is not valid JSON: {error}", "usage") from error
|
|
271
|
+
if inline is None:
|
|
272
|
+
raise ToolError(f"{name} is required", "usage")
|
|
273
|
+
try:
|
|
274
|
+
return json.loads(inline)
|
|
275
|
+
except json.JSONDecodeError as error:
|
|
276
|
+
raise ToolError(f"{name} is not valid JSON: {error}", "usage") from error
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _column_number(letters: str) -> int:
|
|
280
|
+
value = 0
|
|
281
|
+
for character in letters.upper():
|
|
282
|
+
value = value * 26 + (ord(character) - ord("A") + 1)
|
|
283
|
+
return value
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def parse_rectangle(address: str) -> tuple[int, int, int, int]:
|
|
287
|
+
"""Return (top_row, left_column, rows, columns) for 'A1:C12'; -1 marks a bad address."""
|
|
288
|
+
parts = address.split(":")
|
|
289
|
+
if len(parts) not in (1, 2):
|
|
290
|
+
raise ToolError(f"not a rectangular A1 range: '{address}'", "usage")
|
|
291
|
+
first = CELL.match(parts[0].strip())
|
|
292
|
+
second = CELL.match(parts[-1].strip())
|
|
293
|
+
if not first or not second:
|
|
294
|
+
raise ToolError(
|
|
295
|
+
f"not a rectangular A1 range: '{address}' (use rows and columns, not A:A)",
|
|
296
|
+
"usage",
|
|
297
|
+
)
|
|
298
|
+
row_one, column_one = int(first.group(2)), _column_number(first.group(1))
|
|
299
|
+
row_two, column_two = int(second.group(2)), _column_number(second.group(1))
|
|
300
|
+
top, bottom = min(row_one, row_two), max(row_one, row_two)
|
|
301
|
+
left, right = min(column_one, column_two), max(column_one, column_two)
|
|
302
|
+
return top, left, bottom - top + 1, right - left + 1
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _two_dimensional(data, field: str) -> list[list[object]]:
|
|
306
|
+
if not isinstance(data, list) or not data:
|
|
307
|
+
raise ToolError(f"{field} must be a non-empty JSON array of rows", "usage")
|
|
308
|
+
rows: list[list[object]] = []
|
|
309
|
+
for index, row in enumerate(data, start=1):
|
|
310
|
+
if not isinstance(row, list):
|
|
311
|
+
row = [row]
|
|
312
|
+
rows.append(list(row))
|
|
313
|
+
width = len(rows[0])
|
|
314
|
+
for index, row in enumerate(rows, start=1):
|
|
315
|
+
if len(row) != width:
|
|
316
|
+
raise ToolError(
|
|
317
|
+
f"{field}: row {index} has {len(row)} values but row 1 has {width}; "
|
|
318
|
+
"every row must be the same length",
|
|
319
|
+
"usage",
|
|
320
|
+
)
|
|
321
|
+
return rows
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _check_color(value, key: str) -> str:
|
|
325
|
+
if not isinstance(value, str) or not HEX.match(value):
|
|
326
|
+
raise ToolError(f"style.{key} must be a 6-digit hex RGB string like '#1F3864'", "usage")
|
|
327
|
+
return value
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def build_params(args: argparse.Namespace) -> dict[str, object]:
|
|
331
|
+
command = args.command
|
|
332
|
+
|
|
333
|
+
if command == "info":
|
|
334
|
+
return {}
|
|
335
|
+
|
|
336
|
+
if command == "read_range":
|
|
337
|
+
if not args.range:
|
|
338
|
+
raise ToolError("read_range needs --range (an A1 range, or 'used')", "usage")
|
|
339
|
+
if args.max_cells < 1:
|
|
340
|
+
raise ToolError("--max-cells must be at least 1", "usage")
|
|
341
|
+
return {
|
|
342
|
+
"sheet": _require_sheet(args),
|
|
343
|
+
"range": args.range.strip(),
|
|
344
|
+
"formulas": bool(args.formulas),
|
|
345
|
+
"display": bool(args.display),
|
|
346
|
+
"max_cells": int(args.max_cells),
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if command == "write_range":
|
|
350
|
+
sheet = _require_sheet(args)
|
|
351
|
+
if not args.range:
|
|
352
|
+
raise ToolError("write_range needs --range (a rectangular A1 range)", "usage")
|
|
353
|
+
_, _, rows, columns = parse_rectangle(args.range)
|
|
354
|
+
if args.clear:
|
|
355
|
+
if args.data is not None or args.data_file is not None:
|
|
356
|
+
raise ToolError("--clear writes nothing; drop --data and --data-file", "usage")
|
|
357
|
+
return {
|
|
358
|
+
"sheet": sheet,
|
|
359
|
+
"range": args.range.strip(),
|
|
360
|
+
"clear": True,
|
|
361
|
+
"rows": 0,
|
|
362
|
+
"cols": 0,
|
|
363
|
+
"cells": [],
|
|
364
|
+
}
|
|
365
|
+
data = _two_dimensional(_json_payload(args.data, args.data_file, "--data"), "--data")
|
|
366
|
+
if len(data) != rows or len(data[0]) != columns:
|
|
367
|
+
raise ToolError(
|
|
368
|
+
f"data is {len(data)} rows by {len(data[0])} columns but {args.range} is "
|
|
369
|
+
f"{rows} rows by {columns} columns; make them match",
|
|
370
|
+
"usage",
|
|
371
|
+
)
|
|
372
|
+
flat = [value for row in data for value in row]
|
|
373
|
+
return {
|
|
374
|
+
"sheet": sheet,
|
|
375
|
+
"range": args.range.strip(),
|
|
376
|
+
"clear": False,
|
|
377
|
+
"rows": rows,
|
|
378
|
+
"cols": columns,
|
|
379
|
+
"cells": flat,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if command == "set_formula":
|
|
383
|
+
sheet = _require_sheet(args)
|
|
384
|
+
if bool(args.formula) == bool(args.cells or args.cells_file):
|
|
385
|
+
raise ToolError("pass exactly one of --formula or --cells/--cells-file", "usage")
|
|
386
|
+
if args.formula:
|
|
387
|
+
if not args.range:
|
|
388
|
+
raise ToolError("--formula needs --range", "usage")
|
|
389
|
+
text = args.formula.strip()
|
|
390
|
+
if not text.startswith("="):
|
|
391
|
+
raise ToolError(f"formula must start with '=': '{args.formula}'", "usage")
|
|
392
|
+
return {
|
|
393
|
+
"sheet": sheet,
|
|
394
|
+
"range": args.range.strip(),
|
|
395
|
+
"formula": text,
|
|
396
|
+
"r1c1": bool(args.r1c1),
|
|
397
|
+
}
|
|
398
|
+
mapping = _json_payload(args.cells, args.cells_file, "--cells")
|
|
399
|
+
if not isinstance(mapping, dict) or not mapping:
|
|
400
|
+
raise ToolError('--cells must be a JSON object such as {"C1":"=A1+B1"}', "usage")
|
|
401
|
+
cleaned: dict[str, str] = {}
|
|
402
|
+
for address, text in mapping.items():
|
|
403
|
+
if not isinstance(text, str) or not text.strip().startswith("="):
|
|
404
|
+
raise ToolError(f"formula for {address} must start with '='", "usage")
|
|
405
|
+
if not CELL.match(address.strip()):
|
|
406
|
+
raise ToolError(f"not a single-cell A1 address: '{address}'", "usage")
|
|
407
|
+
cleaned[address.strip().upper()] = text.strip()
|
|
408
|
+
return {"sheet": sheet, "cells": cleaned, "r1c1": bool(args.r1c1)}
|
|
409
|
+
|
|
410
|
+
if command == "style_range":
|
|
411
|
+
sheet = _require_sheet(args)
|
|
412
|
+
if not args.range:
|
|
413
|
+
raise ToolError("style_range needs --range", "usage")
|
|
414
|
+
style = _json_payload(args.style, args.style_file, "--style")
|
|
415
|
+
if not isinstance(style, dict) or not style:
|
|
416
|
+
raise ToolError("--style must be a non-empty JSON object", "usage")
|
|
417
|
+
unknown = sorted(set(style) - STYLE_KEYS)
|
|
418
|
+
if unknown:
|
|
419
|
+
raise ToolError(
|
|
420
|
+
f"unknown style key(s): {', '.join(unknown)}; allowed: {', '.join(sorted(STYLE_KEYS))}",
|
|
421
|
+
"usage",
|
|
422
|
+
)
|
|
423
|
+
if "color" in style:
|
|
424
|
+
_check_color(style["color"], "color")
|
|
425
|
+
if "fill" in style:
|
|
426
|
+
_check_color(style["fill"], "fill")
|
|
427
|
+
if "h_align" in style and style["h_align"] not in ALIGN_H:
|
|
428
|
+
raise ToolError(
|
|
429
|
+
f"style.h_align must be one of {', '.join(sorted(ALIGN_H))}", "usage"
|
|
430
|
+
)
|
|
431
|
+
if "v_align" in style and style["v_align"] not in ALIGN_V:
|
|
432
|
+
raise ToolError(
|
|
433
|
+
f"style.v_align must be one of {', '.join(sorted(ALIGN_V))}", "usage"
|
|
434
|
+
)
|
|
435
|
+
if "border" in style and style["border"] not in ("outline", "all", "none"):
|
|
436
|
+
raise ToolError("style.border must be 'outline', 'all' or 'none'", "usage")
|
|
437
|
+
return {"sheet": sheet, "range": args.range.strip(), "style": style, "merge": bool(args.merge)}
|
|
438
|
+
|
|
439
|
+
if command == "add_chart":
|
|
440
|
+
if args.width < 40 or args.height < 40:
|
|
441
|
+
raise ToolError("--width and --height are in points and must be at least 40", "usage")
|
|
442
|
+
return {
|
|
443
|
+
"sheet": _require_sheet(args),
|
|
444
|
+
"chart_type": CHART_TYPES[args.type],
|
|
445
|
+
"type": args.type,
|
|
446
|
+
"data": args.data.strip(),
|
|
447
|
+
"anchor": args.anchor.strip().upper(),
|
|
448
|
+
"title": args.title,
|
|
449
|
+
"width": float(args.width),
|
|
450
|
+
"height": float(args.height),
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if command == "add_table":
|
|
454
|
+
if args.name and not re.match(r"^[A-Za-z_][A-Za-z0-9_.]*$", args.name):
|
|
455
|
+
raise ToolError(
|
|
456
|
+
"--name must start with a letter or underscore and contain only letters, "
|
|
457
|
+
"digits, underscores or dots",
|
|
458
|
+
"usage",
|
|
459
|
+
)
|
|
460
|
+
return {
|
|
461
|
+
"sheet": _require_sheet(args),
|
|
462
|
+
"range": args.range.strip(),
|
|
463
|
+
"name": args.name,
|
|
464
|
+
"style": args.style,
|
|
465
|
+
"no_headers": bool(args.no_headers),
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if command == "recalc":
|
|
469
|
+
return {}
|
|
470
|
+
|
|
471
|
+
if command == "save":
|
|
472
|
+
target = None
|
|
473
|
+
existed = False
|
|
474
|
+
if args.path:
|
|
475
|
+
target = Path(args.path).expanduser().resolve()
|
|
476
|
+
existed = target.exists()
|
|
477
|
+
if target.is_dir():
|
|
478
|
+
raise ToolError(f"--path is a directory, expected a file: {target}", "usage")
|
|
479
|
+
return {"path": str(target) if target else "", "path_existed": existed}
|
|
480
|
+
|
|
481
|
+
if command == "close":
|
|
482
|
+
if args.save and args.discard:
|
|
483
|
+
raise ToolError("--save and --discard are mutually exclusive", "usage")
|
|
484
|
+
return {"save": bool(args.save), "discard": bool(args.discard), "quit_app": bool(args.quit_app)}
|
|
485
|
+
|
|
486
|
+
raise ToolError(f"unknown command '{command}'", "usage")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
# PowerShell bridge
|
|
491
|
+
# ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def powershell_exe() -> str:
|
|
495
|
+
found = shutil.which("powershell.exe") or shutil.which("powershell")
|
|
496
|
+
if found:
|
|
497
|
+
return found
|
|
498
|
+
windows = Path(os.environ.get("SystemRoot", r"C:\Windows"))
|
|
499
|
+
candidate = windows / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
|
500
|
+
if candidate.is_file():
|
|
501
|
+
return str(candidate)
|
|
502
|
+
raise ToolError(
|
|
503
|
+
"Windows PowerShell 5.1 (powershell.exe) was not found; this tool drives COM "
|
|
504
|
+
"through it because PowerShell 7 has no Marshal.GetActiveObject",
|
|
505
|
+
"powershell",
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def decode_output(raw: bytes) -> str:
|
|
510
|
+
for encoding in ("utf-8", locale.getpreferredencoding(False), "cp1252"):
|
|
511
|
+
try:
|
|
512
|
+
return raw.decode(encoding)
|
|
513
|
+
except (UnicodeDecodeError, LookupError):
|
|
514
|
+
continue
|
|
515
|
+
return raw.decode("utf-8", "replace")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def extract_json(text: str) -> dict[str, object] | None:
|
|
519
|
+
start, end = text.find("{"), text.rfind("}")
|
|
520
|
+
if start < 0 or end <= start:
|
|
521
|
+
return None
|
|
522
|
+
try:
|
|
523
|
+
parsed = json.loads(text[start : end + 1])
|
|
524
|
+
except json.JSONDecodeError:
|
|
525
|
+
return None
|
|
526
|
+
return parsed if isinstance(parsed, dict) else None
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def run_powershell(request: dict[str, object], timeout: int) -> dict[str, object]:
|
|
530
|
+
payload = json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
531
|
+
encoded = base64.b64encode(payload).decode("ascii")
|
|
532
|
+
try:
|
|
533
|
+
# A trailing blank line is load-bearing: `powershell.exe -Command -` reads
|
|
534
|
+
# statements from stdin and never runs the very last one when the input
|
|
535
|
+
# ends on it, so the JSON would be swallowed.
|
|
536
|
+
script = (_POWERSHELL_SCRIPT.replace("__REQUEST_B64__", encoded) + "\n").encode("ascii")
|
|
537
|
+
except UnicodeEncodeError as error: # pragma: no cover - guard against edited constants
|
|
538
|
+
raise ToolError(f"the embedded PowerShell script is not ASCII: {error}", "internal") from error
|
|
539
|
+
|
|
540
|
+
command = [powershell_exe(), "-NoProfile", "-NonInteractive", "-Command", "-"]
|
|
541
|
+
try:
|
|
542
|
+
completed = subprocess.run(
|
|
543
|
+
command, input=script, capture_output=True, timeout=max(1, timeout)
|
|
544
|
+
)
|
|
545
|
+
except FileNotFoundError as error:
|
|
546
|
+
raise ToolError(f"cannot start powershell.exe: {error}", "powershell") from error
|
|
547
|
+
except subprocess.TimeoutExpired as error:
|
|
548
|
+
raise ToolError(
|
|
549
|
+
f"the COM call did not finish within {timeout}s; a dialog may be waiting for "
|
|
550
|
+
"input in the spreadsheet application, or the operation was too large to batch",
|
|
551
|
+
"timeout",
|
|
552
|
+
) from error
|
|
553
|
+
|
|
554
|
+
stdout = decode_output(completed.stdout).strip()
|
|
555
|
+
stderr = decode_output(completed.stderr).strip()
|
|
556
|
+
result = extract_json(stdout)
|
|
557
|
+
if result is None:
|
|
558
|
+
detail = stderr or stdout or "(no output)"
|
|
559
|
+
raise ToolError(
|
|
560
|
+
f"PowerShell returned no JSON (exit {completed.returncode}): {detail[:900]}",
|
|
561
|
+
"powershell",
|
|
562
|
+
)
|
|
563
|
+
if not result.get("ok") and stderr and "powershell_stderr" not in result:
|
|
564
|
+
result["powershell_stderr"] = stderr[-900:]
|
|
565
|
+
return result
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
# Main
|
|
570
|
+
# ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def failure_payload(op: str, error: ToolError) -> dict[str, object]:
|
|
574
|
+
payload: dict[str, object] = {
|
|
575
|
+
"ok": False,
|
|
576
|
+
"op": op,
|
|
577
|
+
"stage": error.stage,
|
|
578
|
+
"error": error.message,
|
|
579
|
+
}
|
|
580
|
+
if error.remediation:
|
|
581
|
+
payload["remediation"] = error.remediation
|
|
582
|
+
return payload
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def emit(payload: dict[str, object], code: int) -> int:
|
|
586
|
+
text = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
|
587
|
+
print(text)
|
|
588
|
+
if code:
|
|
589
|
+
print(text, file=sys.stderr)
|
|
590
|
+
return code
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def main() -> int:
|
|
594
|
+
parser = build_parser()
|
|
595
|
+
args = parser.parse_args()
|
|
596
|
+
|
|
597
|
+
if args.diagnose and args.command:
|
|
598
|
+
parser.error("--diagnose runs on its own; drop the command")
|
|
599
|
+
if not args.diagnose and not args.command:
|
|
600
|
+
parser.error("choose a command (info, read_range, ...) or pass --diagnose")
|
|
601
|
+
|
|
602
|
+
op = "diagnose" if args.diagnose else str(args.command)
|
|
603
|
+
try:
|
|
604
|
+
params = {} if args.diagnose else build_params(args)
|
|
605
|
+
except ToolError as error:
|
|
606
|
+
return emit(failure_payload(op, error), 2)
|
|
607
|
+
|
|
608
|
+
request = {
|
|
609
|
+
"op": op,
|
|
610
|
+
"launch_hidden": bool(getattr(args, "launch_hidden", False)),
|
|
611
|
+
"workbook": getattr(args, "workbook", None),
|
|
612
|
+
"params": params,
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
started = time.monotonic()
|
|
616
|
+
try:
|
|
617
|
+
result = run_powershell(request, int(getattr(args, "timeout", 180)))
|
|
618
|
+
except ToolError as error:
|
|
619
|
+
if error.stage == "attach":
|
|
620
|
+
payload = failure_payload(op, error)
|
|
621
|
+
payload["remediation"] = ATTACH_REMEDIATION
|
|
622
|
+
return emit(payload, 1)
|
|
623
|
+
code = 3 if error.stage in ("powershell", "timeout") else 1
|
|
624
|
+
return emit(failure_payload(op, error), code)
|
|
625
|
+
|
|
626
|
+
if not result.get("ok"):
|
|
627
|
+
payload = dict(result)
|
|
628
|
+
payload.setdefault("op", op)
|
|
629
|
+
if payload.get("stage") == "attach" and "remediation" not in payload:
|
|
630
|
+
payload["remediation"] = ATTACH_REMEDIATION
|
|
631
|
+
return emit(payload, 1)
|
|
632
|
+
|
|
633
|
+
result["op"] = op
|
|
634
|
+
result["elapsed_seconds"] = round(time.monotonic() - started, 2)
|
|
635
|
+
return emit(result, 0)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ---------------------------------------------------------------------------
|
|
639
|
+
# The PowerShell side. ASCII only: it travels through stdin, which Windows
|
|
640
|
+
# PowerShell decodes with the console codepage; all request data is base64.
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
_POWERSHELL_SCRIPT = r"""
|
|
644
|
+
$ErrorActionPreference = 'Stop'
|
|
645
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
646
|
+
try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) } catch { }
|
|
647
|
+
|
|
648
|
+
function Get-OleColor {
|
|
649
|
+
param([string]$Hex)
|
|
650
|
+
$text = $Hex.Trim()
|
|
651
|
+
if ($text.StartsWith('#')) { $text = $text.Substring(1) }
|
|
652
|
+
if ($text.Length -ne 6 -or $text -notmatch '^[0-9A-Fa-f]{6}$') {
|
|
653
|
+
throw "colour must be a 6-digit hex RGB string like '#1F3864'; got '$Hex'"
|
|
654
|
+
}
|
|
655
|
+
$r = [Convert]::ToInt32($text.Substring(0, 2), 16)
|
|
656
|
+
$g = [Convert]::ToInt32($text.Substring(2, 2), 16)
|
|
657
|
+
$b = [Convert]::ToInt32($text.Substring(4, 2), 16)
|
|
658
|
+
return ($r + ($g * 256) + ($b * 65536))
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function Get-SaveFormat {
|
|
662
|
+
param([string]$Path)
|
|
663
|
+
$extension = [IO.Path]::GetExtension($Path).ToLowerInvariant()
|
|
664
|
+
if ($extension -eq '.xlsx') { return 51 }
|
|
665
|
+
if ($extension -eq '.xlsm') { return 52 }
|
|
666
|
+
if ($extension -eq '.xls') { return 56 }
|
|
667
|
+
if ($extension -eq '.csv') { return 6 }
|
|
668
|
+
if ($extension -eq '.txt') { return -4158 }
|
|
669
|
+
if ($extension -eq '.html' -or $extension -eq '.htm') { return 44 }
|
|
670
|
+
throw "cannot save to '$Path': supported extensions are .xlsx, .xlsm, .xls, .csv, .txt, .html"
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function Get-WorkbookList {
|
|
674
|
+
param($App)
|
|
675
|
+
$list = New-Object System.Collections.ArrayList
|
|
676
|
+
$count = 0
|
|
677
|
+
try { $count = [int]$App.Workbooks.Count } catch { $count = 0 }
|
|
678
|
+
for ($i = 1; $i -le $count; $i++) {
|
|
679
|
+
$book = $null
|
|
680
|
+
try { $book = $App.Workbooks.Item($i) } catch { $book = $null }
|
|
681
|
+
if ($null -ne $book) { [void]$list.Add($book) }
|
|
682
|
+
}
|
|
683
|
+
# Workbook objects are not enumerable, so the list can flow out unrolled.
|
|
684
|
+
return $list
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function Get-BookInfo {
|
|
688
|
+
param($Book)
|
|
689
|
+
$full = ''
|
|
690
|
+
try { $full = [string]$Book.FullName } catch { $full = '' }
|
|
691
|
+
$directory = ''
|
|
692
|
+
try { $directory = [string]$Book.Path } catch { $directory = '' }
|
|
693
|
+
$saved = $null
|
|
694
|
+
try { $saved = [bool]$Book.Saved } catch { $saved = $null }
|
|
695
|
+
$names = New-Object System.Collections.ArrayList
|
|
696
|
+
try {
|
|
697
|
+
for ($i = 1; $i -le $Book.Sheets.Count; $i++) {
|
|
698
|
+
[void]$names.Add([string]$Book.Sheets.Item($i).Name)
|
|
699
|
+
}
|
|
700
|
+
} catch { }
|
|
701
|
+
$path = $null
|
|
702
|
+
if ($directory -ne '') { $path = $full }
|
|
703
|
+
return [ordered]@{
|
|
704
|
+
name = [string]$Book.Name
|
|
705
|
+
full_name = $full
|
|
706
|
+
path = $path
|
|
707
|
+
saved = $saved
|
|
708
|
+
sheets = @($names)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function Get-SheetInfo {
|
|
713
|
+
param($Sheet)
|
|
714
|
+
$used = ''
|
|
715
|
+
try { $used = [string]$Sheet.UsedRange.Address() } catch { $used = '' }
|
|
716
|
+
$charts = 0
|
|
717
|
+
try { $charts = [int]$Sheet.ChartObjects().Count } catch { $charts = 0 }
|
|
718
|
+
$tables = New-Object System.Collections.ArrayList
|
|
719
|
+
try {
|
|
720
|
+
for ($i = 1; $i -le $Sheet.ListObjects.Count; $i++) {
|
|
721
|
+
[void]$tables.Add([string]$Sheet.ListObjects.Item($i).Name)
|
|
722
|
+
}
|
|
723
|
+
} catch { }
|
|
724
|
+
$pivots = 0
|
|
725
|
+
try { $pivots = [int]$Sheet.PivotTables().Count } catch { $pivots = 0 }
|
|
726
|
+
$visible = 1
|
|
727
|
+
try { $visible = [int]$Sheet.Visible } catch { $visible = 1 }
|
|
728
|
+
$protected = $false
|
|
729
|
+
try { $protected = [bool]$Sheet.ProtectContents } catch { $protected = $false }
|
|
730
|
+
return [ordered]@{
|
|
731
|
+
name = [string]$Sheet.Name
|
|
732
|
+
used_range = $used
|
|
733
|
+
charts = $charts
|
|
734
|
+
tables = @($tables)
|
|
735
|
+
pivot_tables = $pivots
|
|
736
|
+
visible = $visible
|
|
737
|
+
protected = $protected
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function Resolve-Workbook {
|
|
742
|
+
param($App, [string]$Selector)
|
|
743
|
+
$books = @(Get-WorkbookList $App)
|
|
744
|
+
if ($books.Count -eq 0) { throw 'no workbook is open in the attached application' }
|
|
745
|
+
if ([string]::IsNullOrEmpty($Selector)) {
|
|
746
|
+
if ($books.Count -eq 1) { return $books[0] }
|
|
747
|
+
$names = @()
|
|
748
|
+
foreach ($book in $books) { $names += [string]$book.Name }
|
|
749
|
+
throw "several workbooks are open ($($names -join ', ')); pass --workbook with the file name or full path"
|
|
750
|
+
}
|
|
751
|
+
$hits = @()
|
|
752
|
+
foreach ($book in $books) {
|
|
753
|
+
$full = ''
|
|
754
|
+
try { $full = [string]$book.FullName } catch { $full = '' }
|
|
755
|
+
$name = [string]$book.Name
|
|
756
|
+
if ($full -ne '' -and ($full -eq $Selector -or $full -ieq $Selector)) { $hits += $book; continue }
|
|
757
|
+
if ([string]::Equals($name, $Selector, [StringComparison]::OrdinalIgnoreCase)) { $hits += $book; continue }
|
|
758
|
+
if ($full -ne '' -and $full.IndexOf($Selector, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $hits += $book; continue }
|
|
759
|
+
if ($name.IndexOf($Selector, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $hits += $book }
|
|
760
|
+
}
|
|
761
|
+
if ($hits.Count -eq 1) { return $hits[0] }
|
|
762
|
+
if ($hits.Count -eq 0) { throw "no open workbook matches '$Selector'" }
|
|
763
|
+
throw "'$Selector' matches $($hits.Count) open workbooks; pass the full path instead"
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function Resolve-Sheet {
|
|
767
|
+
param($Book, [string]$Name)
|
|
768
|
+
if ([string]::IsNullOrEmpty($Name)) {
|
|
769
|
+
throw '--sheet is required: this tool never falls back to the active sheet'
|
|
770
|
+
}
|
|
771
|
+
$sheet = $null
|
|
772
|
+
try { $sheet = $Book.Worksheets.Item($Name) } catch { $sheet = $null }
|
|
773
|
+
if ($null -eq $sheet) {
|
|
774
|
+
$names = @()
|
|
775
|
+
try {
|
|
776
|
+
for ($i = 1; $i -le $Book.Worksheets.Count; $i++) {
|
|
777
|
+
$names += [string]$Book.Worksheets.Item($i).Name
|
|
778
|
+
}
|
|
779
|
+
} catch { }
|
|
780
|
+
throw "the workbook has no sheet named '$Name'; its sheets are: $($names -join ', ')"
|
|
781
|
+
}
|
|
782
|
+
return $sheet
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function Resolve-Range {
|
|
786
|
+
param($Sheet, [string]$Address)
|
|
787
|
+
# A Range is enumerable, so returning it bare would hand the caller one
|
|
788
|
+
# object per cell; the unary comma keeps it whole.
|
|
789
|
+
if ([string]::IsNullOrEmpty($Address) -or $Address -eq 'used') {
|
|
790
|
+
return ,$Sheet.UsedRange
|
|
791
|
+
}
|
|
792
|
+
$range = $null
|
|
793
|
+
try { $range = $Sheet.Range($Address) } catch { $range = $null }
|
|
794
|
+
if ($null -eq $range) {
|
|
795
|
+
throw "not a valid A1 range on sheet '$($Sheet.Name)': '$Address'"
|
|
796
|
+
}
|
|
797
|
+
return ,$range
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function Get-RangeFacts {
|
|
801
|
+
param($Range)
|
|
802
|
+
return [ordered]@{
|
|
803
|
+
address = [string]$Range.Address()
|
|
804
|
+
rows = [int]$Range.Rows.Count
|
|
805
|
+
columns = [int]$Range.Columns.Count
|
|
806
|
+
cells = [int]$Range.Count
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function Get-RawCell {
|
|
811
|
+
param($Raw, [int]$Rows, [int]$Columns, [int]$Row, [int]$Column)
|
|
812
|
+
if ($Rows -eq 1 -and $Columns -eq 1) { return $Raw }
|
|
813
|
+
return $Raw[$Row, $Column]
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function Read-Grid {
|
|
817
|
+
param($Range, [int]$Rows, [int]$Columns, [bool]$AsFormula)
|
|
818
|
+
$raw = $null
|
|
819
|
+
if ($AsFormula) { $raw = $Range.Formula } else { $raw = $Range.Value2 }
|
|
820
|
+
$grid = New-Object System.Collections.ArrayList
|
|
821
|
+
$errors = New-Object System.Collections.ArrayList
|
|
822
|
+
for ($r = 1; $r -le $Rows; $r++) {
|
|
823
|
+
$line = New-Object System.Collections.ArrayList
|
|
824
|
+
for ($c = 1; $c -le $Columns; $c++) {
|
|
825
|
+
$value = Get-RawCell $raw $Rows $Columns $r $c
|
|
826
|
+
if (-not $AsFormula -and $value -is [int]) {
|
|
827
|
+
# A live value only ever arrives as Double, String, Boolean or null;
|
|
828
|
+
# an integer is the marshalled CVErr variant behind #DIV/0! and friends.
|
|
829
|
+
$text = ''
|
|
830
|
+
$formula = ''
|
|
831
|
+
$address = ''
|
|
832
|
+
try {
|
|
833
|
+
$cell = $Range.Cells.Item($r, $c)
|
|
834
|
+
$text = [string]$cell.Text
|
|
835
|
+
$formula = [string]$cell.Formula
|
|
836
|
+
$address = [string]$cell.Address(0, 0)
|
|
837
|
+
} catch { $text = '#ERROR' }
|
|
838
|
+
[void]$errors.Add([ordered]@{ cell = $address; value = $text; formula = $formula })
|
|
839
|
+
$value = $text
|
|
840
|
+
}
|
|
841
|
+
[void]$line.Add($value)
|
|
842
|
+
}
|
|
843
|
+
[void]$grid.Add(@($line))
|
|
844
|
+
}
|
|
845
|
+
return @{ grid = @($grid); errors = @($errors) }
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function Read-TextGrid {
|
|
849
|
+
param($Range, [int]$Rows, [int]$Columns)
|
|
850
|
+
$grid = New-Object System.Collections.ArrayList
|
|
851
|
+
for ($r = 1; $r -le $Rows; $r++) {
|
|
852
|
+
$line = New-Object System.Collections.ArrayList
|
|
853
|
+
for ($c = 1; $c -le $Columns; $c++) {
|
|
854
|
+
$text = ''
|
|
855
|
+
try { $text = [string]$Range.Cells.Item($r, $c).Text } catch { $text = '' }
|
|
856
|
+
[void]$line.Add($text)
|
|
857
|
+
}
|
|
858
|
+
[void]$grid.Add(@($line))
|
|
859
|
+
}
|
|
860
|
+
return @($grid)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
$script:Result = $null
|
|
864
|
+
|
|
865
|
+
function Invoke-Request {
|
|
866
|
+
try {
|
|
867
|
+
$requestJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__REQUEST_B64__'))
|
|
868
|
+
$request = $requestJson | ConvertFrom-Json
|
|
869
|
+
$script:Request = $request
|
|
870
|
+
$operation = [string]$request.op
|
|
871
|
+
$params = $request.params
|
|
872
|
+
|
|
873
|
+
# ---- attach ----------------------------------------------------
|
|
874
|
+
$attempts = New-Object System.Collections.ArrayList
|
|
875
|
+
$app = $null
|
|
876
|
+
$progid = $null
|
|
877
|
+
$mechanism = ''
|
|
878
|
+
$launched = $false
|
|
879
|
+
foreach ($candidate in @('Excel.Application', 'Ket.Application')) {
|
|
880
|
+
try {
|
|
881
|
+
$app = [Runtime.InteropServices.Marshal]::GetActiveObject($candidate)
|
|
882
|
+
$progid = $candidate
|
|
883
|
+
$mechanism = 'GetActiveObject'
|
|
884
|
+
break
|
|
885
|
+
} catch {
|
|
886
|
+
[void]$attempts.Add([ordered]@{ progid = $candidate; error = $_.Exception.Message })
|
|
887
|
+
$app = $null
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if ($null -eq $app -and [bool]$request.launch_hidden) {
|
|
891
|
+
foreach ($candidate in @('Ket.Application', 'Excel.Application')) {
|
|
892
|
+
try {
|
|
893
|
+
$app = New-Object -ComObject $candidate
|
|
894
|
+
$progid = $candidate
|
|
895
|
+
$mechanism = 'New-Object -ComObject'
|
|
896
|
+
$launched = $true
|
|
897
|
+
break
|
|
898
|
+
} catch {
|
|
899
|
+
[void]$attempts.Add([ordered]@{ progid = $candidate; error = "launch failed: $($_.Exception.Message)" })
|
|
900
|
+
$app = $null
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if ($null -ne $app) {
|
|
904
|
+
try { $app.Visible = $false } catch { }
|
|
905
|
+
try {
|
|
906
|
+
if ([int]$app.Workbooks.Count -eq 0) { [void]$app.Workbooks.Add() }
|
|
907
|
+
} catch { }
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if ($null -eq $app) {
|
|
911
|
+
$script:Result = [ordered]@{
|
|
912
|
+
ok = $false
|
|
913
|
+
stage = 'attach'
|
|
914
|
+
error = 'no running Excel or WPS Spreadsheets instance was found'
|
|
915
|
+
attach_attempts = @($attempts)
|
|
916
|
+
remediation = 'Open the workbook in Excel or WPS Spreadsheets and keep it open, then run the command again. Nothing was started: this tool never creates a hidden instance for normal work (--launch-hidden exists only for self-tests).'
|
|
917
|
+
}
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
$applicationPath = ''
|
|
922
|
+
try { $applicationPath = [string]$app.Path } catch { $applicationPath = '' }
|
|
923
|
+
$vendor = 'Microsoft Excel'
|
|
924
|
+
if ($applicationPath -match '(?i)kingsoft|wps') { $vendor = 'WPS Spreadsheets' }
|
|
925
|
+
$engine = [ordered]@{
|
|
926
|
+
mechanism = 'com'
|
|
927
|
+
attach = $mechanism
|
|
928
|
+
progid = $progid
|
|
929
|
+
launched = $launched
|
|
930
|
+
powershell = $PSVersionTable.PSVersion.ToString()
|
|
931
|
+
app_name = ''
|
|
932
|
+
version = ''
|
|
933
|
+
vendor = $vendor
|
|
934
|
+
path = $applicationPath
|
|
935
|
+
}
|
|
936
|
+
try { $engine['app_name'] = [string]$app.Name } catch { }
|
|
937
|
+
try { $engine['version'] = [string]$app.Version } catch { }
|
|
938
|
+
|
|
939
|
+
$alertsBefore = 1
|
|
940
|
+
$screenBefore = $true
|
|
941
|
+
try { $alertsBefore = [int]$app.DisplayAlerts } catch { }
|
|
942
|
+
try { $screenBefore = [bool]$app.ScreenUpdating } catch { }
|
|
943
|
+
|
|
944
|
+
if ($operation -eq 'diagnose') {
|
|
945
|
+
$rotPresent = $false
|
|
946
|
+
try { $rotPresent = [bool]([Runtime.InteropServices.Marshal].GetMethod('GetActiveObject')) } catch { }
|
|
947
|
+
$books = New-Object System.Collections.ArrayList
|
|
948
|
+
$sheets = New-Object System.Collections.ArrayList
|
|
949
|
+
foreach ($book in @(Get-WorkbookList $app)) {
|
|
950
|
+
[void]$books.Add((Get-BookInfo $book))
|
|
951
|
+
for ($i = 1; $i -le $book.Sheets.Count; $i++) {
|
|
952
|
+
$sheet = $book.Sheets.Item($i)
|
|
953
|
+
$used = ''
|
|
954
|
+
try { $used = [string]$sheet.UsedRange.Address() } catch { }
|
|
955
|
+
[void]$sheets.Add([ordered]@{
|
|
956
|
+
workbook = [string]$book.Name
|
|
957
|
+
sheet = [string]$sheet.Name
|
|
958
|
+
used_range = $used
|
|
959
|
+
})
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
$script:Result = [ordered]@{
|
|
963
|
+
ok = $true
|
|
964
|
+
op = 'diagnose'
|
|
965
|
+
engine = $engine
|
|
966
|
+
attach_attempts = @($attempts)
|
|
967
|
+
get_active_object = $rotPresent
|
|
968
|
+
workbooks = @($books)
|
|
969
|
+
sheets = @($sheets)
|
|
970
|
+
commands = @('info', 'read_range', 'write_range', 'set_formula', 'style_range', 'add_chart', 'add_table', 'recalc', 'save', 'close')
|
|
971
|
+
notes = @(
|
|
972
|
+
'attach uses [Runtime.InteropServices.Marshal]::GetActiveObject in Windows PowerShell 5.1; PowerShell 7 removed that method',
|
|
973
|
+
'WPS Spreadsheets registers Excel.Application as an alias, so the application name is not proof of which product is running; vendor is inferred from Application.Path'
|
|
974
|
+
)
|
|
975
|
+
}
|
|
976
|
+
return
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
$book = Resolve-Workbook $app ([string]$request.workbook)
|
|
980
|
+
|
|
981
|
+
# ---- info ------------------------------------------------------
|
|
982
|
+
if ($operation -eq 'info') {
|
|
983
|
+
$warnings = New-Object System.Collections.ArrayList
|
|
984
|
+
$activeName = ''
|
|
985
|
+
try { $activeName = [string]$app.ActiveWorkbook.Name } catch { }
|
|
986
|
+
$books = New-Object System.Collections.ArrayList
|
|
987
|
+
foreach ($openBook in @(Get-WorkbookList $app)) {
|
|
988
|
+
$entry = Get-BookInfo $openBook
|
|
989
|
+
$entry['active'] = ($entry['name'] -eq $activeName)
|
|
990
|
+
[void]$books.Add($entry)
|
|
991
|
+
}
|
|
992
|
+
$target = Get-BookInfo $book
|
|
993
|
+
$details = New-Object System.Collections.ArrayList
|
|
994
|
+
for ($i = 1; $i -le $book.Sheets.Count; $i++) {
|
|
995
|
+
[void]$details.Add((Get-SheetInfo $book.Sheets.Item($i)))
|
|
996
|
+
}
|
|
997
|
+
$target['sheet_details'] = @($details)
|
|
998
|
+
try {
|
|
999
|
+
if ([bool]$book.ReadOnly) {
|
|
1000
|
+
[void]$warnings.Add('the workbook is open read-only; a save will fail or ask for another path')
|
|
1001
|
+
}
|
|
1002
|
+
} catch { }
|
|
1003
|
+
$script:Result = [ordered]@{
|
|
1004
|
+
ok = $true
|
|
1005
|
+
op = 'info'
|
|
1006
|
+
engine = $engine
|
|
1007
|
+
workbook = $target
|
|
1008
|
+
workbooks = @($books)
|
|
1009
|
+
warnings = @($warnings)
|
|
1010
|
+
}
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
# ---- read_range ------------------------------------------------
|
|
1015
|
+
if ($operation -eq 'read_range') {
|
|
1016
|
+
$sheet = Resolve-Sheet $book ([string]$params.sheet)
|
|
1017
|
+
$range = Resolve-Range $sheet ([string]$params.range)
|
|
1018
|
+
$facts = Get-RangeFacts $range
|
|
1019
|
+
if ($facts['cells'] -gt [int]$params.max_cells) {
|
|
1020
|
+
throw "range $($facts['address']) has $($facts['cells']) cells, over the limit of $($params.max_cells); narrow --range or raise --max-cells"
|
|
1021
|
+
}
|
|
1022
|
+
$read = Read-Grid $range $facts['rows'] $facts['columns'] $false
|
|
1023
|
+
$result = [ordered]@{
|
|
1024
|
+
ok = $true
|
|
1025
|
+
op = 'read_range'
|
|
1026
|
+
engine = $engine
|
|
1027
|
+
workbook = (Get-BookInfo $book)
|
|
1028
|
+
sheet = [string]$sheet.Name
|
|
1029
|
+
range = [string]$params.range
|
|
1030
|
+
address = $facts['address']
|
|
1031
|
+
rows = $facts['rows']
|
|
1032
|
+
columns = $facts['columns']
|
|
1033
|
+
cells = $facts['cells']
|
|
1034
|
+
values = $read.grid
|
|
1035
|
+
errors = $read.errors
|
|
1036
|
+
merged = @()
|
|
1037
|
+
}
|
|
1038
|
+
try {
|
|
1039
|
+
if ([bool]$range.MergeCells) { $result['merged'] = @([string]$range.MergeArea.Address()) }
|
|
1040
|
+
} catch { }
|
|
1041
|
+
if ([bool]$params.formulas) {
|
|
1042
|
+
$formulas = Read-Grid $range $facts['rows'] $facts['columns'] $true
|
|
1043
|
+
$result['formulas'] = $formulas.grid
|
|
1044
|
+
}
|
|
1045
|
+
if ([bool]$params.display) {
|
|
1046
|
+
if ([int]$facts['cells'] -gt 500) {
|
|
1047
|
+
throw '--display is limited to 500 cells per read; read a smaller range'
|
|
1048
|
+
}
|
|
1049
|
+
$result['display'] = Read-TextGrid $range $facts['rows'] $facts['columns']
|
|
1050
|
+
}
|
|
1051
|
+
$formulaCount = 0
|
|
1052
|
+
try {
|
|
1053
|
+
for ($r = 1; $r -le $facts['rows']; $r++) {
|
|
1054
|
+
for ($c = 1; $c -le $facts['columns']; $c++) {
|
|
1055
|
+
$text = [string]$range.Cells.Item($r, $c).Formula
|
|
1056
|
+
if ($text.StartsWith('=')) { $formulaCount = $formulaCount + 1 }
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
} catch { $formulaCount = -1 }
|
|
1060
|
+
$result['formula_cells'] = $formulaCount
|
|
1061
|
+
$script:Result = $result
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
# ---- write_range -----------------------------------------------
|
|
1066
|
+
if ($operation -eq 'write_range') {
|
|
1067
|
+
$sheet = Resolve-Sheet $book ([string]$params.sheet)
|
|
1068
|
+
$range = Resolve-Range $sheet ([string]$params.range)
|
|
1069
|
+
$facts = Get-RangeFacts $range
|
|
1070
|
+
$warnings = New-Object System.Collections.ArrayList
|
|
1071
|
+
$cleared = [bool]$params.clear
|
|
1072
|
+
$written = 0
|
|
1073
|
+
try {
|
|
1074
|
+
$app.DisplayAlerts = 0
|
|
1075
|
+
$app.ScreenUpdating = $false
|
|
1076
|
+
if ($cleared) {
|
|
1077
|
+
$range.ClearContents()
|
|
1078
|
+
$written = 0
|
|
1079
|
+
} else {
|
|
1080
|
+
if ([int]$params.rows -ne $facts['rows'] -or [int]$params.cols -ne $facts['columns']) {
|
|
1081
|
+
throw "the data block is $($params.rows)x$($params.cols) but $($facts['address']) is $($facts['rows']) rows by $($facts['columns']) columns"
|
|
1082
|
+
}
|
|
1083
|
+
$values = @($params.cells)
|
|
1084
|
+
$grid = New-Object 'object[,]' $facts['rows'], $facts['columns']
|
|
1085
|
+
$index = 0
|
|
1086
|
+
for ($r = 0; $r -lt $facts['rows']; $r++) {
|
|
1087
|
+
for ($c = 0; $c -lt $facts['columns']; $c++) {
|
|
1088
|
+
$value = $values[$index]
|
|
1089
|
+
$index = $index + 1
|
|
1090
|
+
if ($value -is [int] -or $value -is [long] -or $value -is [double] -or $value -is [decimal]) {
|
|
1091
|
+
$grid[$r, $c] = [double]$value
|
|
1092
|
+
} else {
|
|
1093
|
+
$grid[$r, $c] = $value
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
$range.Value2 = $grid
|
|
1098
|
+
$written = $facts['cells']
|
|
1099
|
+
}
|
|
1100
|
+
} finally {
|
|
1101
|
+
try { $app.ScreenUpdating = $screenBefore } catch { }
|
|
1102
|
+
try { $app.DisplayAlerts = $alertsBefore } catch { }
|
|
1103
|
+
}
|
|
1104
|
+
if (-not $cleared) {
|
|
1105
|
+
$stored = $range.Value2
|
|
1106
|
+
$index = 0
|
|
1107
|
+
$warned = 0
|
|
1108
|
+
foreach ($value in @($params.cells)) {
|
|
1109
|
+
$row = [int][math]::Floor($index / $facts['columns'])
|
|
1110
|
+
$column = $index % $facts['columns']
|
|
1111
|
+
$index = $index + 1
|
|
1112
|
+
if (-not ($value -is [string]) -or $warned -ge 10) { continue }
|
|
1113
|
+
$after = Get-RawCell $stored $facts['rows'] $facts['columns'] ($row + 1) ($column + 1)
|
|
1114
|
+
$address = ''
|
|
1115
|
+
try { $address = [string]$range.Cells.Item($row + 1, $column + 1).Address(0, 0) } catch { $address = '' }
|
|
1116
|
+
if ($value.StartsWith('=')) {
|
|
1117
|
+
[void]$warnings.Add("$address starts with '=' so it was stored as a formula, not as text: $value")
|
|
1118
|
+
$warned = $warned + 1
|
|
1119
|
+
continue
|
|
1120
|
+
}
|
|
1121
|
+
if ($null -ne $after -and -not ($after -is [string])) {
|
|
1122
|
+
[void]$warnings.Add("$address was sent as text '$value' but the application stored $after; set a number format or send a serial for dates")
|
|
1123
|
+
$warned = $warned + 1
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
$script:Result = [ordered]@{
|
|
1128
|
+
ok = $true
|
|
1129
|
+
op = 'write_range'
|
|
1130
|
+
engine = $engine
|
|
1131
|
+
workbook = (Get-BookInfo $book)
|
|
1132
|
+
sheet = [string]$sheet.Name
|
|
1133
|
+
range = [string]$params.range
|
|
1134
|
+
address = $facts['address']
|
|
1135
|
+
rows = $facts['rows']
|
|
1136
|
+
columns = $facts['columns']
|
|
1137
|
+
cells = $facts['cells']
|
|
1138
|
+
action = 'cleared'
|
|
1139
|
+
written = $written
|
|
1140
|
+
warnings = @($warnings)
|
|
1141
|
+
}
|
|
1142
|
+
if (-not $cleared) { $script:Result['action'] = 'wrote' }
|
|
1143
|
+
return
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
# ---- set_formula -----------------------------------------------
|
|
1147
|
+
if ($operation -eq 'set_formula') {
|
|
1148
|
+
$sheet = Resolve-Sheet $book ([string]$params.sheet)
|
|
1149
|
+
$writtenFormulas = New-Object System.Collections.ArrayList
|
|
1150
|
+
$rangeText = ''
|
|
1151
|
+
$cellCount = 0
|
|
1152
|
+
try {
|
|
1153
|
+
$app.DisplayAlerts = 0
|
|
1154
|
+
$app.ScreenUpdating = $false
|
|
1155
|
+
if ($null -ne $params.cells) {
|
|
1156
|
+
$properties = $params.cells.PSObject.Properties
|
|
1157
|
+
foreach ($property in $properties) {
|
|
1158
|
+
$address = [string]$property.Name
|
|
1159
|
+
$formulaText = [string]$property.Value
|
|
1160
|
+
if (-not $formulaText.StartsWith('=')) {
|
|
1161
|
+
throw "formula for $address must start with '=': $formulaText"
|
|
1162
|
+
}
|
|
1163
|
+
$target = Resolve-Range $sheet $address
|
|
1164
|
+
if ([bool]$params.r1c1) { $target.FormulaR1C1 = $formulaText }
|
|
1165
|
+
else { $target.Formula = $formulaText }
|
|
1166
|
+
$back = ''
|
|
1167
|
+
try { $back = [string]$target.Formula } catch { $back = '' }
|
|
1168
|
+
[void]$writtenFormulas.Add([ordered]@{
|
|
1169
|
+
cell = [string]$target.Address(0, 0)
|
|
1170
|
+
formula = $formulaText
|
|
1171
|
+
stored = $back
|
|
1172
|
+
})
|
|
1173
|
+
}
|
|
1174
|
+
$cellCount = $writtenFormulas.Count
|
|
1175
|
+
$rangeText = "single cells"
|
|
1176
|
+
} else {
|
|
1177
|
+
$range = Resolve-Range $sheet ([string]$params.range)
|
|
1178
|
+
if (-not ([string]$params.formula).StartsWith('=')) {
|
|
1179
|
+
throw "formula must start with '=': $($params.formula)"
|
|
1180
|
+
}
|
|
1181
|
+
if ([bool]$params.r1c1) { $range.FormulaR1C1 = [string]$params.formula }
|
|
1182
|
+
else { $range.Formula = [string]$params.formula }
|
|
1183
|
+
$cellCount = [int]$range.Count
|
|
1184
|
+
$rangeText = [string]$range.Address()
|
|
1185
|
+
$sample = $range.Cells.Item(1, 1)
|
|
1186
|
+
[void]$writtenFormulas.Add([ordered]@{
|
|
1187
|
+
cell = [string]$sample.Address(0, 0)
|
|
1188
|
+
formula = [string]$params.formula
|
|
1189
|
+
stored = [string]$sample.Formula
|
|
1190
|
+
})
|
|
1191
|
+
}
|
|
1192
|
+
} finally {
|
|
1193
|
+
try { $app.ScreenUpdating = $screenBefore } catch { }
|
|
1194
|
+
try { $app.DisplayAlerts = $alertsBefore } catch { }
|
|
1195
|
+
}
|
|
1196
|
+
$warnings = New-Object System.Collections.ArrayList
|
|
1197
|
+
$calculation = $null
|
|
1198
|
+
try { $calculation = [int]$app.Calculation } catch { $calculation = $null }
|
|
1199
|
+
if ($calculation -eq -4135) {
|
|
1200
|
+
[void]$warnings.Add('the workbook is in manual calculation mode; run recalc before reading these numbers')
|
|
1201
|
+
}
|
|
1202
|
+
$script:Result = [ordered]@{
|
|
1203
|
+
ok = $true
|
|
1204
|
+
op = 'set_formula'
|
|
1205
|
+
engine = $engine
|
|
1206
|
+
workbook = (Get-BookInfo $book)
|
|
1207
|
+
sheet = [string]$sheet.Name
|
|
1208
|
+
range = $rangeText
|
|
1209
|
+
cells = $cellCount
|
|
1210
|
+
formulas = @($writtenFormulas)
|
|
1211
|
+
calculation = $calculation
|
|
1212
|
+
warnings = @($warnings)
|
|
1213
|
+
}
|
|
1214
|
+
return
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
# ---- style_range -----------------------------------------------
|
|
1218
|
+
if ($operation -eq 'style_range') {
|
|
1219
|
+
$sheet = Resolve-Sheet $book ([string]$params.sheet)
|
|
1220
|
+
$range = Resolve-Range $sheet ([string]$params.range)
|
|
1221
|
+
$facts = Get-RangeFacts $range
|
|
1222
|
+
$style = $params.style
|
|
1223
|
+
$applied = New-Object System.Collections.ArrayList
|
|
1224
|
+
$colors = [ordered]@{}
|
|
1225
|
+
try {
|
|
1226
|
+
$app.DisplayAlerts = 0
|
|
1227
|
+
$app.ScreenUpdating = $false
|
|
1228
|
+
if ($null -ne $style.font_name) { $range.Font.Name = [string]$style.font_name; [void]$applied.Add('font_name') }
|
|
1229
|
+
if ($null -ne $style.font_size) { $range.Font.Size = [double]$style.font_size; [void]$applied.Add('font_size') }
|
|
1230
|
+
if ($null -ne $style.bold) { $range.Font.Bold = [bool]$style.bold; [void]$applied.Add('bold') }
|
|
1231
|
+
if ($null -ne $style.italic) { $range.Font.Italic = [bool]$style.italic; [void]$applied.Add('italic') }
|
|
1232
|
+
if ($null -ne $style.color) {
|
|
1233
|
+
$ole = Get-OleColor ([string]$style.color)
|
|
1234
|
+
$range.Font.Color = $ole
|
|
1235
|
+
$colors['font_bgr'] = $ole
|
|
1236
|
+
$colors['font_hex'] = [string]$style.color
|
|
1237
|
+
[void]$applied.Add('color')
|
|
1238
|
+
}
|
|
1239
|
+
if ($null -ne $style.fill) {
|
|
1240
|
+
$ole = Get-OleColor ([string]$style.fill)
|
|
1241
|
+
$range.Interior.Color = $ole
|
|
1242
|
+
$range.Interior.Pattern = 1
|
|
1243
|
+
$colors['fill_bgr'] = $ole
|
|
1244
|
+
$colors['fill_hex'] = [string]$style.fill
|
|
1245
|
+
[void]$applied.Add('fill')
|
|
1246
|
+
}
|
|
1247
|
+
if ($null -ne $style.number_format) {
|
|
1248
|
+
$range.NumberFormat = [string]$style.number_format
|
|
1249
|
+
[void]$applied.Add('number_format')
|
|
1250
|
+
}
|
|
1251
|
+
if ($null -ne $style.h_align) {
|
|
1252
|
+
$map = @{ left = -4131; center = -4108; right = -4152; general = 1; justify = -4130 }
|
|
1253
|
+
$range.HorizontalAlignment = $map[[string]$style.h_align]
|
|
1254
|
+
[void]$applied.Add('h_align')
|
|
1255
|
+
}
|
|
1256
|
+
if ($null -ne $style.v_align) {
|
|
1257
|
+
$map = @{ top = -4160; center = -4108; bottom = -4104 }
|
|
1258
|
+
$range.VerticalAlignment = $map[[string]$style.v_align]
|
|
1259
|
+
[void]$applied.Add('v_align')
|
|
1260
|
+
}
|
|
1261
|
+
if ($null -ne $style.wrap) { $range.WrapText = [bool]$style.wrap; [void]$applied.Add('wrap') }
|
|
1262
|
+
if ($null -ne $style.indent) { $range.IndentLevel = [int]$style.indent; [void]$applied.Add('indent') }
|
|
1263
|
+
if ($null -ne $style.border) {
|
|
1264
|
+
$border = [string]$style.border
|
|
1265
|
+
if ($border -eq 'outline') {
|
|
1266
|
+
$range.BorderAround(1, 2)
|
|
1267
|
+
} elseif ($border -eq 'all') {
|
|
1268
|
+
$range.Borders.LineStyle = 1
|
|
1269
|
+
} else {
|
|
1270
|
+
$range.Borders.LineStyle = -4142
|
|
1271
|
+
}
|
|
1272
|
+
[void]$applied.Add('border')
|
|
1273
|
+
}
|
|
1274
|
+
if ($null -ne $style.column_width) {
|
|
1275
|
+
$width = [double]$style.column_width
|
|
1276
|
+
if ($width -lt 0.5) { $width = 0.5 }
|
|
1277
|
+
if ($width -gt 60) { $width = 60 }
|
|
1278
|
+
$range.EntireColumn.ColumnWidth = $width
|
|
1279
|
+
[void]$applied.Add('column_width')
|
|
1280
|
+
}
|
|
1281
|
+
if ([bool]$params.merge) {
|
|
1282
|
+
$range.Merge()
|
|
1283
|
+
[void]$applied.Add('merge')
|
|
1284
|
+
}
|
|
1285
|
+
} finally {
|
|
1286
|
+
try { $app.ScreenUpdating = $screenBefore } catch { }
|
|
1287
|
+
try { $app.DisplayAlerts = $alertsBefore } catch { }
|
|
1288
|
+
}
|
|
1289
|
+
$numberFormat = ''
|
|
1290
|
+
try { $numberFormat = [string]$range.NumberFormat } catch { }
|
|
1291
|
+
$mergeArea = ''
|
|
1292
|
+
try { if ([bool]$range.MergeCells) { $mergeArea = [string]$range.MergeArea.Address() } } catch { }
|
|
1293
|
+
$script:Result = [ordered]@{
|
|
1294
|
+
ok = $true
|
|
1295
|
+
op = 'style_range'
|
|
1296
|
+
engine = $engine
|
|
1297
|
+
workbook = (Get-BookInfo $book)
|
|
1298
|
+
sheet = [string]$sheet.Name
|
|
1299
|
+
range = [string]$params.range
|
|
1300
|
+
address = $facts['address']
|
|
1301
|
+
cells = $facts['cells']
|
|
1302
|
+
applied = @($applied)
|
|
1303
|
+
colors = $colors
|
|
1304
|
+
number_format = $numberFormat
|
|
1305
|
+
merged = $mergeArea
|
|
1306
|
+
}
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
# ---- add_chart -------------------------------------------------
|
|
1311
|
+
if ($operation -eq 'add_chart') {
|
|
1312
|
+
$sheet = Resolve-Sheet $book ([string]$params.sheet)
|
|
1313
|
+
$dataSheet = $sheet
|
|
1314
|
+
$dataAddress = [string]$params.data
|
|
1315
|
+
if ($dataAddress.Contains('!')) {
|
|
1316
|
+
$split = $dataAddress.Split('!')
|
|
1317
|
+
$dataSheet = Resolve-Sheet $book $split[0].Trim("'")
|
|
1318
|
+
$dataAddress = $split[1]
|
|
1319
|
+
}
|
|
1320
|
+
$source = Resolve-Range $dataSheet $dataAddress
|
|
1321
|
+
$anchor = Resolve-Range $sheet ([string]$params.anchor)
|
|
1322
|
+
$warnings = New-Object System.Collections.ArrayList
|
|
1323
|
+
$blank = $true
|
|
1324
|
+
$sourceCells = [int]$source.Count
|
|
1325
|
+
if ($sourceCells -le 5000) {
|
|
1326
|
+
try {
|
|
1327
|
+
$raw = $source.Value2
|
|
1328
|
+
for ($r = 1; $r -le [int]$source.Rows.Count; $r++) {
|
|
1329
|
+
for ($c = 1; $c -le [int]$source.Columns.Count; $c++) {
|
|
1330
|
+
$value = Get-RawCell $raw ([int]$source.Rows.Count) ([int]$source.Columns.Count) $r $c
|
|
1331
|
+
if ($null -ne $value) { $blank = $false; break }
|
|
1332
|
+
}
|
|
1333
|
+
if (-not $blank) { break }
|
|
1334
|
+
}
|
|
1335
|
+
} catch { $blank = $false }
|
|
1336
|
+
} else {
|
|
1337
|
+
$blank = $false
|
|
1338
|
+
}
|
|
1339
|
+
if ($blank) {
|
|
1340
|
+
[void]$warnings.Add('every source cell is empty; the chart will render blank until the range holds data')
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
$app.DisplayAlerts = 0
|
|
1344
|
+
$app.ScreenUpdating = $false
|
|
1345
|
+
$chartObject = $sheet.ChartObjects().Add(
|
|
1346
|
+
[double]$anchor.Left,
|
|
1347
|
+
[double]$anchor.Top,
|
|
1348
|
+
[double]$params.width,
|
|
1349
|
+
[double]$params.height
|
|
1350
|
+
)
|
|
1351
|
+
$chart = $chartObject.Chart
|
|
1352
|
+
$chart.SetSourceData($source)
|
|
1353
|
+
$chart.ChartType = [int]$params.chart_type
|
|
1354
|
+
if ([string]::IsNullOrEmpty([string]$params.title)) {
|
|
1355
|
+
$chart.HasTitle = $false
|
|
1356
|
+
} else {
|
|
1357
|
+
$chart.HasTitle = $true
|
|
1358
|
+
$chart.ChartTitle.Text = [string]$params.title
|
|
1359
|
+
}
|
|
1360
|
+
} finally {
|
|
1361
|
+
try { $app.ScreenUpdating = $screenBefore } catch { }
|
|
1362
|
+
try { $app.DisplayAlerts = $alertsBefore } catch { }
|
|
1363
|
+
}
|
|
1364
|
+
$series = 0
|
|
1365
|
+
try { $series = [int]$chart.SeriesCollection().Count } catch { $series = 0 }
|
|
1366
|
+
$script:Result = [ordered]@{
|
|
1367
|
+
ok = $true
|
|
1368
|
+
op = 'add_chart'
|
|
1369
|
+
engine = $engine
|
|
1370
|
+
workbook = (Get-BookInfo $book)
|
|
1371
|
+
sheet = [string]$sheet.Name
|
|
1372
|
+
chart = [string]$chartObject.Name
|
|
1373
|
+
type = [string]$params.type
|
|
1374
|
+
chart_type = [int]$params.chart_type
|
|
1375
|
+
anchor = [string]$anchor.Address()
|
|
1376
|
+
source = "$([string]$dataSheet.Name)!$([string]$source.Address())"
|
|
1377
|
+
source_cells = $sourceCells
|
|
1378
|
+
series = $series
|
|
1379
|
+
title = [string]$params.title
|
|
1380
|
+
warnings = @($warnings)
|
|
1381
|
+
}
|
|
1382
|
+
return
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
# ---- add_table -------------------------------------------------
|
|
1386
|
+
if ($operation -eq 'add_table') {
|
|
1387
|
+
$sheet = Resolve-Sheet $book ([string]$params.sheet)
|
|
1388
|
+
$range = Resolve-Range $sheet ([string]$params.range)
|
|
1389
|
+
$conflict = ''
|
|
1390
|
+
try {
|
|
1391
|
+
for ($i = 1; $i -le $sheet.ListObjects.Count; $i++) {
|
|
1392
|
+
$existing = $sheet.ListObjects.Item($i)
|
|
1393
|
+
$overlap = $app.Intersect($range, $existing.Range)
|
|
1394
|
+
if ($null -ne $overlap) { $conflict = [string]$existing.Name; break }
|
|
1395
|
+
}
|
|
1396
|
+
} catch { $conflict = '' }
|
|
1397
|
+
if ($conflict -ne '') {
|
|
1398
|
+
throw "range $([string]$range.Address()) overlaps the existing table '$conflict'; extend that table instead of adding a second one"
|
|
1399
|
+
}
|
|
1400
|
+
$hasHeaders = 1
|
|
1401
|
+
if ([bool]$params.no_headers) { $hasHeaders = 0 }
|
|
1402
|
+
$styleWarning = ''
|
|
1403
|
+
try {
|
|
1404
|
+
$app.DisplayAlerts = 0
|
|
1405
|
+
$app.ScreenUpdating = $false
|
|
1406
|
+
$table = $sheet.ListObjects.Add(1, $range, [Type]::Missing, $hasHeaders)
|
|
1407
|
+
if (-not [string]::IsNullOrEmpty([string]$params.name)) { $table.Name = [string]$params.name }
|
|
1408
|
+
if (-not [string]::IsNullOrEmpty([string]$params.style)) {
|
|
1409
|
+
try { $table.TableStyle = [string]$params.style }
|
|
1410
|
+
catch { $styleWarning = "the table was created but the style '$($params.style)' was refused" }
|
|
1411
|
+
}
|
|
1412
|
+
} finally {
|
|
1413
|
+
try { $app.ScreenUpdating = $screenBefore } catch { }
|
|
1414
|
+
try { $app.DisplayAlerts = $alertsBefore } catch { }
|
|
1415
|
+
}
|
|
1416
|
+
$bodyRows = 0
|
|
1417
|
+
try { $bodyRows = [int]$table.DataBodyRange.Rows.Count } catch { $bodyRows = 0 }
|
|
1418
|
+
$tableRange = $range
|
|
1419
|
+
try { $tableRange = $table.Range } catch { $tableRange = $range }
|
|
1420
|
+
$warnings = New-Object System.Collections.ArrayList
|
|
1421
|
+
if (-not [string]::IsNullOrEmpty($styleWarning)) { [void]$warnings.Add($styleWarning) }
|
|
1422
|
+
if ($hasHeaders -eq 0) {
|
|
1423
|
+
[void]$warnings.Add('with no headers the application generated a header row above the data, so the values moved down one row and the table starts at the header')
|
|
1424
|
+
}
|
|
1425
|
+
$script:Result = [ordered]@{
|
|
1426
|
+
ok = $true
|
|
1427
|
+
op = 'add_table'
|
|
1428
|
+
engine = $engine
|
|
1429
|
+
workbook = (Get-BookInfo $book)
|
|
1430
|
+
sheet = [string]$sheet.Name
|
|
1431
|
+
table = [string]$table.Name
|
|
1432
|
+
requested_range = [string]$params.range
|
|
1433
|
+
range = [string]$tableRange.Address()
|
|
1434
|
+
cells = [int]$tableRange.Count
|
|
1435
|
+
rows = [int]$tableRange.Rows.Count
|
|
1436
|
+
columns = [int]$tableRange.Columns.Count
|
|
1437
|
+
body_rows = $bodyRows
|
|
1438
|
+
has_headers = ($hasHeaders -eq 1)
|
|
1439
|
+
style = [string]$params.style
|
|
1440
|
+
warnings = @($warnings)
|
|
1441
|
+
}
|
|
1442
|
+
return
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
# ---- recalc ----------------------------------------------------
|
|
1446
|
+
if ($operation -eq 'recalc') {
|
|
1447
|
+
$calculating = $null
|
|
1448
|
+
try { $calculating = [int]$app.Calculation } catch { $calculating = $null }
|
|
1449
|
+
$iteration = $false
|
|
1450
|
+
try { $iteration = [bool]$app.Iteration } catch { $iteration = $false }
|
|
1451
|
+
try {
|
|
1452
|
+
$app.ScreenUpdating = $false
|
|
1453
|
+
$app.CalculateFull()
|
|
1454
|
+
} finally {
|
|
1455
|
+
try { $app.ScreenUpdating = $screenBefore } catch { }
|
|
1456
|
+
}
|
|
1457
|
+
$schema = New-Object System.Collections.ArrayList
|
|
1458
|
+
for ($i = 1; $i -le $book.Sheets.Count; $i++) { [void]$schema.Add([string]$book.Sheets.Item($i).Name) }
|
|
1459
|
+
$script:Result = [ordered]@{
|
|
1460
|
+
ok = $true
|
|
1461
|
+
op = 'recalc'
|
|
1462
|
+
engine = $engine
|
|
1463
|
+
workbook = (Get-BookInfo $book)
|
|
1464
|
+
calculated = $true
|
|
1465
|
+
sheets = @($schema)
|
|
1466
|
+
calculation = $calculating
|
|
1467
|
+
iteration = $iteration
|
|
1468
|
+
}
|
|
1469
|
+
return
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
# ---- save ------------------------------------------------------
|
|
1473
|
+
if ($operation -eq 'save') {
|
|
1474
|
+
$target = [string]$params.path
|
|
1475
|
+
$info = Get-BookInfo $book
|
|
1476
|
+
$action = 'saved'
|
|
1477
|
+
if ($target -eq '' -and $null -eq $info['path']) {
|
|
1478
|
+
throw 'the workbook has never been saved; pass --path to choose where the file goes'
|
|
1479
|
+
}
|
|
1480
|
+
try {
|
|
1481
|
+
$app.DisplayAlerts = 0
|
|
1482
|
+
$app.ScreenUpdating = $false
|
|
1483
|
+
if ($target -eq '') {
|
|
1484
|
+
$book.Save()
|
|
1485
|
+
} else {
|
|
1486
|
+
$format = Get-SaveFormat $target
|
|
1487
|
+
$book.SaveAs($target, $format)
|
|
1488
|
+
$action = 'save_as'
|
|
1489
|
+
}
|
|
1490
|
+
} finally {
|
|
1491
|
+
try { $app.ScreenUpdating = $screenBefore } catch { }
|
|
1492
|
+
try { $app.DisplayAlerts = $alertsBefore } catch { }
|
|
1493
|
+
}
|
|
1494
|
+
$after = Get-BookInfo $book
|
|
1495
|
+
$savedPath = [string]$after['path']
|
|
1496
|
+
$exists = $false
|
|
1497
|
+
$size = 0
|
|
1498
|
+
if ($savedPath -ne '') {
|
|
1499
|
+
$exists = Test-Path -LiteralPath $savedPath
|
|
1500
|
+
if ($exists) { $size = (Get-Item -LiteralPath $savedPath).Length }
|
|
1501
|
+
}
|
|
1502
|
+
$overwrote = $false
|
|
1503
|
+
if ($action -eq 'saved' -and $null -ne $info['path']) { $overwrote = $true }
|
|
1504
|
+
if ($action -eq 'save_as' -and [bool]$params.path_existed) { $overwrote = $true }
|
|
1505
|
+
$script:Result = [ordered]@{
|
|
1506
|
+
ok = $true
|
|
1507
|
+
op = 'save'
|
|
1508
|
+
engine = $engine
|
|
1509
|
+
workbook = $after
|
|
1510
|
+
action = $action
|
|
1511
|
+
path = $savedPath
|
|
1512
|
+
file_exists = $exists
|
|
1513
|
+
size_bytes = $size
|
|
1514
|
+
overwrote = $overwrote
|
|
1515
|
+
original_path = $info['path']
|
|
1516
|
+
}
|
|
1517
|
+
return
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
# ---- close -----------------------------------------------------
|
|
1521
|
+
if ($operation -eq 'close') {
|
|
1522
|
+
$info = Get-BookInfo $book
|
|
1523
|
+
$others = @()
|
|
1524
|
+
foreach ($openBook in @(Get-WorkbookList $app)) {
|
|
1525
|
+
if ([string]$openBook.Name -ne [string]$info['name']) { $others += [string]$openBook.Name }
|
|
1526
|
+
}
|
|
1527
|
+
$saveChanges = 0
|
|
1528
|
+
if ([bool]$params.save) {
|
|
1529
|
+
if ($null -eq $info['path']) {
|
|
1530
|
+
throw 'the workbook has never been saved, so close cannot save it; save it with --path first, or close with --discard'
|
|
1531
|
+
}
|
|
1532
|
+
try {
|
|
1533
|
+
$app.DisplayAlerts = 0
|
|
1534
|
+
$book.Save()
|
|
1535
|
+
} finally {
|
|
1536
|
+
try { $app.DisplayAlerts = $alertsBefore } catch { }
|
|
1537
|
+
}
|
|
1538
|
+
} elseif (-not [bool]$params.discard) {
|
|
1539
|
+
if ($info['saved'] -ne $true -or $null -eq $info['path']) {
|
|
1540
|
+
throw 'the workbook has unsaved changes; pass --save to keep them or --discard to throw them away'
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
if ([bool]$params.quit_app -and $others.Count -gt 0) {
|
|
1544
|
+
throw "--quit-app would close these other workbooks too: $($others -join ', '); close them yourself and close without --quit-app"
|
|
1545
|
+
}
|
|
1546
|
+
try {
|
|
1547
|
+
$app.DisplayAlerts = 0
|
|
1548
|
+
$book.Close($saveChanges)
|
|
1549
|
+
if ([bool]$params.quit_app) { $app.Quit() }
|
|
1550
|
+
} finally {
|
|
1551
|
+
try { $app.DisplayAlerts = $alertsBefore } catch { }
|
|
1552
|
+
}
|
|
1553
|
+
$script:Result = [ordered]@{
|
|
1554
|
+
ok = $true
|
|
1555
|
+
op = 'close'
|
|
1556
|
+
engine = $engine
|
|
1557
|
+
workbook = $info
|
|
1558
|
+
saved = [bool]$params.save
|
|
1559
|
+
discarded = (-not [bool]$params.save)
|
|
1560
|
+
quit_app = [bool]$params.quit_app
|
|
1561
|
+
other_workbooks = @($others)
|
|
1562
|
+
}
|
|
1563
|
+
return
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
throw "unknown operation '$operation'"
|
|
1567
|
+
} catch {
|
|
1568
|
+
$script:Result = [ordered]@{
|
|
1569
|
+
ok = $false
|
|
1570
|
+
stage = 'com'
|
|
1571
|
+
error = $_.Exception.Message
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
# The body relays to Out-Null: property assignments emit their value, and the
|
|
1577
|
+
# only thing allowed on stdout is the JSON written below.
|
|
1578
|
+
Invoke-Request | Out-Null
|
|
1579
|
+
|
|
1580
|
+
if ($null -eq $script:Result) {
|
|
1581
|
+
$script:Result = [ordered]@{ ok = $false; stage = 'powershell'; error = 'the script produced no result' }
|
|
1582
|
+
}
|
|
1583
|
+
[Console]::Out.Write(($script:Result | ConvertTo-Json -Depth 12 -Compress))
|
|
1584
|
+
exit 0
|
|
1585
|
+
"""
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
if __name__ == "__main__":
|
|
1589
|
+
sys.exit(main())
|