dsh-plugin-powerbi 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +116 -2
- package/README.zh.md +140 -0
- package/cordis.patch.yml +4 -0
- package/lib/index.js +351 -0
- package/lib/patch.js +241 -0
- package/lib/powerbi.js +604 -0
- package/package.json +55 -4
- package/test/core.test.mjs +150 -0
- package/test/e2e.patch.mjs +120 -0
- package/test/entry.test.mjs +119 -0
- package/test/setup-links.mjs +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-plugin-powerbi contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,117 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-plugin-powerbi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Operate local **Power BI** (`.pbip` projects / Power BI Desktop) from [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
|
|
4
|
+
|
|
5
|
+
> This plugin bakes a set of **empirically verified hard constraints** into its behaviour instead of leaving them for you to discover the hard way. See section 4.
|
|
6
|
+
|
|
7
|
+
中文文档见 [README.zh.md](./README.zh.md).
|
|
8
|
+
|
|
9
|
+
## 1. What it does
|
|
10
|
+
|
|
11
|
+
One tool, `powerbi`, with 12 actions:
|
|
12
|
+
|
|
13
|
+
| Action | Purpose |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `check` | Self-check: Node, both official CLIs, Power BI Desktop, named-pipe reachability |
|
|
16
|
+
| `inspect` | Read-only inventory: pages, visuals, field bindings, theme state — **plus cross-table binding risks** |
|
|
17
|
+
| `validate` | Run Microsoft's PBIR validator, return structured diagnostics |
|
|
18
|
+
| `status` | Query the Desktop bridge (instances, current file, readiness, pages) |
|
|
19
|
+
| `open` | Open a `.pbip` in Desktop and poll until ready |
|
|
20
|
+
| `reload` | Ask Desktop to re-read report-layer edits from disk |
|
|
21
|
+
| `screenshot` | Capture all pages, **with empty-page detection and automatic retry** |
|
|
22
|
+
| `backup` / `backups` / `restore` | Whole-directory backup, list, restore |
|
|
23
|
+
| `patch` | **Guarded report-layer editing**: backup → apply → validate → **auto-rollback if diagnostics increase** |
|
|
24
|
+
|
|
25
|
+
It deliberately provides **no way to write model TMDL** — see section 4.
|
|
26
|
+
|
|
27
|
+
## 2. Requirements
|
|
28
|
+
|
|
29
|
+
The plugin itself needs no Power BI SDK. It drives two **official Microsoft CLIs**:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
# PowerShell blocks npm.ps1 by default — use npm.cmd
|
|
33
|
+
& "C:\Program Files\nodejs\npm.cmd" install `
|
|
34
|
+
"@microsoft/powerbi-report-authoring-cli@0.4.0" `
|
|
35
|
+
"@microsoft/powerbi-desktop-bridge-cli@1.0.0"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Point the plugin at them:
|
|
39
|
+
|
|
40
|
+
```powershell
|
|
41
|
+
$env:DSH_POWERBI_CLI_DIR = "D:\tools\pbi-cli" # dir containing node_modules\.bin\powerbi-report-author.cmd
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The Microsoft Store build of Power BI Desktop lives outside the official CLI's search paths;
|
|
45
|
+
the plugin discovers it via `Get-AppxPackage`. You can also pin it:
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
$env:PBI_DESKTOP_PATH = "C:\Program Files\WindowsApps\Microsoft.MicrosoftPowerBIDesktop_<ver>_x64__8wekyb3d8bbwe\bin\PBIDesktop.exe"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 3. Quick start
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
powerbi(action="check")
|
|
55
|
+
powerbi(action="inspect", path="D:\reports\sales.pbip")
|
|
56
|
+
powerbi(action="validate", path="D:\reports\sales.pbip")
|
|
57
|
+
powerbi(action="open", path="D:\reports\sales.pbip")
|
|
58
|
+
powerbi(action="patch", path="...", patch={ visual: "2b5023e9", set: [ ... ] })
|
|
59
|
+
powerbi(action="reload")
|
|
60
|
+
powerbi(action="screenshot")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Recommended rhythm: **inspect → backup → patch → validate → reload → screenshot**.
|
|
64
|
+
|
|
65
|
+
## 4. Why the limits exist (all empirically verified, not defensive design)
|
|
66
|
+
|
|
67
|
+
| Fact | Consequence | Plugin's answer |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| Microsoft's `validate` covers **only the report layer (PBIR), not TMDL**; no TMDL schema exists in `microsoft/json-schemas` | Model edits have **zero machine validation** | Provides **no** model-writing capability |
|
|
70
|
+
| A TMDL measure referencing a **non-existent column** makes the whole `.pbip` **silently unopenable** (no error — the window just stays "Untitled") | One typo bricks the project | Same as above + mandatory whole-directory backup |
|
|
71
|
+
| A **syntactically broken** measure (unclosed paren) **loads fine** — the model silently ignores it | Errors can hide until runtime | Does not treat "it loads" as correctness |
|
|
72
|
+
| This Desktop build does **not support model hot-reload** (`--reload-with-model` → `CAPABILITY_UNSUPPORTED`) | Model changes require reopening Desktop | `reload` is report-layer only |
|
|
73
|
+
| After `reload` returns success, **visuals are still querying** | An immediate screenshot captures a **blank page** | `screenshot` retries on empty output |
|
|
74
|
+
| Desktop's own `.pbip` export **omits `$schema`**, which Fabric rejects | Upload fails | Surfaced by `inspect` / `validate` |
|
|
75
|
+
| A theme JSON's internal `name` must equal its filename (GUID suffix included) | `PBIR_THEME_FILE_NAME_MISMATCH` | `inspect` flags the mismatch |
|
|
76
|
+
| `validate` **cannot detect cross-table binding errors** (Cartesian products) | Inflated numbers with no warning | `inspect` includes a cross-table binding check |
|
|
77
|
+
| On Windows, Node 20+ **cannot spawn `.cmd` directly** | `spawn EINVAL` | Routed through `cmd.exe /d /s /c` |
|
|
78
|
+
|
|
79
|
+
## 5. The `patch` safety model
|
|
80
|
+
|
|
81
|
+
`patch` is the only action that writes:
|
|
82
|
+
|
|
83
|
+
1. **Whole-directory backup first** (keeps the most recent N, default 5, auto-rotated)
|
|
84
|
+
2. Apply declarative edits (only under `Report/definition/**`)
|
|
85
|
+
3. Run the official validator and compare against the **pre-edit baseline**
|
|
86
|
+
4. **If diagnostics increased → roll the whole directory back** and report exactly which code appeared
|
|
87
|
+
|
|
88
|
+
Supported `set` ops: `setQueryField`, `removeQueryProjection`, `setObject`, `removeObject`,
|
|
89
|
+
`setFilter`, `removeFilter`, `setSort`, `setTitle`, `setPosition`.
|
|
90
|
+
|
|
91
|
+
> Passing `validate` is necessary, not sufficient — it cannot see cross-table bindings or
|
|
92
|
+
> whether the numbers are right. Run `reload` + `screenshot` after a successful `patch`.
|
|
93
|
+
|
|
94
|
+
## 6. Install
|
|
95
|
+
|
|
96
|
+
Local (development):
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"dependencies": { "dsh-plugin-powerbi": "file:D:/path/to/dsh-plugin-powerbi" },
|
|
101
|
+
"dsh": { "profile": { "bundles": [ "...", "dsh-plugin-powerbi" ] } }
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
From npm: install into your profile and add `dsh-plugin-powerbi` to `dsh.profile.bundles`.
|
|
106
|
+
|
|
107
|
+
## 7. Tests
|
|
108
|
+
|
|
109
|
+
```powershell
|
|
110
|
+
node test/core.test.mjs [project-path]
|
|
111
|
+
node test/entry.test.mjs [project-path]
|
|
112
|
+
node test/e2e.patch.mjs [project-path]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## 8. License
|
|
116
|
+
|
|
117
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# dsh-plugin-powerbi
|
|
2
|
+
|
|
3
|
+
用 DeepSeek Harness 操作本地 **Power BI**(`.pbip` 项目 / Power BI Desktop)。
|
|
4
|
+
|
|
5
|
+
> 这个插件把一批实测得到的**硬约束**直接内建为行为,而不是留给使用者去踩坑。
|
|
6
|
+
> 约束来源与验证证据见仓库内 `_插件硬约束_已验证.md` 的思路(本插件 README 第 4 节摘录了要点)。
|
|
7
|
+
|
|
8
|
+
## 1. 它能做什么
|
|
9
|
+
|
|
10
|
+
一个工具 `powerbi`,12 个动作:
|
|
11
|
+
|
|
12
|
+
| 动作 | 作用 |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `check` | 自检:Node / 两个官方 CLI / Power BI Desktop / 命名管道,并给出修复建议 |
|
|
15
|
+
| `inspect` | 只读盘点:页面、视觉对象、字段绑定、主题状态;**并列出跨表绑定风险** |
|
|
16
|
+
| `validate` | 跑官方 PBIR 校验,返回结构化诊断明细 |
|
|
17
|
+
| `status` | 查 Desktop 桥状态(实例、当前文件、是否 ready、页面列表) |
|
|
18
|
+
| `open` | 用 Desktop 打开 `.pbip` 并轮询到 ready |
|
|
19
|
+
| `reload` | 让 Desktop 重载磁盘上的报表层改动 |
|
|
20
|
+
| `screenshot` | 截图全部页面,**带非空校验与自动重试** |
|
|
21
|
+
| `backup` / `backups` / `restore` | 整目录备份、列举、还原 |
|
|
22
|
+
| `patch` | **受控改写报表层**:自动备份 → 应用 → 校验对比 → 诊断变多则自动回滚 |
|
|
23
|
+
|
|
24
|
+
设计上刻意**不提供写模型 TMDL 的能力** —— 原因见第 4 节。
|
|
25
|
+
|
|
26
|
+
## 2. 依赖
|
|
27
|
+
|
|
28
|
+
插件本身不依赖任何 Power BI SDK,但要装两个**微软官方 CLI**(它们才是真正干活的):
|
|
29
|
+
|
|
30
|
+
```powershell
|
|
31
|
+
# 注意:PowerShell 默认执行策略会拦 npm.ps1,必须用 npm.cmd
|
|
32
|
+
& "C:\Program Files\nodejs\npm.cmd" install `
|
|
33
|
+
"@microsoft/powerbi-report-authoring-cli@0.4.0" `
|
|
34
|
+
"@microsoft/powerbi-desktop-bridge-cli@1.0.0"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
装好后告诉插件它们在哪儿(指向包含 `node_modules` 的目录):
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
$env:DSH_POWERBI_CLI_DIR = "D:\tools\pbi-cli" # 该目录下有 node_modules\.bin\powerbi-report-author.cmd
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Store 版 Power BI Desktop 的路径**不在官方 CLI 的候选列表里**,插件会自动用
|
|
44
|
+
`Get-AppxPackage` 找到它;也可以手动指定:
|
|
45
|
+
|
|
46
|
+
```powershell
|
|
47
|
+
$env:PBI_DESKTOP_PATH = "C:\Program Files\WindowsApps\Microsoft.MicrosoftPowerBIDesktop_<版本>_x64__8wekyb3d8bbwe\bin\PBIDesktop.exe"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 3. 快速开始
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
powerbi(action="check") # 先自检
|
|
54
|
+
powerbi(action="inspect", path="D:\报表\销售.pbip") # 摸清现状
|
|
55
|
+
powerbi(action="validate", path="D:\报表\销售.pbip") # 校验
|
|
56
|
+
powerbi(action="open", path="D:\报表\销售.pbip") # 打开并等就绪
|
|
57
|
+
powerbi(action="patch", path="...", patch={
|
|
58
|
+
visual: "2b5023e9",
|
|
59
|
+
set: [{ op: "setObject", object: "labels",
|
|
60
|
+
properties: { show: { expr: { Literal: { Value: "true" } } } } }]
|
|
61
|
+
}) # 受控改写(自动备份+回滚)
|
|
62
|
+
powerbi(action="reload") # 热重载报表层
|
|
63
|
+
powerbi(action="screenshot") # 截图确认
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
推荐节奏:**inspect → backup → patch → validate → reload → screenshot**。
|
|
67
|
+
|
|
68
|
+
## 4. 为什么有这些限制(全部实测,不是保守设计)
|
|
69
|
+
|
|
70
|
+
| 事实 | 后果 | 插件对策 |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| 官方 `validate` **只校验报表层 PBIR,完全不校验 TMDL**;微软 json-schemas 里也没有 TMDL schema | 模型层改动**没有任何机器校验兜底** | **不提供**写模型的能力 |
|
|
73
|
+
| TMDL 里引用一个**不存在的列** → 整个 `.pbip` **静默打不开**(不报错,只是窗口永远"无标题") | 一次笔误就毁掉项目 | 同上 + 强制整目录备份 |
|
|
74
|
+
| 语法错(如括号不闭合)的度量值**反而能被加载**(模型静默忽略) | 错误可能潜伏到运行期 | 不依赖"能加载"当正确性判据 |
|
|
75
|
+
| 本版本 Desktop **不支持模型热重载**(`--reload-with-model` 返回 `CAPABILITY_UNSUPPORTED`) | 模型改动必须重开 Desktop | `reload` 只用于报表层 |
|
|
76
|
+
| `reload` 返回 success 后**视觉对象仍在查询** | 立刻截图会拍到**空白页** | `screenshot` 做非空校验 + 自动重试 |
|
|
77
|
+
| Desktop 自己导出的 `.pbip` **不写 `$schema`**,但 Fabric 拒收 | 上云时报错 | `inspect`/`validate` 会报出来 |
|
|
78
|
+
| 主题 JSON 内部 `name` 必须等于文件名(含 GUID) | 校验器报 `PBIR_THEME_FILE_NAME_MISMATCH` | `inspect` 标出是否一致 |
|
|
79
|
+
| `validate` **抓不到跨表绑定错误**(笛卡尔积) | 图表数值虚高却没报错 | `inspect` 自带跨表绑定检查 |
|
|
80
|
+
| Windows 上 Node 20+ **不能直接 spawn `.cmd`** | `spawn EINVAL` | 内部走 `cmd.exe /d /s /c` |
|
|
81
|
+
|
|
82
|
+
## 5. `patch` 的安全模型
|
|
83
|
+
|
|
84
|
+
`patch` 是唯一会写盘的动作,它的流程是:
|
|
85
|
+
|
|
86
|
+
1. **先整目录备份**(保留最近 N 份,默认 5,自动轮转)
|
|
87
|
+
2. 应用声明式改动(只碰 `Report/definition/**`)
|
|
88
|
+
3. 跑官方 `validate`,与**改前基线**对比
|
|
89
|
+
4. **诊断数增加 → 立即整目录回滚**,并如实报告新增了哪条诊断
|
|
90
|
+
|
|
91
|
+
支持的 `set` 操作:
|
|
92
|
+
|
|
93
|
+
| op | 说明 |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `setQueryField` | 改类别/系列/Y 轴的字段绑定(`role`、`index`、`field`、`queryRef`) |
|
|
96
|
+
| `removeQueryProjection` | 删一个字段投影 |
|
|
97
|
+
| `setObject` | 设/合并视觉对象的格式属性(`object`、`properties`、`selectorIndex`) |
|
|
98
|
+
| `removeObject` | 删一个格式对象(例如去除写反的 `dataPoint` 配色) |
|
|
99
|
+
| `setFilter` / `removeFilter` | 设/清视觉级筛选(`filterConfig.filters`) |
|
|
100
|
+
| `setSort` | 设/清排序 |
|
|
101
|
+
| `setTitle` | 设标题 |
|
|
102
|
+
| `setPosition` | 改位置尺寸 |
|
|
103
|
+
|
|
104
|
+
> 注意:`validate` 通过只是**必要条件**。跨表绑定、数值是否正确它管不到,
|
|
105
|
+
> 所以 `patch` 成功后建议接着 `reload` + `screenshot` 做视觉确认。
|
|
106
|
+
|
|
107
|
+
## 6. 安装
|
|
108
|
+
|
|
109
|
+
### 本地安装(开发/自用)
|
|
110
|
+
|
|
111
|
+
在 profile 的 `package.json` 的 `dependencies` 与 `dsh.profile.bundles` 里各加一条,
|
|
112
|
+
然后 `pnpm install`:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"dependencies": { "dsh-plugin-powerbi": "file:D:/path/to/dsh-plugin-powerbi" },
|
|
117
|
+
"dsh": { "profile": { "bundles": [ "...", "dsh-plugin-powerbi" ] } }
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 从 npm 安装
|
|
122
|
+
|
|
123
|
+
```powershell
|
|
124
|
+
cd $env:USERPROFILE\.dsh\profiles\desktop
|
|
125
|
+
& "C:\Program Files\nodejs\npm.cmd" install dsh-plugin-powerbi
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
然后把 `dsh-plugin-powerbi` 加进 `dsh.profile.bundles`。
|
|
129
|
+
|
|
130
|
+
## 7. 测试
|
|
131
|
+
|
|
132
|
+
```powershell
|
|
133
|
+
node test/core.test.mjs [项目路径] # 核心逻辑(不需要 DSH)
|
|
134
|
+
node test/entry.test.mjs [项目路径] # 工具注册与动作分发(mock ctx)
|
|
135
|
+
node test/e2e.patch.mjs [项目路径] # patch 成功/回滚/备份还原三条路径
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## 8. 许可
|
|
139
|
+
|
|
140
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Power BI 插件 —— 工具注册入口。
|
|
3
|
+
*
|
|
4
|
+
* 只暴露**验证过可行**的能力,并把《插件硬约束》内建为行为:
|
|
5
|
+
* - check / inspect / validate / status / open / reload / screenshot:全程只读或报表层
|
|
6
|
+
* - patch:报表层受控改写(自动备份 + 改后 validate 对比,诊断变多即自动回滚)
|
|
7
|
+
* - backup / restore:整目录备份与回滚
|
|
8
|
+
* - **不提供**写模型 TMDL 的能力(那层没有校验护栏,实测错一个列引用整个项目就打不开)
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-powerbi
|
|
11
|
+
*/
|
|
12
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
13
|
+
import z from '@deepseek-ai/schemastery'
|
|
14
|
+
import {
|
|
15
|
+
resolveProject,
|
|
16
|
+
resolveDesktopExe,
|
|
17
|
+
reportAuthorCli,
|
|
18
|
+
desktopBridgeCli,
|
|
19
|
+
validateReport,
|
|
20
|
+
desktopStatus,
|
|
21
|
+
pickInstance,
|
|
22
|
+
openProject,
|
|
23
|
+
waitReady,
|
|
24
|
+
reloadProject,
|
|
25
|
+
screenshotWithRetry,
|
|
26
|
+
readReportMeta,
|
|
27
|
+
inspectVisuals,
|
|
28
|
+
findCrossTableBindings,
|
|
29
|
+
backupProject,
|
|
30
|
+
restoreBackup,
|
|
31
|
+
listBackups,
|
|
32
|
+
} from './powerbi.js'
|
|
33
|
+
import { applyPatch } from './patch.js'
|
|
34
|
+
|
|
35
|
+
export const name = 'dsh-plugin-powerbi'
|
|
36
|
+
export const inject = ['tools']
|
|
37
|
+
|
|
38
|
+
/** 供 DSH 校验的配置 schema。 */
|
|
39
|
+
export const Config = z.object({
|
|
40
|
+
/** 覆盖 report-author CLI 路径 */
|
|
41
|
+
reportAuthorPath: z.string().default(''),
|
|
42
|
+
/** 覆盖 desktop bridge CLI 路径 */
|
|
43
|
+
desktopBridgePath: z.string().default(''),
|
|
44
|
+
/** 截图默认缩放 */
|
|
45
|
+
screenshotScale: z.number().default(2),
|
|
46
|
+
/** 备份保留份数 */
|
|
47
|
+
backupKeep: z.number().default(5),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const TEXT = (text) => [{ type: 'text', text }]
|
|
51
|
+
|
|
52
|
+
/** 统一的动作说明表,供模型选择动作。 */
|
|
53
|
+
const ACTIONS = {
|
|
54
|
+
check: '自检环境:Node/CLI/Power BI Desktop/命名管道可用性,并给出修复建议',
|
|
55
|
+
inspect: '只读盘点项目:页面、视觉对象、字段绑定、主题状态、页面尺寸;并列出跨表绑定风险',
|
|
56
|
+
validate: '跑官方 PBIR 校验,返回结构化诊断(error/warning 明细)',
|
|
57
|
+
status: '查询 Power BI Desktop 桥状态(实例、当前文件、是否 ready、页面列表)',
|
|
58
|
+
open: '用 Desktop 打开 .pbip 并轮询到 ready(Store 版会自动设置 PBI_DESKTOP_PATH)',
|
|
59
|
+
reload: '让 Desktop 重载磁盘上的报表层改动(模型层改动不支持热重载)',
|
|
60
|
+
screenshot: '截图全部页面,带非空校验与自动重试(reload 后视觉对象仍需时间查询)',
|
|
61
|
+
backup: '整目录备份项目(改任何东西之前应先跑这个)',
|
|
62
|
+
restore: '从备份整目录还原',
|
|
63
|
+
backups: '列出已有备份',
|
|
64
|
+
patch: '受控改写报表层:自动备份 → 应用改动 → validate 对比 → 诊断变多则自动回滚',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fmtDiagnostics(diag) {
|
|
68
|
+
if (!diag) return '(无诊断)'
|
|
69
|
+
const lines = []
|
|
70
|
+
for (const [code, group] of Object.entries(diag)) {
|
|
71
|
+
lines.push(`${group.severity === 'error' ? 'ERROR' : 'WARNING'} [${code}] ×${group.items.length}`)
|
|
72
|
+
for (const it of group.items.slice(0, 4)) {
|
|
73
|
+
lines.push(` ${it.message}`)
|
|
74
|
+
if (it.file) lines.push(` file: ${it.file}${it.path ? ' @ ' + it.path : ''}`)
|
|
75
|
+
}
|
|
76
|
+
if (group.items.length > 4) lines.push(` …还有 ${group.items.length - 4} 条`)
|
|
77
|
+
}
|
|
78
|
+
return lines.join('\n')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function doCheck() {
|
|
82
|
+
const out = []
|
|
83
|
+
out.push(`Node: ${process.version}`)
|
|
84
|
+
const exe = resolveDesktopExe()
|
|
85
|
+
out.push(
|
|
86
|
+
exe.path
|
|
87
|
+
? `Power BI Desktop: ${exe.path} (来源: ${exe.source})`
|
|
88
|
+
: 'Power BI Desktop: ✘ 未找到 —— 请设置 PBI_DESKTOP_PATH',
|
|
89
|
+
)
|
|
90
|
+
const ra = reportAuthorCli()
|
|
91
|
+
const db = desktopBridgeCli()
|
|
92
|
+
out.push(ra ? `powerbi-report-author CLI: ${ra}` : 'powerbi-report-author CLI: ✘ 未找到')
|
|
93
|
+
out.push(db ? `powerbi-desktop CLI: ${db}` : 'powerbi-desktop CLI: ✘ 未找到')
|
|
94
|
+
if (!ra || !db) {
|
|
95
|
+
out.push('')
|
|
96
|
+
out.push('安装命令(注意 PowerShell 需用 npm.cmd):')
|
|
97
|
+
out.push(' & "C:\\Program Files\\nodejs\\npm.cmd" install "@microsoft/powerbi-report-authoring-cli@0.4.0" "@microsoft/powerbi-desktop-bridge-cli@1.0.0"')
|
|
98
|
+
out.push(' 然后把该目录的 node_modules 加进 DSH 的模块解析路径,或设置 DSH_POWERBI_CLI_DIR 指向它的父目录')
|
|
99
|
+
}
|
|
100
|
+
if (db) {
|
|
101
|
+
try {
|
|
102
|
+
const s = await desktopStatus({ timeoutMs: 30000 })
|
|
103
|
+
out.push(`桥状态: ${s.status}(实例 ${(s.instances ?? []).length} 个)`)
|
|
104
|
+
for (const i of s.instances ?? []) {
|
|
105
|
+
out.push(` pid=${i.pid} bridge=${i.bridgeStatus} file=${i.currentFilePath ?? '-'} pages=${(i.pages ?? []).length}`)
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
out.push(`桥查询失败: ${e.message}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
out.push('')
|
|
112
|
+
out.push('已知边界:报表层可改(有 validate + 热重载);模型层 *.tmdl 无任何校验、不能热重载,本插件不提供写入。')
|
|
113
|
+
return out.join('\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function doInspect(proj) {
|
|
117
|
+
const meta = readReportMeta(proj)
|
|
118
|
+
const { visuals } = inspectVisuals(proj)
|
|
119
|
+
const cross = findCrossTableBindings(proj)
|
|
120
|
+
const out = []
|
|
121
|
+
out.push(`项目: ${proj.name}`)
|
|
122
|
+
out.push(` .pbip : ${proj.pbip ?? '(无)'}`)
|
|
123
|
+
out.push(` Report : ${proj.reportDir}`)
|
|
124
|
+
out.push(` SemanticModel: ${proj.modelDir}${proj.modelDir && listBackupsSafe(proj) ? '' : ''}`)
|
|
125
|
+
out.push('')
|
|
126
|
+
out.push(`PBIR 版本: ${meta.version?.version ?? '?'}`)
|
|
127
|
+
out.push(`页面数: ${(meta.pages?.pageOrder ?? []).length}`)
|
|
128
|
+
const pageDims = []
|
|
129
|
+
for (const p of meta.pages?.pageOrder ?? []) pageDims.push(p)
|
|
130
|
+
out.push(`视觉对象数: ${visuals.length}`)
|
|
131
|
+
out.push('')
|
|
132
|
+
if (meta.theme) {
|
|
133
|
+
out.push(`主题: ${meta.theme.name}`)
|
|
134
|
+
out.push(` 文件存在: ${meta.theme.exists}`)
|
|
135
|
+
out.push(` 内部 name: ${meta.theme.themeName}`)
|
|
136
|
+
out.push(` name 与文件名${meta.theme.mismatch ? '❌ 不一致(应改为文件名)' : '✅ 一致'}`)
|
|
137
|
+
out.push(` dataColors[0..7]: ${(meta.theme.dataColors ?? []).join(', ')}`)
|
|
138
|
+
} else {
|
|
139
|
+
out.push('主题: (未使用自定义主题)')
|
|
140
|
+
}
|
|
141
|
+
out.push('')
|
|
142
|
+
out.push('视觉对象字段绑定:')
|
|
143
|
+
for (const v of visuals) {
|
|
144
|
+
const refs = v.refs.map((r) => `${r.entity}[${r.property}]`).join(' ')
|
|
145
|
+
out.push(` [${v.visualType ?? '容器'}] ${v.visual.slice(0, 8)} ${refs || '(无字段)'}`)
|
|
146
|
+
}
|
|
147
|
+
if (cross.length) {
|
|
148
|
+
out.push('')
|
|
149
|
+
out.push(`⚠ 跨表绑定风险 ${cross.length} 处(官方 validate 抓不到这类问题):`)
|
|
150
|
+
for (const c of cross) {
|
|
151
|
+
out.push(` ${c.visualType} ${c.visual.slice(0, 8)}: 引用了 ${c.entities.join(' + ')}`)
|
|
152
|
+
out.push(` ${c.note}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out.join('\n')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function listBackupsSafe(proj) {
|
|
159
|
+
try {
|
|
160
|
+
return listBackups(proj)
|
|
161
|
+
} catch {
|
|
162
|
+
return []
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {any} ctx cordis 上下文
|
|
168
|
+
* @param {any} config
|
|
169
|
+
*/
|
|
170
|
+
export function apply(ctx, config = {}) {
|
|
171
|
+
if (process.env.DSH_POWERBI_CLI_DIR && !config.reportAuthorPath) {
|
|
172
|
+
// 允许通过环境变量指定 CLI 目录(findCliInNodeModules 会读它)
|
|
173
|
+
}
|
|
174
|
+
const scale = config.screenshotScale ?? 2
|
|
175
|
+
const keep = config.backupKeep ?? 5
|
|
176
|
+
|
|
177
|
+
// 防重复注册:DSH Desktop 用 patchReload: live,插件可能在同一 tools 注册表上被重复 apply
|
|
178
|
+
const key = ctx.tools
|
|
179
|
+
if (apply.__done?.has(key)) return
|
|
180
|
+
apply.__done ??= new WeakSet()
|
|
181
|
+
apply.__done.add(key)
|
|
182
|
+
|
|
183
|
+
ctx.tools.register(
|
|
184
|
+
defineTool({
|
|
185
|
+
name: 'powerbi',
|
|
186
|
+
description: [
|
|
187
|
+
'操作本地 Power BI 项目(.pbip)与 Power BI Desktop。',
|
|
188
|
+
'',
|
|
189
|
+
'动作:',
|
|
190
|
+
...Object.entries(ACTIONS).map(([k, v]) => ` ${k} — ${v}`),
|
|
191
|
+
'',
|
|
192
|
+
'重要边界(实测结论,务必遵守):',
|
|
193
|
+
' · 报表层 Report/definition/** 可以改:有官方 validate 兜底,且能被 Desktop 热重载。',
|
|
194
|
+
' · 模型层 *.tmdl 是雷区:官方 validate 完全覆盖不到,且本版本 Desktop 不支持模型热重载;',
|
|
195
|
+
' 实测只要引用一个不存在的列,整个 .pbip 就会静默打不开。**本插件不提供写模型的能力。**',
|
|
196
|
+
' · 改任何东西之前先跑 action="backup";patch 会自动备份并在诊断变多时自动回滚。',
|
|
197
|
+
' · reload 之后不要立刻截图(视觉对象仍在查询),用 action="screenshot" 它自带重试与非空校验。',
|
|
198
|
+
].join('\n'),
|
|
199
|
+
parameters: {
|
|
200
|
+
action: {
|
|
201
|
+
type: 'string',
|
|
202
|
+
required: true,
|
|
203
|
+
enum: Object.keys(ACTIONS),
|
|
204
|
+
description: '要执行的动作,见描述中的动作表。',
|
|
205
|
+
},
|
|
206
|
+
path: {
|
|
207
|
+
type: 'string',
|
|
208
|
+
description: '项目路径:可以是 .pbip 文件、.Report 目录,或包含它们的项目目录。除 check 外都需要。',
|
|
209
|
+
},
|
|
210
|
+
pid: {
|
|
211
|
+
type: 'string',
|
|
212
|
+
description: 'reload / screenshot 用的 Desktop 进程号。省略则自动取已连接的实例。',
|
|
213
|
+
},
|
|
214
|
+
outDir: {
|
|
215
|
+
type: 'string',
|
|
216
|
+
description: 'screenshot 的输出目录。默认 <项目目录>/_shots。',
|
|
217
|
+
},
|
|
218
|
+
backup: {
|
|
219
|
+
type: 'string',
|
|
220
|
+
description: 'restore 用的备份目录名(可只给后缀片段)。省略则用最近一份。',
|
|
221
|
+
},
|
|
222
|
+
keep: {
|
|
223
|
+
type: 'number',
|
|
224
|
+
description: 'backup 时保留的备份份数,默认沿用插件配置。',
|
|
225
|
+
},
|
|
226
|
+
patch: {
|
|
227
|
+
type: 'object',
|
|
228
|
+
additionalProperties: true,
|
|
229
|
+
description: [
|
|
230
|
+
'patch 动作的改动描述(声明式,不写代码):',
|
|
231
|
+
' { "visual": "<视觉对象目录名或前 8 位>", "set": [ ... ] }',
|
|
232
|
+
'set 支持的操作:',
|
|
233
|
+
' { "op":"setQueryField", "role":"Category|Series|Y|Values", "index":0,',
|
|
234
|
+
' "field":{"Measure":{"Expression":{"SourceRef":{"Entity":"_Measures"}},"Property":"度量值名"}},',
|
|
235
|
+
' "queryRef":"_Measures.度量值名", "nativeQueryRef":"度量值名" }',
|
|
236
|
+
' { "op":"setObject", "object":"labels", "properties":{ "show":{"expr":{"Literal":{"Value":"true"}}} } }',
|
|
237
|
+
' { "op":"removeObject", "object":"dataPoint" }',
|
|
238
|
+
' { "op":"setFilter", "filters":[ ... FilterConfig 结构 ... ] }',
|
|
239
|
+
' { "op":"setSort", "sort":{ "sort":[...], "isDefaultSort":true } }',
|
|
240
|
+
].join('\n'),
|
|
241
|
+
},
|
|
242
|
+
scale: { type: 'number', description: 'screenshot 缩放,默认沿用插件配置。' },
|
|
243
|
+
},
|
|
244
|
+
output: {
|
|
245
|
+
schema: {
|
|
246
|
+
type: 'object',
|
|
247
|
+
additionalProperties: false,
|
|
248
|
+
properties: {
|
|
249
|
+
action: { type: 'string', required: true },
|
|
250
|
+
ok: { type: 'boolean', required: true },
|
|
251
|
+
text: { type: 'string', required: true },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
async execute(args, exec) {
|
|
256
|
+
const action = args.action
|
|
257
|
+
try {
|
|
258
|
+
if (action === 'check') {
|
|
259
|
+
return { action, ok: true, text: await doCheck() }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!args.path) throw new Error(`action="${action}" 需要 path 参数`)
|
|
263
|
+
const proj = resolveProject(args.path)
|
|
264
|
+
|
|
265
|
+
if (action === 'inspect') {
|
|
266
|
+
return { action, ok: true, text: await doInspect(proj) }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (action === 'validate') {
|
|
270
|
+
const v = await validateReport(proj)
|
|
271
|
+
const head = `result=${v.result} errors=${v.errorCount} warnings=${v.warningCount}`
|
|
272
|
+
return { action, ok: v.errorCount === 0, text: head + '\n\n' + fmtDiagnostics(v.diagnostics) }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (action === 'status') {
|
|
276
|
+
const s = await desktopStatus()
|
|
277
|
+
return { action, ok: s.status === 'ready', text: JSON.stringify(s, null, 2) }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (action === 'open') {
|
|
281
|
+
if (!proj.pbip) throw new Error('该项目没有 .pbip 文件,无法用 Desktop 打开')
|
|
282
|
+
const r = await openProject(proj.pbip)
|
|
283
|
+
const txt = r.ok
|
|
284
|
+
? `已就绪\npid=${r.instance.pid}\n页面: ${(r.instance.pages ?? []).map((p) => p.displayName).join(', ')}`
|
|
285
|
+
: `未在超时内就绪(这是重要信号:项目很可能有问题,而不是"再等等")\n` + JSON.stringify(r.status, null, 2)
|
|
286
|
+
return { action, ok: r.ok, text: txt }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (action === 'reload') {
|
|
290
|
+
let pid = args.pid
|
|
291
|
+
if (!pid) {
|
|
292
|
+
const s = await desktopStatus()
|
|
293
|
+
const inst = pickInstance(s)
|
|
294
|
+
if (!inst) throw new Error('没有已连接的 Desktop 实例;先跑 action="open"')
|
|
295
|
+
pid = inst.pid
|
|
296
|
+
}
|
|
297
|
+
const r = await reloadProject(pid)
|
|
298
|
+
return { action, ok: r?.result?.success === true || r?.status === 'ok', text: JSON.stringify(r, null, 2) }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (action === 'screenshot') {
|
|
302
|
+
let pid = args.pid
|
|
303
|
+
if (!pid) {
|
|
304
|
+
const s = await desktopStatus()
|
|
305
|
+
const inst = pickInstance(s)
|
|
306
|
+
if (!inst) throw new Error('没有已连接的 Desktop 实例;先跑 action="open"')
|
|
307
|
+
pid = inst.pid
|
|
308
|
+
}
|
|
309
|
+
const outDir = args.outDir || `${proj.projectDir}\\_shots`
|
|
310
|
+
const r = await screenshotWithRetry(pid, outDir, { scale: args.scale ?? scale })
|
|
311
|
+
const lines = [`status=${r.status} attempts=${r.attempts ?? 1} 输出: ${outDir}`]
|
|
312
|
+
for (const s of r.screenshots ?? []) {
|
|
313
|
+
lines.push(` ${s.pageDisplayName} -> ${s.outputPath}`)
|
|
314
|
+
}
|
|
315
|
+
if (r.emptySuspected) lines.push('⚠ 截图疑似仍为空白(已重试到上限)。可再等一会重跑,或先 action="reload"。')
|
|
316
|
+
if (r.failures?.length) lines.push('失败页: ' + JSON.stringify(r.failures))
|
|
317
|
+
return { action, ok: r.status === 'ok' && !r.emptySuspected, text: lines.join('\n') }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (action === 'backup') {
|
|
321
|
+
const b = backupProject(proj, { keep: args.keep ?? keep })
|
|
322
|
+
const lines = [`已备份到: ${b.dest}`, `文件 ${b.files} 个,${b.bytes} 字节`]
|
|
323
|
+
if (b.removed.length) lines.push(`已轮转删除旧备份: ${b.removed.join(', ')}`)
|
|
324
|
+
lines.push(`现有备份: ${listBackupsSafe(proj).join(', ') || '(无)'}`)
|
|
325
|
+
return { action, ok: true, text: lines.join('\n') }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (action === 'backups') {
|
|
329
|
+
const list = listBackupsSafe(proj)
|
|
330
|
+
return { action, ok: true, text: list.length ? list.join('\n') : '(没有备份)' }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (action === 'restore') {
|
|
334
|
+
const r = restoreBackup(proj, args.backup)
|
|
335
|
+
return { action, ok: true, text: `已从备份还原: ${r.restored}` }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (action === 'patch') {
|
|
339
|
+
if (!args.patch) throw new Error('patch 动作需要 patch 参数')
|
|
340
|
+
const r = await applyPatch(proj, args.patch, { keep: args.keep ?? keep })
|
|
341
|
+
return { action, ok: r.ok, text: r.text }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
throw new Error(`未知动作: ${action}`)
|
|
345
|
+
} catch (e) {
|
|
346
|
+
return { action, ok: false, text: `执行失败: ${e?.message ?? String(e)}` }
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
}),
|
|
350
|
+
)
|
|
351
|
+
}
|