mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
37 Commits
sun/tempv2
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79b2b499d | ||
|
|
e0bc29a648 | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 | ||
|
|
3d2c10cd0b | ||
|
|
03de81c5f3 | ||
|
|
7abcaa7f68 | ||
|
|
8fb2476985 | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 | ||
|
|
38e8806d91 | ||
|
|
a7865cd0a7 | ||
|
|
f77b7eea68 | ||
|
|
dd7f741b62 | ||
|
|
e7d5ecdd01 |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -99,22 +99,6 @@ jobs:
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
windows-compat:
|
||||
needs: fast-gate
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python scripts/fetch_meta.py
|
||||
- name: Run Windows compatibility tests
|
||||
run: go test -count=1 -timeout=5m . ./shortcuts/doc/...
|
||||
|
||||
lint:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
46
.github/workflows/semantic-review.yml
vendored
46
.github/workflows/semantic-review.yml
vendored
@@ -25,19 +25,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
@@ -253,19 +250,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,7 +42,6 @@ tests/mail/reports/
|
||||
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-cli-e2e-test/reports/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
|
||||
1
.lark-slides-on9-readback.xml
Normal file
1
.lark-slides-on9-readback.xml
Normal file
File diff suppressed because one or more lines are too long
88
CHANGELOG.md
88
CHANGELOG.md
@@ -2,6 +2,90 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [v1.0.79] - 2026-07-28
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: update xsd (#2067)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci**: validate static workflow identity (#2015)
|
||||
- **sheets**: recognize OFL0X local office tokens (#2063)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: clarify identity selection by event ownership (#2071)
|
||||
- **slides**: add formula inline element syntax to quick-ref (#2077)
|
||||
|
||||
## [v1.0.78] - 2026-07-27
|
||||
|
||||
### Features
|
||||
|
||||
- event description support rich text (#1975)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: restrict canvas overflow checks
|
||||
- **slides**: upgrade text overflow to error above 10px threshold
|
||||
- **slides**: detect letterSpacing-driven text overflow
|
||||
- **slides**: downgrade background-decoration text overflow to info
|
||||
- **slides**: allow chartParsedValues roundtrip tag
|
||||
- refine character width estimation for lark-slides text lint
|
||||
- **slides**: preserve info lint severity
|
||||
- **slides**: text may over flow shape
|
||||
- exempt ghost text from slides lint
|
||||
|
||||
## [v1.0.77] - 2026-07-24
|
||||
|
||||
### Features
|
||||
|
||||
- introducing official card icon (#1973)
|
||||
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
|
||||
- **apps**: support absolute and relative upload paths (#2005)
|
||||
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
|
||||
- **slides**: add layout density lint for sparse/empty containers (#2022)
|
||||
- add risk-control protection (#1910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: normalize presentation flag aliases (#2032)
|
||||
- **base**: classify +form-submit as high-risk-write (#1969)
|
||||
- **slides**: declare screenshot scope
|
||||
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: clarify scope handling for query expansion (#2030)
|
||||
- **base**: clarify complete and partial updates (#1993)
|
||||
- **skills**: clarify callout child rules (#2048)
|
||||
|
||||
### Misc
|
||||
|
||||
- fix/task id handling (#2023)
|
||||
- fix/task search pagination (#2041)
|
||||
|
||||
## [v1.0.75] - 2026-07-22
|
||||
|
||||
### Features
|
||||
@@ -1638,6 +1722,10 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
|
||||
23
README.md
23
README.md
@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
|
||||
|
||||
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
|
||||
|
||||
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
|
||||
|
||||
- Operating system type: macOS, Windows, or Linux
|
||||
- Device hardware model: for example, Mac17,9
|
||||
|
||||
To disable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
To enable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
To restore the default policy for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
23
README.zh.md
23
README.zh.md
@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
|
||||
|
||||
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
|
||||
|
||||
为降低访问令牌被盗用后的安全风险,CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
|
||||
|
||||
- 操作系统类型:macOS、Windows 或 Linux
|
||||
- 设备的硬件产品型号:例如 Mac17,9
|
||||
|
||||
如需让当前 workspace 退出该保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
如需开启当前 workspace 的保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
恢复当前 workspace 默认策略可执行:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// BenchmarkBuild_Default measures the per-Build cost for the default
|
||||
// configuration (service commands + shortcuts + plugins + strict mode).
|
||||
// This is the hot-path baseline for repeated Build invocations.
|
||||
func BenchmarkBuild_Default(b *testing.B) {
|
||||
// Warm one-time caches first
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuild_WithoutServiceCommands measures the Build cost without
|
||||
// service command registration. The delta from Default gives the
|
||||
// service-command registration cost.
|
||||
func BenchmarkBuild_WithoutServiceCommands(b *testing.B) {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands())
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands())
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuild_WithoutPlugins measures the Build cost without plugins.
|
||||
// The delta from Default gives the plugin + policy + hook cost.
|
||||
func BenchmarkBuild_WithoutPlugins(b *testing.B) {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuild_WithoutServiceAndPlugins measures the Build cost with
|
||||
// neither service commands nor plugins. This isolates the base cost
|
||||
// (root command + builtins + shortcuts).
|
||||
func BenchmarkBuild_WithoutServiceAndPlugins(b *testing.B) {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands(), WithoutPlugins())
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands(), WithoutPlugins())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_CommandTreeStats counts the total number of commands,
|
||||
// runnable commands, and flags in the default build. This gives us
|
||||
// the scale of the command tree to reason about optimization targets.
|
||||
func TestBuild_CommandTreeStats(t *testing.T) {
|
||||
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
|
||||
var totalCmds, runnableCmds, groupCmds int
|
||||
var totalFlags int
|
||||
|
||||
var walk func(cmd *cobra.Command)
|
||||
walk = func(cmd *cobra.Command) {
|
||||
totalCmds++
|
||||
if cmd.RunE != nil || cmd.Run != nil {
|
||||
runnableCmds++
|
||||
} else {
|
||||
groupCmds++
|
||||
}
|
||||
if cmd.Flags() != nil {
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
totalFlags++
|
||||
})
|
||||
}
|
||||
for _, child := range cmd.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
|
||||
t.Logf("Command tree stats:")
|
||||
t.Logf(" Total commands: %d", totalCmds)
|
||||
t.Logf(" Runnable commands: %d", runnableCmds)
|
||||
t.Logf(" Group commands: %d", groupCmds)
|
||||
t.Logf(" Total flags: %d", totalFlags)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigRiskControl(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
|
||||
80
cmd/config/risk_control.go
Normal file
80
cmd/config/risk_control.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
|
||||
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "risk-control [on|off|default]",
|
||||
Short: "Manage workspace account-protection policy",
|
||||
Long: `View or set the account-protection risk-control policy for this workspace.
|
||||
|
||||
Account protection is on by default. Use off to opt this workspace out, on to
|
||||
opt it back in explicitly, or default to remove the explicit preference.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
// This is persistent workspace policy, not credential management.
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
printRiskControl(f, config)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "on":
|
||||
enabled := true
|
||||
config.RiskControl = &enabled
|
||||
case "off":
|
||||
enabled := false
|
||||
config.RiskControl = &enabled
|
||||
case "default":
|
||||
config.RiskControl = nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid risk-control value %q, valid values: on | off | default", args[0])
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage,
|
||||
"failed to save risk-control policy: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
|
||||
source := "default"
|
||||
if config.RiskControl != nil {
|
||||
source = "workspace"
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
|
||||
}
|
||||
|
||||
func riskControlState(enabled bool) string {
|
||||
if enabled {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
130
cmd/config/risk_control_test.go
Normal file
130
cmd/config/risk_control_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off: %v", err)
|
||||
}
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "set to off") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set on: %v", err)
|
||||
}
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || !*loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"default"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("reset default: %v", err)
|
||||
}
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl != nil {
|
||||
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show default: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"invalid"})
|
||||
err := cmd.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs([]string{"risk-control", "off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off with external credentials: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||
}
|
||||
}
|
||||
appendSlidesXMLQuickReference(&b, cmd, skillFS)
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
@@ -116,6 +117,90 @@ const (
|
||||
shortcutBaseAnnotation = "affordance-shortcut-base"
|
||||
)
|
||||
|
||||
const slidesXMLQuickReferencePath = "lark-slides/references/xml-schema-quick-ref.md"
|
||||
|
||||
// slidesShortcutReferencePaths maps each Slides shortcut to its primary
|
||||
// command guide. XML-consuming shortcuts also include the shared schema
|
||||
// reference because their command guide accepts XML but does not repeat the
|
||||
// complete element grammar.
|
||||
var slidesShortcutReferencePaths = map[string][]string{
|
||||
"+create": {
|
||||
"lark-slides/references/lark-slides-create.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+xml-get": {
|
||||
"lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
},
|
||||
"+screenshot": {
|
||||
"lark-slides/references/lark-slides-screenshot.md",
|
||||
},
|
||||
"+media-upload": {
|
||||
"lark-slides/references/lark-slides-media-upload.md",
|
||||
},
|
||||
"+replace-slide": {
|
||||
"lark-slides/references/lark-slides-replace-slide.md",
|
||||
"lark-slides/references/lark-slides-edit-workflows.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+replace-pages": {
|
||||
"lark-slides/references/lark-slides-replace-pages.md",
|
||||
"lark-slides/references/lark-slides-edit-workflows.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+history-list": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
"+history-revert": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
"+history-revert-status": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
}
|
||||
|
||||
// appendSlidesXMLQuickReference adds the embedded XML schema summary to the
|
||||
// slides domain help. The reference file is already shipped in the skill
|
||||
// content tree, so help and the standalone skill reader share one source of
|
||||
// truth instead of maintaining a second, drifting copy in Go.
|
||||
func appendSlidesXMLQuickReference(b *strings.Builder, cmd *cobra.Command, skillFS fs.FS) {
|
||||
if cmd.Name() != "slides" || skillFS == nil {
|
||||
return
|
||||
}
|
||||
content, err := fs.ReadFile(skillFS, slidesXMLQuickReferencePath)
|
||||
if err != nil || len(content) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("\n\nEmbedded XML syntax quick reference:\n")
|
||||
b.Write(content)
|
||||
}
|
||||
|
||||
func readSlidesShortcutReferences(cmd *cobra.Command, skillFS fs.FS) ([]string, bool) {
|
||||
if cmdmeta.Domain(cmd) != "slides" || skillFS == nil {
|
||||
return nil, false
|
||||
}
|
||||
paths, ok := slidesShortcutReferencePaths[cmd.Name()]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var contents []string
|
||||
for _, path := range paths {
|
||||
content, err := fs.ReadFile(skillFS, path)
|
||||
if err != nil || len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
contents = append(contents, fmt.Sprintf("Embedded command reference: %s\n%s", path, content))
|
||||
}
|
||||
return contents, len(contents) > 0
|
||||
}
|
||||
|
||||
func appendSlidesShortcutReferences(b *strings.Builder, contents []string) {
|
||||
for _, content := range contents {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(content)
|
||||
}
|
||||
}
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
// few strings is the only build-time cost; the overlay stays untouched).
|
||||
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
|
||||
@@ -171,11 +256,11 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
|
||||
// overlay — the same top layout as method help (description, Risk, guidance
|
||||
// block, related skills) minus the schema pointer, which shortcuts have none
|
||||
// of. Returns false when the command is not a shortcut or carries no overlay
|
||||
// entry, so shortcuts without guidance keep the default help plus the bottom
|
||||
// risk/tips append.
|
||||
// overlay and any embedded command references — the same top layout as method
|
||||
// help (description, Risk, guidance block, related skills) minus the schema
|
||||
// pointer, which shortcuts have none of. Returns false when the command is not
|
||||
// a shortcut, or when it has neither an overlay nor an embedded reference, so
|
||||
// ordinary shortcuts keep the default help plus the bottom risk/tips append.
|
||||
//
|
||||
// The lead is the command's pristine base (captureHelpBase): a shortcut that
|
||||
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
|
||||
@@ -191,12 +276,17 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
|
||||
return false
|
||||
}
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return false
|
||||
references, hasReferences := readSlidesShortcutReferences(cmd, skillFS)
|
||||
|
||||
var a meta.Affordance
|
||||
hasAffordance := false
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if parsed, parsedOK := (meta.Method{Affordance: raw}).ParsedAffordance(); parsedOK {
|
||||
a = parsed
|
||||
hasAffordance = true
|
||||
}
|
||||
}
|
||||
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
|
||||
if !ok {
|
||||
if !hasAffordance && !hasReferences {
|
||||
return false
|
||||
}
|
||||
if len(a.Tips) == 0 {
|
||||
@@ -211,6 +301,7 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
b.WriteString(block)
|
||||
}
|
||||
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||
appendSlidesShortcutReferences(&b, references)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
|
||||
@@ -264,6 +264,94 @@ func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareShortcutHelp_SlidesReferenceWithoutAffordance(t *testing.T) {
|
||||
sc := &cobra.Command{Use: "+xml-get", Short: "Fetch presentation XML"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
cmdmeta.SetAffordanceRef(sc, "slides", "+xml-get")
|
||||
cmdutil.SetRisk(sc, "read")
|
||||
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/references/lark-slides-xml-presentations-get.md": {
|
||||
Data: []byte("# slides +xml-get\n\nRead the presentation XML."),
|
||||
},
|
||||
}
|
||||
if !PrepareShortcutHelp(sc, skillFS) {
|
||||
t.Fatal("PrepareShortcutHelp returned false for a Slides shortcut with an embedded reference")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Fetch presentation XML",
|
||||
"Risk: read",
|
||||
"Embedded command reference: lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
"Read the presentation XML.",
|
||||
} {
|
||||
if !strings.Contains(sc.Long, want) {
|
||||
t.Errorf("Slides shortcut help missing %q:\n%s", want, sc.Long)
|
||||
}
|
||||
}
|
||||
PrepareShortcutHelp(sc, skillFS)
|
||||
if got := strings.Count(sc.Long, "Embedded command reference:"); got != 1 {
|
||||
t.Fatalf("embedded reference appended %d times after re-render, want 1:\n%s", got, sc.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesShortcutReferenceMapping(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"+create": "lark-slides/references/lark-slides-create.md",
|
||||
"+xml-get": "lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
"+screenshot": "lark-slides/references/lark-slides-screenshot.md",
|
||||
"+media-upload": "lark-slides/references/lark-slides-media-upload.md",
|
||||
"+replace-slide": "lark-slides/references/lark-slides-replace-slide.md",
|
||||
"+replace-pages": "lark-slides/references/lark-slides-replace-pages.md",
|
||||
"+history-list": "lark-slides/references/lark-slides-history.md",
|
||||
"+history-revert": "lark-slides/references/lark-slides-history.md",
|
||||
"+history-revert-status": "lark-slides/references/lark-slides-history.md",
|
||||
}
|
||||
|
||||
for command, path := range want {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
sc := &cobra.Command{Use: command, Short: command}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
skillFS := fstest.MapFS{
|
||||
path: {Data: []byte("reference content")},
|
||||
}
|
||||
contents, ok := readSlidesShortcutReferences(sc, skillFS)
|
||||
if !ok || len(contents) == 0 {
|
||||
t.Fatalf("shortcut %q has no mapped reference", command)
|
||||
}
|
||||
if !strings.Contains(contents[0], "Embedded command reference: "+path) {
|
||||
t.Fatalf("shortcut %q mapped content does not include %q:\n%s", command, path, contents[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotHelpDoesNotIncludeXMLQuickReference(t *testing.T) {
|
||||
sc := &cobra.Command{Use: "+screenshot", Short: "Save screenshots"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/references/lark-slides-screenshot.md": {
|
||||
Data: []byte("# slides +screenshot\n\nSave screenshots."),
|
||||
},
|
||||
slidesXMLQuickReferencePath: {
|
||||
Data: []byte("# XML Schema Quick Reference"),
|
||||
},
|
||||
}
|
||||
|
||||
contents, ok := readSlidesShortcutReferences(sc, skillFS)
|
||||
if !ok {
|
||||
t.Fatal("screenshot shortcut should have a primary reference")
|
||||
}
|
||||
if len(contents) != 1 {
|
||||
t.Fatalf("screenshot reference count = %d, want 1: %#v", len(contents), contents)
|
||||
}
|
||||
if strings.Contains(contents[0], "XML Schema Quick Reference") {
|
||||
t.Fatalf("screenshot help must not include the XML quick reference:\n%s", contents[0])
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
@@ -306,3 +394,51 @@ func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_SlidesIncludesEmbeddedXMLReference(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
dom := &cobra.Command{Use: "slides", Short: "Slides"}
|
||||
cmdmeta.SetDomain(dom, "slides")
|
||||
dom.AddCommand(&cobra.Command{Use: "+create", Short: "Create", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(dom)
|
||||
|
||||
const quickReference = `# XML Schema Quick Reference
|
||||
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title"><p>Title</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
|
||||
<table><colgroup><col/></colgroup><tr><td><content><p>A</p></content></td></tr></table>
|
||||
<chart><chartPlotArea/><chartData/></chart>`
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/SKILL.md": {Data: []byte("# slides")},
|
||||
"lark-slides/references/xml-schema-quick-ref.md": {Data: []byte(quickReference)},
|
||||
}
|
||||
|
||||
if !PrepareDomainHelp(dom, skillFS) {
|
||||
t.Fatal("PrepareDomainHelp returned false for slides domain")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Embedded XML syntax quick reference:",
|
||||
`<presentation xmlns="http://www.larkoffice.com/sml/2.0"`,
|
||||
"<shape type=\"text\"",
|
||||
"<content",
|
||||
"topLeftX",
|
||||
"<table>",
|
||||
"<chart>",
|
||||
} {
|
||||
if !strings.Contains(dom.Long, want) {
|
||||
t.Errorf("slides help missing XML reference marker %q:\n%s", want, dom.Long)
|
||||
}
|
||||
}
|
||||
PrepareDomainHelp(dom, skillFS)
|
||||
if got := strings.Count(dom.Long, "Embedded XML syntax quick reference:"); got != 1 {
|
||||
t.Fatalf("slides XML reference appended %d times after re-render, want 1:\n%s", got, dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,15 +42,6 @@ type FileIO interface {
|
||||
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
|
||||
}
|
||||
|
||||
// TempDirFileCreator is an optional FileIO capability for atomically creating
|
||||
// a unique directory and an empty named file inside it. The directory pattern
|
||||
// follows os.MkdirTemp semantics: the last '*' is replaced with a random
|
||||
// value. Implementations return a relative file path that can be passed back
|
||||
// to FileIO.
|
||||
type TempDirFileCreator interface {
|
||||
CreateTempDirFile(directoryPattern, fileName string) (string, error)
|
||||
}
|
||||
|
||||
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
|
||||
// os.FileInfo satisfies this interface.
|
||||
type FileInfo interface {
|
||||
|
||||
1
go.mod
1
go.mod
@@ -18,7 +18,6 @@ require (
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sync v0.15.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -131,8 +131,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||
@@ -33,7 +34,7 @@ import (
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
// Phase 2: Credential (sole data source for account info)
|
||||
// Phase 3: Config derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential and workspace policy
|
||||
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
streams = normalizeStreams(streams)
|
||||
f := &Factory{
|
||||
@@ -54,9 +55,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
workspaceConfig := core.NewConfigSnapshot()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc(f)
|
||||
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
@@ -67,7 +69,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Config derived from Credential via an explicit conversion boundary.
|
||||
// Phase 3: Runtime config contains resolved account data only.
|
||||
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -78,8 +80,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return cfg, nil
|
||||
})
|
||||
|
||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
// Phase 4: LarkClient composes account data and workspace policy at the SDK
|
||||
// transport boundary.
|
||||
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
|
||||
|
||||
return f
|
||||
}
|
||||
@@ -108,13 +111,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
// .StderrIsTerminal field, which tests set directly.
|
||||
var warnIfProxied = transport.WarnIfProxied
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = riskcontrol.NewTransport(rt, hostSignalSource)
|
||||
rt = &RetryTransport{Base: rt}
|
||||
rt = &SecurityHeaderTransport{Base: rt}
|
||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
||||
@@ -128,7 +134,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
|
||||
return sync.OnceValues(func() (*lark.Client, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -142,8 +148,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
var sdkBase http.RoundTripper = transport.Shared()
|
||||
// The innermost SDK boundary always strips reserved host-signal headers;
|
||||
// a nil source makes it strip-only when workspace policy disables signal
|
||||
// collection.
|
||||
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
|
||||
sdkTransport := wrapSDKTransport(sdkBase)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
Transport: sdkTransport,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
@@ -152,9 +165,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = transport.Shared()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
|
||||
@@ -6,10 +6,15 @@ package cmdutil
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
|
||||
c1, err := fn()
|
||||
if err != nil {
|
||||
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
c, _ := fn()
|
||||
if c.Timeout == 0 {
|
||||
t.Error("expected non-zero timeout")
|
||||
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
c, _ := fn()
|
||||
if c.CheckRedirect == nil {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
|
||||
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
|
||||
// invokes WarnIfProxied only when stderr is an interactive terminal.
|
||||
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
isEnabled := false
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := installProxyWarnSpy(t)
|
||||
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
|
||||
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
|
||||
}})
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
f.IOStreams.StderrIsTerminal = tc.terminal
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
if _, err := fn(); err != nil {
|
||||
t.Fatalf("http client init: %v", err)
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
// normalizeStreams copies the struct (out := *s), so the
|
||||
// StderrIsTerminal field survives into f.IOStreams.
|
||||
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
|
||||
if _, err := cachedLarkClientFunc(f)(); err != nil {
|
||||
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
|
||||
t.Fatalf("lark client init: %v", err)
|
||||
}
|
||||
|
||||
|
||||
28
internal/cmdutil/risk_control.go
Normal file
28
internal/cmdutil/risk_control.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
)
|
||||
|
||||
type workspaceConfigSource interface {
|
||||
MultiAppConfig() (*core.MultiAppConfig, error)
|
||||
}
|
||||
|
||||
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
|
||||
// boundary.
|
||||
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
workspace, configErr := config.MultiAppConfig()
|
||||
// Default-on means an existing config with no explicit preference. Absent
|
||||
// or unreadable config cannot authorize host-signal collection.
|
||||
if configErr != nil || !workspace.RiskControlEnabled() {
|
||||
return nil
|
||||
}
|
||||
return riskcontrol.NewHostSource()
|
||||
}
|
||||
45
internal/cmdutil/risk_control_test.go
Normal file
45
internal/cmdutil/risk_control_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type staticWorkspaceConfig struct {
|
||||
config *core.MultiAppConfig
|
||||
err error
|
||||
}
|
||||
|
||||
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
|
||||
return s.config, s.err
|
||||
}
|
||||
|
||||
func TestResolveSDKHostSignalSource(t *testing.T) {
|
||||
disabled := false
|
||||
tests := []struct {
|
||||
name string
|
||||
config workspaceConfigSource
|
||||
wantSource bool
|
||||
}{
|
||||
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
|
||||
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
|
||||
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
|
||||
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
|
||||
{name: "nil config value", config: staticWorkspaceConfig{}},
|
||||
{name: "nil config source"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := resolveSDKHostSignalSource(test.config)
|
||||
if (got != nil) != test.wantSource {
|
||||
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// Agent headers injected via BaseSecurityHeaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(envvars.CliAgentName, agentName)
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != agentName {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildSDKTransport chain composition
|
||||
// wrapSDKTransport chain composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := buildSDKTransport()
|
||||
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
func TestWrapSDKTransport_WithExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
|
||||
return nil
|
||||
}
|
||||
|
||||
type riskHeaderTamperingInterceptor struct{}
|
||||
|
||||
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
|
||||
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
var received http.Header
|
||||
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
|
||||
t.Fatalf("extension risk headers reached network: %v", received)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
|
||||
// transport chain, even when an extension tries to delete or spoof it. This
|
||||
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Replicate the SDK chain layering used by buildSDKTransport.
|
||||
// Replicate the SDK chain layering used by wrapSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
|
||||
@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
|
||||
// MultiAppConfig is the multi-app config file format.
|
||||
type MultiAppConfig struct {
|
||||
StrictMode StrictMode `json:"strictMode,omitempty"`
|
||||
RiskControl *bool `json:"riskControl,omitempty"`
|
||||
CurrentApp string `json:"currentApp,omitempty"`
|
||||
PreviousApp string `json:"previousApp,omitempty"`
|
||||
Apps []AppConfig `json:"apps"`
|
||||
}
|
||||
|
||||
// RiskControlEnabled resolves the workspace policy. An omitted preference
|
||||
// keeps the default-on account-protection behavior.
|
||||
func (m *MultiAppConfig) RiskControlEnabled() bool {
|
||||
return m != nil && (m.RiskControl == nil || *m.RiskControl)
|
||||
}
|
||||
|
||||
// CurrentAppConfig returns the currently active app config.
|
||||
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
|
||||
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
|
||||
|
||||
37
internal/core/config_snapshot.go
Normal file
37
internal/core/config_snapshot.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
|
||||
// invocation. All runtime consumers share the same load result so account and
|
||||
// workspace policy resolution cannot observe different file revisions. Callers
|
||||
// must treat the returned config as read-only.
|
||||
type ConfigSnapshot struct {
|
||||
load func() (*MultiAppConfig, error)
|
||||
}
|
||||
|
||||
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
|
||||
func NewConfigSnapshot() *ConfigSnapshot {
|
||||
return newConfigSnapshot(LoadMultiAppConfig)
|
||||
}
|
||||
|
||||
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
|
||||
if load == nil {
|
||||
return &ConfigSnapshot{}
|
||||
}
|
||||
return &ConfigSnapshot{load: sync.OnceValues(load)}
|
||||
}
|
||||
|
||||
// MultiAppConfig returns the captured persistent config and load error.
|
||||
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
|
||||
if s == nil || s.load == nil {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return s.load()
|
||||
}
|
||||
58
internal/core/config_snapshot_test.go
Normal file
58
internal/core/config_snapshot_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigSnapshotLoadsOnce(t *testing.T) {
|
||||
calls := 0
|
||||
want := &MultiAppConfig{}
|
||||
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
|
||||
calls++
|
||||
return want, nil
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
config, err := snapshot.MultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config != want {
|
||||
t.Fatal("snapshot returned a different config instance")
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("config loads = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
|
||||
config, err := (&ConfigSnapshot{}).MultiAppConfig()
|
||||
if config != nil || !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSnapshotCachesError(t *testing.T) {
|
||||
calls := 0
|
||||
want := errors.New("load failed")
|
||||
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
|
||||
calls++
|
||||
return nil, want
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
config, err := snapshot.MultiAppConfig()
|
||||
if config != nil || !errors.Is(err, want) {
|
||||
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("config loads = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||
disabled := false
|
||||
config := &MultiAppConfig{
|
||||
RiskControl: &disabled,
|
||||
Apps: []AppConfig{{
|
||||
AppId: "cli_test", AppSecret: PlainSecret("s"),
|
||||
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
|
||||
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||
if got.Apps[0].Brand != BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
|
||||
}
|
||||
if got.RiskControl == nil || *got.RiskControl {
|
||||
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
|
||||
|
||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
142
internal/riskcontrol/osmodel.go
Normal file
142
internal/riskcontrol/osmodel.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package deviceinfo collects the platform hardware product model and the
|
||||
// platform values used by device-related risk-control headers.
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
// OSType is the server-side risk-control operating-system enum.
|
||||
type OSType string
|
||||
|
||||
// OS type enum values for X-Agent-Os-Type.
|
||||
const (
|
||||
OSTypeUnknown = "0"
|
||||
OSTypeWindows = "1"
|
||||
OSTypeLinux = "2"
|
||||
OSTypeMacOS = "3"
|
||||
)
|
||||
|
||||
const (
|
||||
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
|
||||
TerminalTypePC = "1"
|
||||
|
||||
// Unknown is used when the hardware product model cannot be collected.
|
||||
Unknown = "Unknown"
|
||||
|
||||
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
|
||||
// Device models are short identifiers; a larger value is treated as
|
||||
// malformed rather than truncated so the header never misrepresents it.
|
||||
deviceModelMaxBytes = 256
|
||||
)
|
||||
|
||||
// Snapshot contains the deliberately small risk-control signal set.
|
||||
// ProductModel is omitted when the platform cannot provide a safe value.
|
||||
type Snapshot struct {
|
||||
OSType OSType
|
||||
ProductModel string
|
||||
}
|
||||
|
||||
// Source supplies one immutable process-level snapshot.
|
||||
type Source interface {
|
||||
Snapshot() Snapshot
|
||||
}
|
||||
|
||||
// HostSource lazily reads host signals once, after outbound policy authorizes
|
||||
// the first request. Failed probes are cached and are not retried per request.
|
||||
type HostSource struct {
|
||||
once sync.Once
|
||||
value Snapshot
|
||||
readModel func() string
|
||||
}
|
||||
|
||||
// NewHostSource creates the production host signal source.
|
||||
func NewHostSource() *HostSource {
|
||||
return &HostSource{readModel: readDeviceModel}
|
||||
}
|
||||
|
||||
// Snapshot returns the cached host signal snapshot.
|
||||
func (s *HostSource) Snapshot() Snapshot {
|
||||
if s == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
s.once.Do(func() {
|
||||
readModel := s.readModel
|
||||
if readModel == nil {
|
||||
readModel = readDeviceModel
|
||||
}
|
||||
s.value = Snapshot{
|
||||
OSType: GetOSType(OSName()),
|
||||
ProductModel: normalizeDeviceModel(readModel()),
|
||||
}
|
||||
})
|
||||
return s.value
|
||||
}
|
||||
|
||||
// normalizeModel removes non-printable characters and returns a model only
|
||||
// when the remaining text is safe to use as an HTTP header value. Input that
|
||||
// cannot produce a valid model is rejected so Get can fall back to Unknown.
|
||||
func normalizeDeviceModel(model string) string {
|
||||
if !utf8.ValidString(model) {
|
||||
return ""
|
||||
}
|
||||
model = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r == '\r' || r == '\n' || r == '\x00':
|
||||
return -1
|
||||
case unicode.IsSpace(r):
|
||||
return ' '
|
||||
case unicode.IsPrint(r):
|
||||
return r
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}, model)
|
||||
|
||||
model = strings.Join(strings.Fields(model), " ")
|
||||
|
||||
if model == "" || len(model) > deviceModelMaxBytes {
|
||||
return ""
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldValue(model) {
|
||||
return ""
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
|
||||
func GetOSType(osName string) OSType {
|
||||
switch osName {
|
||||
case "Windows":
|
||||
return OSTypeWindows
|
||||
case "Linux":
|
||||
return OSTypeLinux
|
||||
case "MacOS":
|
||||
return OSTypeMacOS
|
||||
default:
|
||||
return OSTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// OSName returns the platform name used by GetOSType.
|
||||
func OSName() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "MacOS"
|
||||
case "windows":
|
||||
return "Windows"
|
||||
case "linux":
|
||||
return "Linux"
|
||||
default:
|
||||
return runtime.GOOS
|
||||
}
|
||||
}
|
||||
27
internal/riskcontrol/osmodel_darwin.go
Normal file
27
internal/riskcontrol/osmodel_darwin.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build darwin
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// readDeviceModel reads the current product key first and falls back to the
|
||||
// legacy model key. Trying both keys is more robust than branching on a macOS
|
||||
// version because virtualized or restricted environments may expose only one.
|
||||
func readDeviceModel() string {
|
||||
return readDarwinDeviceModel(unix.Sysctl)
|
||||
}
|
||||
|
||||
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
|
||||
for _, key := range [...]string{"hw.product", "hw.model"} {
|
||||
model, err := readSysctl(key)
|
||||
if err == nil {
|
||||
if model = normalizeDeviceModel(model); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
48
internal/riskcontrol/osmodel_darwin_test.go
Normal file
48
internal/riskcontrol/osmodel_darwin_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build darwin
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
|
||||
t.Run("product available", func(t *testing.T) {
|
||||
var keys []string
|
||||
got := readDarwinDeviceModel(func(key string) (string, error) {
|
||||
keys = append(keys, key)
|
||||
if key == "hw.product" {
|
||||
return "Mac16,1", nil
|
||||
}
|
||||
return "", errors.New("unexpected fallback")
|
||||
})
|
||||
if got != "Mac16,1" {
|
||||
t.Fatalf("model = %q, want %q", got, "Mac16,1")
|
||||
}
|
||||
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("sysctl keys = %v, want %v", keys, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("product unavailable", func(t *testing.T) {
|
||||
var keys []string
|
||||
got := readDarwinDeviceModel(func(key string) (string, error) {
|
||||
keys = append(keys, key)
|
||||
if key == "hw.model" {
|
||||
return "MacBookPro18,3", nil
|
||||
}
|
||||
return "", errors.New("not available")
|
||||
})
|
||||
if got != "MacBookPro18,3" {
|
||||
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
|
||||
}
|
||||
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("sysctl keys = %v, want %v", keys, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
17
internal/riskcontrol/osmodel_linux.go
Normal file
17
internal/riskcontrol/osmodel_linux.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build linux
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
|
||||
// values vary widely and can expose the host or virtualization platform when
|
||||
// the CLI runs in a container or sandbox.
|
||||
func readDeviceModel() string {
|
||||
return readLinuxDeviceModel()
|
||||
}
|
||||
|
||||
func readLinuxDeviceModel() string {
|
||||
return "linux"
|
||||
}
|
||||
20
internal/riskcontrol/osmodel_linux_test.go
Normal file
20
internal/riskcontrol/osmodel_linux_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build linux
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReadDeviceModelReturnsLinux(t *testing.T) {
|
||||
if got := readDeviceModel(); got != "linux" {
|
||||
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLinuxDeviceModel(t *testing.T) {
|
||||
if got := readLinuxDeviceModel(); got != "linux" {
|
||||
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
|
||||
}
|
||||
}
|
||||
11
internal/riskcontrol/osmodel_other.go
Normal file
11
internal/riskcontrol/osmodel_other.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin && !windows && !linux
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
// readDeviceModel returns an empty model on unsupported platforms.
|
||||
func readDeviceModel() string {
|
||||
return ""
|
||||
}
|
||||
143
internal/riskcontrol/osmodel_test.go
Normal file
143
internal/riskcontrol/osmodel_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
|
||||
calls := 0
|
||||
s := &HostSource{readModel: func() string {
|
||||
calls++
|
||||
return " MacBookPro18,3\n"
|
||||
}}
|
||||
|
||||
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
|
||||
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
|
||||
}
|
||||
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
|
||||
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("read called %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostSourceCachesEmptyModel(t *testing.T) {
|
||||
calls := 0
|
||||
s := &HostSource{readModel: func() string {
|
||||
calls++
|
||||
return ""
|
||||
}}
|
||||
|
||||
if got := s.Snapshot(); got.ProductModel != "" {
|
||||
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
|
||||
}
|
||||
if got := s.Snapshot(); got.ProductModel != "" {
|
||||
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("read called %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
s := &HostSource{readModel: func() string {
|
||||
calls.Add(1)
|
||||
return "ThinkPad X1 Carbon"
|
||||
}}
|
||||
|
||||
const goroutines = 32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
snapshot := s.Snapshot()
|
||||
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
|
||||
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("read called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDeviceModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
|
||||
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
|
||||
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
|
||||
{name: "rejects empty", model: " \t\r\n"},
|
||||
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
|
||||
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
|
||||
{name: "normalizes tab", model: "model\tname", want: "model name"},
|
||||
{name: "removes NUL", model: "model\x00name", want: "modelname"},
|
||||
{name: "removes control character", model: "model\x1fname", want: "modelname"},
|
||||
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
|
||||
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
|
||||
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
|
||||
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
|
||||
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := normalizeDeviceModel(tt.model); got != tt.want {
|
||||
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
|
||||
for value := 0; value <= 0x7f; value++ {
|
||||
if value >= 0x20 && value < 0x7f {
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
|
||||
model := "model" + string(rune(value)) + "name"
|
||||
want := "modelname"
|
||||
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
|
||||
want = "model name"
|
||||
}
|
||||
if got := normalizeDeviceModel(model); got != want {
|
||||
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOSType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want OSType
|
||||
}{
|
||||
{name: "Windows", want: OSTypeWindows},
|
||||
{name: "Linux", want: OSTypeLinux},
|
||||
{name: "MacOS", want: OSTypeMacOS},
|
||||
{name: "unknown", want: OSTypeUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := GetOSType(tt.name); got != tt.want {
|
||||
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
44
internal/riskcontrol/osmodel_windows.go
Normal file
44
internal/riskcontrol/osmodel_windows.go
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import "golang.org/x/sys/windows/registry"
|
||||
|
||||
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
|
||||
var systemInfoRegistryPaths = [...]string{
|
||||
`HARDWARE\DESCRIPTION\System\BIOS`,
|
||||
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
|
||||
`SYSTEM\HardwareConfig\Current`,
|
||||
}
|
||||
|
||||
// readDeviceModel returns the first product name found in the Windows registry.
|
||||
func readDeviceModel() string {
|
||||
return readWindowsDeviceModel(readWindowsRegistryModel)
|
||||
}
|
||||
|
||||
func readWindowsRegistryModel(path string) (string, error) {
|
||||
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
model, _, err := key.GetStringValue("SystemProductName")
|
||||
return model, err
|
||||
}
|
||||
|
||||
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
|
||||
for _, path := range systemInfoRegistryPaths {
|
||||
model, err := readRegistryModel(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if model = normalizeDeviceModel(model); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
78
internal/riskcontrol/osmodel_windows_test.go
Normal file
78
internal/riskcontrol/osmodel_windows_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
//go:build windows
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadWindowsDeviceModelFallback(t *testing.T) {
|
||||
readError := errors.New("registry read failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]string
|
||||
errors map[string]error
|
||||
want string
|
||||
wantPaths []string
|
||||
}{
|
||||
{
|
||||
name: "first path wins",
|
||||
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
|
||||
want: "Surface Laptop",
|
||||
wantPaths: []string{systemInfoRegistryPaths[0]},
|
||||
},
|
||||
{
|
||||
name: "read failure falls back",
|
||||
errors: map[string]error{
|
||||
systemInfoRegistryPaths[0]: readError,
|
||||
},
|
||||
values: map[string]string{
|
||||
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
|
||||
},
|
||||
want: "ThinkPad X1 Carbon",
|
||||
wantPaths: systemInfoRegistryPaths[:2],
|
||||
},
|
||||
{
|
||||
name: "empty normalized value falls back",
|
||||
values: map[string]string{
|
||||
systemInfoRegistryPaths[0]: " \r\n\x00",
|
||||
systemInfoRegistryPaths[1]: "Latitude 7450",
|
||||
},
|
||||
want: "Latitude 7450",
|
||||
wantPaths: systemInfoRegistryPaths[:2],
|
||||
},
|
||||
{
|
||||
name: "all paths fail",
|
||||
errors: map[string]error{
|
||||
systemInfoRegistryPaths[0]: readError,
|
||||
systemInfoRegistryPaths[1]: readError,
|
||||
systemInfoRegistryPaths[2]: readError,
|
||||
},
|
||||
wantPaths: systemInfoRegistryPaths[:],
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var paths []string
|
||||
got := readWindowsDeviceModel(func(path string) (string, error) {
|
||||
paths = append(paths, path)
|
||||
if err := tt.errors[path]; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tt.values[path], nil
|
||||
})
|
||||
if got != tt.want {
|
||||
t.Fatalf("model = %q, want %q", got, tt.want)
|
||||
}
|
||||
if !reflect.DeepEqual(paths, tt.wantPaths) {
|
||||
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
138
internal/riskcontrol/transport.go
Normal file
138
internal/riskcontrol/transport.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderProductModel = "X-Agent-Device-Type"
|
||||
HeaderOSType = "X-Agent-Os-Type"
|
||||
)
|
||||
|
||||
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
|
||||
|
||||
// Transport is the feature's final outbound boundary. It removes caller- or
|
||||
// extension-supplied signal headers first and writes trusted values only after
|
||||
// authorizing an official SDK origin and authentication state.
|
||||
type Transport struct {
|
||||
next http.RoundTripper
|
||||
source Source
|
||||
}
|
||||
|
||||
// NewTransport creates the final SDK outbound policy boundary. A nil source
|
||||
// disables collection and injection while preserving restricted-header
|
||||
// stripping for opt-out and extension-credential requests.
|
||||
func NewTransport(next http.RoundTripper, source Source) *Transport {
|
||||
if next == nil {
|
||||
next = internaltransport.Fallback()
|
||||
}
|
||||
return &Transport{
|
||||
next: next,
|
||||
source: source,
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
if req.Header == nil {
|
||||
req.Header = make(http.Header)
|
||||
}
|
||||
stripRestrictedHeaders(req.Header)
|
||||
|
||||
if t.source != nil && t.routeAllowsSignals(req) {
|
||||
snapshot := t.source.Snapshot()
|
||||
if isSupportedOSType(snapshot.OSType) {
|
||||
req.Header.Set(HeaderOSType, string(snapshot.OSType))
|
||||
}
|
||||
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
|
||||
req.Header.Set(HeaderProductModel, model)
|
||||
}
|
||||
}
|
||||
return t.next.RoundTrip(req)
|
||||
}
|
||||
|
||||
func isSupportedOSType(value OSType) bool {
|
||||
switch value {
|
||||
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stripRestrictedHeaders(header http.Header) {
|
||||
for name := range header {
|
||||
for _, restricted := range restrictedHeaders {
|
||||
if strings.EqualFold(name, restricted) {
|
||||
delete(header, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type origin struct {
|
||||
scheme string
|
||||
host string
|
||||
port string
|
||||
}
|
||||
|
||||
var officialFeishuOrigins = [...]origin{
|
||||
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
|
||||
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
|
||||
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
|
||||
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
|
||||
}
|
||||
|
||||
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
|
||||
if req == nil || req.URL == nil {
|
||||
return false
|
||||
}
|
||||
return isOfficialFeishuOrigin(originOf(req.URL))
|
||||
}
|
||||
|
||||
func originOf(value *url.URL) origin {
|
||||
if value == nil {
|
||||
return origin{}
|
||||
}
|
||||
scheme := strings.ToLower(value.Scheme)
|
||||
port := value.Port()
|
||||
if port == "" {
|
||||
switch scheme {
|
||||
case "https":
|
||||
port = "443"
|
||||
case "http":
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
|
||||
}
|
||||
|
||||
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
|
||||
endpoint, err := url.Parse(endpointURL)
|
||||
if err != nil {
|
||||
return origin{}
|
||||
}
|
||||
return originOf(endpoint)
|
||||
}
|
||||
|
||||
func isOfficialFeishuOrigin(candidate origin) bool {
|
||||
if candidate.scheme != "https" || candidate.port != "443" {
|
||||
return false
|
||||
}
|
||||
for _, official := range officialFeishuOrigins {
|
||||
if candidate == official {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
124
internal/riskcontrol/transport_test.go
Normal file
124
internal/riskcontrol/transport_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type countingSource struct {
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (s *countingSource) Snapshot() Snapshot {
|
||||
s.calls.Add(1)
|
||||
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
|
||||
}
|
||||
|
||||
type staticSource Snapshot
|
||||
|
||||
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
|
||||
|
||||
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestURL string
|
||||
authorization string
|
||||
wantSignals bool
|
||||
}{
|
||||
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
|
||||
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
|
||||
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
|
||||
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
|
||||
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
source := &countingSource{}
|
||||
var received http.Header
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", test.authorization)
|
||||
req.Header.Set(HeaderOSType, "caller-value")
|
||||
req.Header.Set(HeaderProductModel, "caller-value")
|
||||
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
|
||||
|
||||
resp, err := NewTransport(base, source).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
gotSignals := received.Get(HeaderOSType) != ""
|
||||
if gotSignals != test.wantSignals {
|
||||
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
|
||||
}
|
||||
wantCalls := int32(0)
|
||||
if test.wantSignals {
|
||||
wantCalls = 1
|
||||
}
|
||||
if got := source.calls.Load(); got != wantCalls {
|
||||
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
|
||||
}
|
||||
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
|
||||
t.Fatalf("caller request OS header = %q, want unchanged", got)
|
||||
}
|
||||
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
|
||||
t.Fatalf("caller request product-model header = %q, want unchanged", got)
|
||||
}
|
||||
if !test.wantSignals {
|
||||
for name := range received {
|
||||
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
|
||||
t.Fatalf("restricted header leaked as %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportValidatesSourceSnapshot(t *testing.T) {
|
||||
var received http.Header
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
resp, err := NewTransport(base, staticSource{
|
||||
OSType: OSType("unsupported"),
|
||||
ProductModel: "unsafe\nvalue",
|
||||
}).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
|
||||
t.Fatalf("no signals collected: %v", received)
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,10 @@ package localfileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -34,8 +30,6 @@ func init() {
|
||||
// and atomic writes are handled internally.
|
||||
type LocalFileIO struct{}
|
||||
|
||||
var _ fileio.TempDirFileCreator = (*LocalFileIO)(nil)
|
||||
|
||||
// Open opens a local file for reading after validating the path.
|
||||
func (l *LocalFileIO) Open(name string) (fileio.File, error) {
|
||||
safePath, err := SafeInputPath(name)
|
||||
@@ -68,46 +62,6 @@ func (l *LocalFileIO) ResolvePath(path string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// CreateTempDirFile atomically creates a unique directory in the current
|
||||
// working directory, then creates the requested empty file inside it.
|
||||
func (l *LocalFileIO) CreateTempDirFile(directoryPattern, fileName string) (string, error) {
|
||||
if err := validateTempDirectoryPattern(directoryPattern); err != nil {
|
||||
return "", &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
if err := validateTempFileName(fileName); err != nil {
|
||||
return "", &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
tempDir, err := vfs.MkdirTemp(".", directoryPattern)
|
||||
if err != nil {
|
||||
return "", &fileio.MkdirError{Err: err}
|
||||
}
|
||||
path := filepath.Join(tempDir, fileName)
|
||||
tempFile, err := vfs.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
_ = vfs.RemoveAll(tempDir)
|
||||
return "", &fileio.WriteError{Err: err}
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
_ = vfs.RemoveAll(tempDir)
|
||||
return "", &fileio.WriteError{Err: fmt.Errorf("close temporary file: %w", err)}
|
||||
}
|
||||
return filepath.Join(filepath.Base(tempDir), fileName), nil
|
||||
}
|
||||
|
||||
func validateTempDirectoryPattern(pattern string) error {
|
||||
if strings.TrimSpace(pattern) == "" || strings.ContainsAny(pattern, `/\\`) || strings.Count(pattern, "*") != 1 {
|
||||
return fmt.Errorf("temporary directory pattern must be one non-empty path component containing exactly one '*'")
|
||||
}
|
||||
return charcheck.RejectControlChars(pattern, "temporary directory pattern")
|
||||
}
|
||||
|
||||
func validateTempFileName(fileName string) error {
|
||||
if strings.TrimSpace(fileName) == "" || fileName != filepath.Base(fileName) || strings.ContainsAny(fileName, "/\\\t\r\n") {
|
||||
return fmt.Errorf("temporary file name must be one non-empty path component")
|
||||
}
|
||||
return charcheck.RejectControlChars(fileName, "temporary file name")
|
||||
}
|
||||
|
||||
// Save writes body to path atomically after validating the output path.
|
||||
// Parent directories are created as needed. The body is streamed directly
|
||||
// to a temp file and renamed, avoiding full in-memory buffering.
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -252,81 +250,6 @@ func TestLocalFileIO_ResolvePath_RejectsAbsolute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_CreateTempDirFileIsUniqueUnderConcurrency(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
const count = 32
|
||||
type result struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
results := make(chan result, count)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < count; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
path, err := (&LocalFileIO{}).CreateTempDirFile("川西_*_folder", "川西.xml")
|
||||
results <- result{path: path, err: err}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
seen := make(map[string]struct{}, count)
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
t.Fatalf("CreateTempDirFile failed: %v", result.err)
|
||||
}
|
||||
directory := filepath.Dir(result.path)
|
||||
if filepath.Base(result.path) != "川西.xml" || filepath.Base(directory) != directory ||
|
||||
!strings.HasPrefix(directory, "川西_") || !strings.HasSuffix(directory, "_folder") {
|
||||
t.Fatalf("CreateTempDirFile path = %q, want 川西_<random>_folder/川西.xml", result.path)
|
||||
}
|
||||
if _, ok := seen[directory]; ok {
|
||||
t.Fatalf("CreateTempDirFile returned duplicate directory %q", directory)
|
||||
}
|
||||
seen[directory] = struct{}{}
|
||||
info, err := os.Stat(result.path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat temporary file %q: %v", result.path, err)
|
||||
}
|
||||
if info.Size() != 0 {
|
||||
t.Fatalf("temporary file %q size = %d, want 0", result.path, info.Size())
|
||||
}
|
||||
}
|
||||
if len(seen) != count {
|
||||
t.Fatalf("unique temporary files = %d, want %d", len(seen), count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_CreateTempDirFileRejectsUnsafeComponents(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
fio := &LocalFileIO{}
|
||||
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
fileName string
|
||||
}{
|
||||
{pattern: "../lark-doc-*", fileName: "draft.xml"},
|
||||
{pattern: "lark-doc-*", fileName: "../draft.xml"},
|
||||
{pattern: "lark-doc-*", fileName: `folder\draft.xml`},
|
||||
} {
|
||||
if _, err := fio.CreateTempDirFile(test.pattern, test.fileName); !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Errorf("CreateTempDirFile(%q, %q) error = %v, want path validation", test.pattern, test.fileName, err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read work directory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("invalid inputs created files: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error message consistency ──
|
||||
|
||||
func TestLocalFileIO_ErrorMessages_ContainCorrectFlagName(t *testing.T) {
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.80",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.80",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
if grep -Fq 'run.name !== "CI"' "$workflow"; then
|
||||
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
|
||||
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
|
||||
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
|
||||
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
|
||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
|
||||
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
|
||||
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
|
||||
|
||||
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
|
||||
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
|
||||
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
|
||||
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
|
||||
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
|
||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
"total": 2,
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
|
||||
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
|
||||
if !strings.Contains(got, `"visible_rule"`) {
|
||||
t.Fatalf("visible_rule missing from list output: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
t.Fatalf("expected error for invalid questions JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visible_rule passthrough", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
if rule["logic"] != "and" {
|
||||
t.Fatalf("visible_rule logic not preserved: %#v", rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {
|
||||
|
||||
@@ -25,14 +25,21 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Update uses full question overwrite semantics, not a patch.",
|
||||
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
|
||||
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question create visible_rule",
|
||||
shortcut: BaseFormQuestionsCreate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question update visible_rule",
|
||||
shortcut: BaseFormQuestionsUpdate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
@@ -1028,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
wantHelp := []string{
|
||||
"Update uses full question overwrite semantics",
|
||||
"run +form-questions-list first",
|
||||
"include existing values you want to keep",
|
||||
"pass null or omit to clear",
|
||||
}
|
||||
for _, want := range wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
"full question overwrite semantics, not a patch",
|
||||
"Run +form-questions-list first",
|
||||
"title/description/required/option_display_mode/visible_rule",
|
||||
"Omitted fields reset to defaults",
|
||||
"empty strings, null, and empty arrays are written as empty/clear",
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
collapseDescription(e)
|
||||
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
|
||||
eventData := map[string]interface{}{
|
||||
"summary": runtime.Str("summary"),
|
||||
"description": runtime.Str("description"),
|
||||
"start_time": map[string]string{"timestamp": startTs},
|
||||
"end_time": map[string]string{"timestamp": endTs},
|
||||
"attendee_ability": "can_modify_event",
|
||||
@@ -33,6 +32,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
|
||||
if rrule := runtime.Str("rrule"); rrule != "" {
|
||||
eventData["recurrence"] = rrule
|
||||
}
|
||||
if description := descriptionToSend(runtime); description != "" {
|
||||
eventData["description_rich"] = description
|
||||
}
|
||||
return eventData
|
||||
}
|
||||
|
||||
@@ -118,7 +120,7 @@ var CalendarCreate = common.Shortcut{
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
|
||||
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
|
||||
{Name: "description", Desc: "event description"},
|
||||
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`).", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
@@ -231,6 +233,9 @@ var CalendarCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventData := buildEventData(runtime, startTs, endTs)
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ type calendarEvent struct {
|
||||
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DescriptionRich string `json:"description_rich,omitempty"`
|
||||
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||
@@ -169,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
|
||||
collapseDescription(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -988,9 +988,15 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured patch body: %v", err)
|
||||
}
|
||||
if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
|
||||
// --description is the unified field, treated as rich text and sent as
|
||||
// description_rich; the CLI never sends the plain description field
|
||||
// (mutually exclusive downstream).
|
||||
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
|
||||
t.Fatalf("unexpected patch body: %#v", body)
|
||||
}
|
||||
if _, ok := body["description"]; ok {
|
||||
t.Fatalf("plain description must not be sent, got: %#v", body)
|
||||
}
|
||||
if body["need_notification"] != false {
|
||||
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
|
||||
}
|
||||
@@ -1364,6 +1370,62 @@ func TestAgenda_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/events/instance_view",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"event_id": "evt_rich",
|
||||
"summary": "Rich",
|
||||
"status": "confirmed",
|
||||
"description": "[测试]\n友情提醒",
|
||||
"description_rich": "友情提醒",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"event_id": "evt_plain",
|
||||
"summary": "Plain",
|
||||
"status": "confirmed",
|
||||
"description": "just text",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarAgenda, []string{
|
||||
"+agenda",
|
||||
"--start", "2025-03-21",
|
||||
"--end", "2025-03-21",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Read exposes a single unified description field: it carries the rich
|
||||
// (Markdown) value when present, and the plain text otherwise. The internal
|
||||
// description_rich key is never surfaced.
|
||||
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenda_EmptyResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
@@ -3375,6 +3437,72 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
// Read exposes a single unified description field carrying the rich value
|
||||
// when present, and the plain text otherwise; description_rich is dropped.
|
||||
t.Run("rich present", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_rich",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_rich",
|
||||
"summary": "Rich",
|
||||
"description": "[表格]",
|
||||
"description_rich": "| a | b |\n| --- | --- |\n| c | d |",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_rich", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "| a | b |") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
// When only a plain description exists, it is surfaced under description.
|
||||
t.Run("only plain surfaces under description", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_plain",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_plain",
|
||||
"summary": "Plain",
|
||||
"description": "just text",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_plain", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description surfaced, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ var CalendarUpdate = common.Shortcut{
|
||||
{Name: "event-id", Desc: "event ID to update", Required: true},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "description", Desc: "event description"},
|
||||
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
|
||||
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
@@ -109,11 +109,13 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
|
||||
body := map[string]interface{}{}
|
||||
hasFields := false
|
||||
|
||||
for _, field := range []string{"summary", "description"} {
|
||||
if runtime.Cmd.Flags().Changed(field) {
|
||||
body[field] = runtime.Str(field)
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("summary") {
|
||||
body["summary"] = runtime.Str("summary")
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
body["description_rich"] = runtime.Str("description")
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("rrule") {
|
||||
rrule := strings.TrimSpace(runtime.Str("rrule"))
|
||||
@@ -356,6 +358,12 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||
}
|
||||
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -428,8 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
|
||||
if summary, _ := event["summary"].(string); summary != "" {
|
||||
result["summary"] = summary
|
||||
}
|
||||
if description, _ := event["description"].(string); description != "" {
|
||||
result["description"] = description
|
||||
if rich, _ := event["description_rich"].(string); rich != "" {
|
||||
result["description"] = rich
|
||||
} else if plain, _ := event["description"].(string); plain != "" {
|
||||
result["description"] = plain
|
||||
}
|
||||
if start := formatCalendarEventTime(event["start_time"]); start != "" {
|
||||
result["start"] = start
|
||||
|
||||
172
shortcuts/calendar/description_rich_images.go
Normal file
172
shortcuts/calendar/description_rich_images.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
// Register the common image decoders so DecodeConfig can read intrinsic
|
||||
// dimensions for PNG/JPEG/GIF sources.
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const calendarMediaParentType = "calendar"
|
||||
|
||||
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
|
||||
|
||||
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
|
||||
md := runtime.Str("description")
|
||||
if md == "" || !strings.Contains(md, "![") {
|
||||
return nil
|
||||
}
|
||||
rewritten, changed, err := uploadLocalDescriptionImages(runtime, calendarID, md)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadLocalDescriptionImages(runtime *common.RuntimeContext, calendarID, md string) (string, bool, error) {
|
||||
matches := markdownImageRe.FindAllStringSubmatchIndex(md, -1)
|
||||
if len(matches) == 0 {
|
||||
return md, false, nil
|
||||
}
|
||||
var out strings.Builder
|
||||
last := 0
|
||||
changed := false
|
||||
cache := map[string]string{}
|
||||
for _, m := range matches {
|
||||
altStart, altEnd, srcStart, srcEnd := m[2], m[3], m[4], m[5]
|
||||
src := strings.TrimSpace(md[srcStart:srcEnd])
|
||||
if !isLocalImageSrc(src) {
|
||||
continue
|
||||
}
|
||||
alt := md[altStart:altEnd]
|
||||
uploadedURL, err := resolveLocalImage(runtime, calendarID, src, alt, cache)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
out.WriteString(md[last:srcStart])
|
||||
out.WriteString(uploadedURL)
|
||||
last = srcEnd
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return md, false, nil
|
||||
}
|
||||
out.WriteString(md[last:])
|
||||
return out.String(), true, nil
|
||||
}
|
||||
|
||||
func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt string, cache map[string]string) (string, error) {
|
||||
localPath := localImagePath(src)
|
||||
if cached, ok := cache[localPath]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeInputPath(localPath)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description image %q could not be read: %v", src, err).
|
||||
WithParam("--description").
|
||||
WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
info, err := runtime.FileIO().Stat(localPath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
|
||||
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: localPath,
|
||||
FileName: filepath.Base(safePath),
|
||||
FileSize: info.Size(),
|
||||
ParentType: calendarMediaParentType,
|
||||
ParentNode: &calendarID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
width, height := decodeImageDimensions(runtime, localPath)
|
||||
uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size())
|
||||
cache[localPath] = uploadedURL
|
||||
return uploadedURL, nil
|
||||
}
|
||||
|
||||
func decodeImageDimensions(runtime *common.RuntimeContext, path string) (int, int) {
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
func isLocalImageSrc(src string) bool {
|
||||
if src == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(src)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "data:"):
|
||||
return false
|
||||
case strings.HasPrefix(lower, "file://"):
|
||||
return true
|
||||
}
|
||||
if i := strings.Index(src, "://"); i > 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func localImagePath(src string) string {
|
||||
s := strings.TrimSpace(src)
|
||||
if strings.HasPrefix(strings.ToLower(s), "file://") {
|
||||
if u, err := url.Parse(s); err == nil && u.Path != "" {
|
||||
s = u.Path
|
||||
}
|
||||
}
|
||||
if decoded, err := url.PathUnescape(s); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
|
||||
host := "internal-api-drive-stream.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "internal-api-drive-stream.larksuite.com"
|
||||
}
|
||||
u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken)
|
||||
if width > 0 && height > 0 {
|
||||
u += fmt.Sprintf("&im_w=%d&im_h=%d", width, height)
|
||||
}
|
||||
if size > 0 {
|
||||
u += fmt.Sprintf("&im_size=%d", size)
|
||||
}
|
||||
return u
|
||||
}
|
||||
279
shortcuts/calendar/description_rich_images_test.go
Normal file
279
shortcuts/calendar/description_rich_images_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestIsLocalImageSrc(t *testing.T) {
|
||||
cases := []struct {
|
||||
src string
|
||||
want bool
|
||||
}{
|
||||
{"./images/pic.png", true},
|
||||
{"images/pic.png", true},
|
||||
{"../assets/a.png", true},
|
||||
{"/Users/me/Desktop/a.png", true},
|
||||
{`C:\Users\me\a.png`, true},
|
||||
{"file:///Users/me/a.png", true},
|
||||
{"图片和附件/测试图片.png", true},
|
||||
{"https://example.com/a.png", false},
|
||||
{"http://example.com/a.png", false},
|
||||
{"HTTPS://EXAMPLE.com/a.png", false},
|
||||
{"data:image/png;base64,iVBOR", false},
|
||||
{"ftp://host/a.png", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isLocalImageSrc(c.src); got != c.want {
|
||||
t.Errorf("isLocalImageSrc(%q) = %v, want %v", c.src, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalImagePath(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"images/pic.png", "images/pic.png"},
|
||||
{"images/my%20pic.png", "images/my pic.png"},
|
||||
{"file:///Users/me/a.png", "/Users/me/a.png"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := localImagePath(c.in); got != c.want {
|
||||
t.Errorf("localImagePath(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCalendarImagePreviewURL guards the contract the OpenAPI service
|
||||
// relies on: a Lark host (so token extraction triggers) whose final path
|
||||
// segment is exactly the uploaded file token.
|
||||
func TestBuildCalendarImagePreviewURL(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
brand core.LarkBrand
|
||||
hostFrag string
|
||||
}{
|
||||
{core.BrandFeishu, "feishu.cn"},
|
||||
{core.BrandLark, "larksuite"},
|
||||
} {
|
||||
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("built URL not parseable: %v", err)
|
||||
}
|
||||
if !strings.Contains(u.Host, tc.hostFrag) {
|
||||
t.Errorf("brand %s host = %q, want fragment %q", tc.brand, u.Host, tc.hostFrag)
|
||||
}
|
||||
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if last := segs[len(segs)-1]; last != "boxcnTOKEN123" {
|
||||
t.Errorf("last path segment = %q, want token", last)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("im_w") != "416" || q.Get("im_h") != "306" || q.Get("im_size") != "142568" {
|
||||
t.Errorf("dimension params missing: im_w=%q im_h=%q im_size=%q", q.Get("im_w"), q.Get("im_h"), q.Get("im_size"))
|
||||
}
|
||||
}
|
||||
|
||||
// With unknown dimensions the helper params are omitted entirely.
|
||||
raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0)
|
||||
if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") {
|
||||
t.Errorf("expected no dimension params for unknown size, got %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadLocalDescriptionImages_RemoteUntouched verifies remote/data images
|
||||
// pass through unchanged and never trigger an upload (runtime unused → nil).
|
||||
func TestUploadLocalDescriptionImages_RemoteUntouched(t *testing.T) {
|
||||
md := "text  more "
|
||||
got, changed, err := uploadLocalDescriptionImages(nil, "cal", md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Errorf("changed = true, want false")
|
||||
}
|
||||
if got != md {
|
||||
t.Errorf("markdown mutated: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_UploadsLocalDescriptionImage runs +create with a local image path,
|
||||
// mocks the drive upload, and asserts the create body's description_rich carries
|
||||
// the uploaded token (not the local path).
|
||||
func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(orig)
|
||||
if err := os.WriteFile(filepath.Join(dir, "pic.png"), []byte("PNGDATA"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Pic",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
runErr := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Pic",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr != nil {
|
||||
t.Fatalf("unexpected error: %v", runErr)
|
||||
}
|
||||
|
||||
if uploadStub.CapturedBody == nil {
|
||||
t.Fatalf("expected drive upload to be called")
|
||||
}
|
||||
if createStub.CapturedBody == nil {
|
||||
t.Fatalf("expected create event to be called")
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("create body unmarshal: %v", err)
|
||||
}
|
||||
dr, _ := body["description_rich"].(string)
|
||||
if !strings.Contains(dr, "boxcnTOKEN123") {
|
||||
t.Fatalf("description_rich should contain uploaded token, got %q", dr)
|
||||
}
|
||||
if strings.Contains(dr, "./pic.png") {
|
||||
t.Fatalf("local path should be rewritten away, got %q", dr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_LocalImageCarriesDimensions verifies a real decodable image's
|
||||
// intrinsic width/height and byte size are appended to the rewritten drive URL
|
||||
// (so the facade can populate originalWidth/originalHeight and the client can
|
||||
// render the image inline).
|
||||
func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(orig)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 5, 7))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "pic.png"), buf.Bytes(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Pic",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
runErr := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Pic",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr != nil {
|
||||
t.Fatalf("unexpected error: %v", runErr)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("create body unmarshal: %v", err)
|
||||
}
|
||||
dr, _ := body["description_rich"].(string)
|
||||
if !strings.Contains(dr, "im_w=5") || !strings.Contains(dr, "im_h=7") {
|
||||
t.Fatalf("description_rich should carry image dimensions, got %q", dr)
|
||||
}
|
||||
if !strings.Contains(dr, "im_size=") {
|
||||
t.Fatalf("description_rich should carry image byte size, got %q", dr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
|
||||
// yields a typed --description validation error before any API call.
|
||||
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
runErr := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Pic",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr == nil {
|
||||
t.Fatalf("expected error for absolute image path")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(runErr, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
|
||||
}
|
||||
if ve.Param != "--description" {
|
||||
t.Errorf("param = %q, want --description", ve.Param)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,26 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
|
||||
return startInput, endInput
|
||||
}
|
||||
|
||||
func collapseDescription(event map[string]interface{}) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
rich, _ := event["description_rich"].(string)
|
||||
plain, _ := event["description"].(string)
|
||||
delete(event, "description_rich")
|
||||
switch {
|
||||
case rich != "":
|
||||
event["description"] = rich
|
||||
case plain != "":
|
||||
event["description"] = plain
|
||||
default:
|
||||
delete(event, "description")
|
||||
}
|
||||
}
|
||||
func descriptionToSend(runtime *common.RuntimeContext) string {
|
||||
return runtime.Str("description")
|
||||
}
|
||||
|
||||
func hasExplicitBotFlag(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
|
||||
)
|
||||
|
||||
const (
|
||||
docsScriptParse = "parse"
|
||||
docsScriptMarkdownToXML = "markdown-to-xml"
|
||||
docsScriptCreateTempXML = "create-temp-xml"
|
||||
docsScriptTempDirSuffix = "_*_folder"
|
||||
)
|
||||
|
||||
var DocsScript = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+script",
|
||||
Description: "Create a unique temporary XML file, parse and profile local or online documents, or convert Markdown to LarkOpenCLI XML",
|
||||
Risk: "read",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{
|
||||
"docx:document:readonly",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{
|
||||
Name: "command",
|
||||
Desc: "local document operation",
|
||||
Required: true,
|
||||
Enum: []string{docsScriptParse, docsScriptMarkdownToXML, docsScriptCreateTempXML},
|
||||
},
|
||||
{
|
||||
Name: "content",
|
||||
Desc: "local content for parse or markdown-to-xml; use @relative-file or - for stdin; mutually exclusive with --doc",
|
||||
Input: []string{common.File, common.Stdin},
|
||||
},
|
||||
{
|
||||
Name: "doc",
|
||||
Desc: "online document URL or token for --command parse; mutually exclusive with --content",
|
||||
},
|
||||
{
|
||||
Name: "output",
|
||||
Desc: "local XML output path for markdown-to-xml; omit to return XML in data.xml",
|
||||
},
|
||||
{
|
||||
Name: "file-name",
|
||||
Desc: "portable base name without .xml; create-temp-xml writes <name>_<random>_folder/<name>.xml",
|
||||
},
|
||||
{
|
||||
Name: "overwrite",
|
||||
Type: "bool",
|
||||
Desc: "overwrite an existing --output file",
|
||||
},
|
||||
},
|
||||
Tips: []string{
|
||||
"create-temp-xml atomically creates <file-name>_<random>_folder/<file-name>.xml in the current directory",
|
||||
"parse accepts local --content or an online --doc URL/token and returns only the text and block profile",
|
||||
"markdown-to-xml converts Markdown to LarkOpenCLI XML",
|
||||
"use --output to save converted XML directly and keep stdout compact",
|
||||
},
|
||||
PostMount: installDocsScriptHelp,
|
||||
Validate: validateDocsScript,
|
||||
DryRun: dryRunDocsScript,
|
||||
Execute: executeDocsScript,
|
||||
}
|
||||
|
||||
type docsScriptParseResult struct {
|
||||
Profile docsScriptPublicProfile `json:"profile"`
|
||||
}
|
||||
|
||||
// docsScriptPublicProfile is the stable shortcut response. The parser keeps
|
||||
// the more detailed breakdown internally so it can be exposed later without
|
||||
// changing the counting implementation.
|
||||
type docsScriptPublicProfile struct {
|
||||
WordCount int `json:"word_count"`
|
||||
CharCount int `json:"char_count"`
|
||||
BlockCount int `json:"block_count"`
|
||||
Blocks []docxparse.BlockShare `json:"blocks"`
|
||||
}
|
||||
|
||||
type docsScriptMarkdownResult struct {
|
||||
XML string `json:"xml"`
|
||||
}
|
||||
|
||||
type docsScriptMarkdownFileResult struct {
|
||||
SavedPath string `json:"saved_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type docsScriptTempXMLResult struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func installDocsScriptHelp(cmd *cobra.Command) {
|
||||
installDocsShortcutHelp("+script")(cmd)
|
||||
cmd.Example = ` lark-cli docs +script --command create-temp-xml --file-name "draft"
|
||||
lark-cli docs +script --command parse --content "@draft.xml"
|
||||
lark-cli docs +script --command parse --content "@draft.md"
|
||||
lark-cli docs +script --command parse --doc "https://example.larksuite.com/docx/doxcn..."
|
||||
lark-cli docs +script --command markdown-to-xml --content "@draft.md" --output "draft.xml"`
|
||||
}
|
||||
|
||||
func validateDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
content := strings.TrimSpace(runtime.Str("content"))
|
||||
doc := strings.TrimSpace(runtime.Str("doc"))
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
fileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
if runtime.Str("command") == docsScriptCreateTempXML {
|
||||
switch {
|
||||
case content != "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--content is not supported with --command create-temp-xml").WithParam("--content")
|
||||
case doc != "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--doc is not supported with --command create-temp-xml").WithParam("--doc")
|
||||
case outputPath != "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output is not supported with --command create-temp-xml").WithParam("--output")
|
||||
case runtime.Bool("overwrite"):
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--overwrite is not supported with --command create-temp-xml").WithParam("--overwrite")
|
||||
case fileName == "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name is required with --command create-temp-xml").WithParam("--file-name")
|
||||
case runtime.Str("file-name") != fileName:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must not start or end with whitespace").WithParam("--file-name")
|
||||
default:
|
||||
return validateDocsScriptTempXMLFileName(fileName)
|
||||
}
|
||||
}
|
||||
if fileName != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name is only supported with --command create-temp-xml").WithParam("--file-name")
|
||||
}
|
||||
if content == "" && doc == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --content or --doc is required").WithParams(
|
||||
errs.InvalidParam{Name: "--content", Reason: "provide local document content"},
|
||||
errs.InvalidParam{Name: "--doc", Reason: "provide an online document URL or token"},
|
||||
)
|
||||
}
|
||||
if content != "" && doc != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content and --doc are mutually exclusive").WithParams(
|
||||
errs.InvalidParam{Name: "--content", Reason: "mutually exclusive with --doc"},
|
||||
errs.InvalidParam{Name: "--doc", Reason: "mutually exclusive with --content"},
|
||||
)
|
||||
}
|
||||
if doc != "" {
|
||||
if runtime.Str("command") != docsScriptParse {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--doc is only supported with --command parse").WithParam("--doc")
|
||||
}
|
||||
if _, err := parseDocumentRef(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.EnsureScopes([]string{"docx:document:readonly"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outputPath == "" {
|
||||
if runtime.Bool("overwrite") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--overwrite requires --output").WithParam("--overwrite")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if runtime.Str("command") != docsScriptMarkdownToXML {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output is only supported with --command markdown-to-xml").WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
|
||||
WithParam("--output").
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunDocsScript(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if runtime.Str("command") == docsScriptCreateTempXML {
|
||||
fileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Create a random directory and an empty named XML file inside it; no API call is made").
|
||||
Set("command", docsScriptCreateTempXML).
|
||||
Set("directory_pattern", docsScriptTempDirectoryPattern(fileName)).
|
||||
Set("file_name", fileName).
|
||||
Set("xml_file_name", docsScriptXMLFileName(fileName)).
|
||||
Set("creates_file", false).
|
||||
Set("network", false)
|
||||
}
|
||||
if doc := strings.TrimSpace(runtime.Str("doc")); doc != "" {
|
||||
ref, _ := parseDocumentRef(doc)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/docs_ai/v1/documents/:document_id/fetch").
|
||||
Desc("OpenAPI: fetch document for parsing and profiling").
|
||||
Body(docsScriptFetchBody(runtime)).
|
||||
Set("command", runtime.Str("command")).
|
||||
Set("document_id", ref.Token).
|
||||
Set("network", true)
|
||||
}
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Local LarkOpenCLI document parsing or conversion; no API call is made").
|
||||
Set("command", runtime.Str("command")).
|
||||
Set("input_bytes", len(runtime.Str("content"))).
|
||||
Set("network", false)
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
dry.Set("output", outputPath).Set("overwrite", runtime.Bool("overwrite"))
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func executeDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
command := runtime.Str("command")
|
||||
content := runtime.Str("content")
|
||||
switch command {
|
||||
case docsScriptCreateTempXML:
|
||||
return createDocsScriptTempXML(runtime)
|
||||
case docsScriptParse:
|
||||
inputParam := "--content"
|
||||
inputLabel := "--content"
|
||||
if strings.TrimSpace(runtime.Str("doc")) != "" {
|
||||
var err error
|
||||
content, err = fetchDocsScriptContent(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputParam = "--doc"
|
||||
inputLabel = "fetched document content"
|
||||
}
|
||||
profile, err := docxparse.ParseAuto(content)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"could not parse %s as LarkOpenCLI XML or Markdown: %s", inputLabel, err).
|
||||
WithParam(inputParam).
|
||||
WithCause(err)
|
||||
}
|
||||
runtime.OutFormatRaw(docsScriptParseResult{Profile: docsScriptPublicProfile{
|
||||
WordCount: profile.WordCount,
|
||||
CharCount: profile.CharCount,
|
||||
BlockCount: profile.BlockCount,
|
||||
Blocks: profile.Blocks,
|
||||
}}, nil, nil)
|
||||
return nil
|
||||
case docsScriptMarkdownToXML:
|
||||
xml, err := docxparse.MarkdownToXML(content)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"could not convert --content from Markdown to LarkOpenCLI XML: %s", err).
|
||||
WithParam("--content").
|
||||
WithCause(err)
|
||||
}
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
return saveDocsScriptXML(runtime, outputPath, xml)
|
||||
}
|
||||
runtime.OutFormatRaw(docsScriptMarkdownResult{XML: xml}, nil, nil)
|
||||
return nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported --command %q", command).
|
||||
WithParam("--command")
|
||||
}
|
||||
}
|
||||
|
||||
func createDocsScriptTempXML(runtime *common.RuntimeContext) error {
|
||||
creator, ok := runtime.FileIO().(fileio.TempDirFileCreator)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"the active file I/O provider does not support temporary file creation").
|
||||
WithHint("run this command with the local file I/O provider")
|
||||
}
|
||||
fileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
path, err := creator.CreateTempDirFile(docsScriptTempDirectoryPattern(fileName), docsScriptXMLFileName(fileName))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(path); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"resolve temporary XML path %s: %s", path, err).
|
||||
WithCause(err)
|
||||
}
|
||||
runtime.Out(docsScriptTempXMLResult{
|
||||
Path: path,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsScriptTempXMLFileName(fileName string) error {
|
||||
if fileName != filepath.Base(fileName) || strings.ContainsAny(fileName, "<>:\"/\\|?*\t\r\n") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must be a portable file name without path separators or reserved characters").WithParam("--file-name")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(fileName, "--file-name"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--file-name").
|
||||
WithCause(err)
|
||||
}
|
||||
if strings.HasSuffix(fileName, ".") || strings.HasSuffix(fileName, " ") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must not end with a dot or space").WithParam("--file-name")
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(fileName), ".xml") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must omit the .xml extension").WithParam("--file-name")
|
||||
}
|
||||
base := strings.ToUpper(strings.SplitN(fileName, ".", 2)[0])
|
||||
if isWindowsReservedFileName(base) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name uses a Windows-reserved device name").WithParam("--file-name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func docsScriptTempDirectoryPattern(fileName string) string {
|
||||
return fileName + docsScriptTempDirSuffix
|
||||
}
|
||||
|
||||
func docsScriptXMLFileName(fileName string) string {
|
||||
return fileName + ".xml"
|
||||
}
|
||||
|
||||
func isWindowsReservedFileName(base string) bool {
|
||||
switch base {
|
||||
case "CON", "PRN", "AUX", "NUL":
|
||||
return true
|
||||
}
|
||||
if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) {
|
||||
return base[3] >= '1' && base[3] <= '9'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func docsScriptFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": "xml",
|
||||
"extra_param": docsFetchExtraParam,
|
||||
"export_option": map[string]interface{}{
|
||||
"export_block_id": false,
|
||||
"export_style_attrs": false,
|
||||
"export_cite_extra_data": false,
|
||||
},
|
||||
}
|
||||
if lang := resolveFetchLang(runtime); lang != "" {
|
||||
body["lang"] = lang
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func fetchDocsScriptContent(runtime *common.RuntimeContext) (string, error) {
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
|
||||
data, err := doDocAPI(runtime, "POST", apiPath, docsScriptFetchBody(runtime))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
document, ok := data["document"].(map[string]interface{})
|
||||
if !ok || document == nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"document fetch response for --doc is missing document")
|
||||
}
|
||||
content, ok := document["content"].(string)
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"document fetch response for --doc is missing document.content")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func saveDocsScriptXML(runtime *common.RuntimeContext, outputPath, xml string) error {
|
||||
if !runtime.Bool("overwrite") {
|
||||
if _, err := runtime.FileIO().Stat(outputPath); err == nil {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"output file already exists: %s (use --overwrite to replace)", outputPath).
|
||||
WithParam("--output")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
|
||||
WithParam("--output").
|
||||
WithCause(err)
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"cannot access output path %s: %s", outputPath, err).
|
||||
WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(xml)),
|
||||
}, strings.NewReader(xml))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
savedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"resolve saved XML path %s: %s", outputPath, err).
|
||||
WithCause(err)
|
||||
}
|
||||
runtime.Out(docsScriptMarkdownFileResult{
|
||||
SavedPath: savedPath,
|
||||
SizeBytes: result.Size(),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -1,660 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
|
||||
)
|
||||
|
||||
func TestDocsScriptParsesAndProfilesXML(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-test"))
|
||||
source := `<title>标题</title><p>一个苹果是 an apple。</p>`
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", source,
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if !envelope.OK {
|
||||
t.Fatalf("ok = false: %s", stdout)
|
||||
}
|
||||
if len(envelope.Data) != 1 || envelope.Data["profile"] == nil {
|
||||
t.Fatalf("data = %+v, want only profile", envelope.Data)
|
||||
}
|
||||
var profile docsScriptPublicProfile
|
||||
if err := json.Unmarshal(envelope.Data["profile"], &profile); err != nil {
|
||||
t.Fatalf("decode profile: %v", err)
|
||||
}
|
||||
var profileFields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(envelope.Data["profile"], &profileFields); err != nil {
|
||||
t.Fatalf("decode profile fields: %v", err)
|
||||
}
|
||||
if len(profileFields) != 4 || profileFields["breakdown"] != nil {
|
||||
t.Fatalf("profile fields = %+v, want breakdown hidden", profileFields)
|
||||
}
|
||||
if profile.WordCount != 10 || profile.CharCount != 15 || profile.BlockCount != 2 {
|
||||
t.Fatalf("profile = %+v", profile)
|
||||
}
|
||||
if got := blockCount(profile.Blocks, "title"); got != 1 {
|
||||
t.Fatalf("title count = %d, want 1", got)
|
||||
}
|
||||
if got := blockCount(profile.Blocks, "p"); got != 1 {
|
||||
t.Fatalf("p count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptParseAutoDetectsMarkdown(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-auto-markdown"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", "# 标题\n\n- item",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data docsScriptParseResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if envelope.Data.Profile.BlockCount != 3 {
|
||||
t.Fatalf("profile = %+v, want 3 blocks", envelope.Data.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptParsesOnlineDocumentFromToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-token"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnScriptToken/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnScriptToken",
|
||||
"content": `<title>在线文档</title><p>Hello world</p>`,
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--doc", "doxcnScriptToken",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script with token: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data docsScriptParseResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if envelope.Data.Profile.BlockCount != 2 {
|
||||
t.Fatalf("profile = %+v, want 2 blocks", envelope.Data.Profile)
|
||||
}
|
||||
if got := blockCount(envelope.Data.Profile.Blocks, "title"); got != 1 {
|
||||
t.Fatalf("title count = %d, want 1", got)
|
||||
}
|
||||
if got := blockCount(envelope.Data.Profile.Blocks, "p"); got != 1 {
|
||||
t.Fatalf("p count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptParsesOnlineDocumentFromURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-url"))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/wikcnScriptURL/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnResolvedScriptURL",
|
||||
"content": `<p>从 Wiki URL 读取</p>`,
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--doc", "https://example.larksuite.com/wiki/wikcnScriptURL",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script with URL: %v", err)
|
||||
}
|
||||
if stub.CapturedBody == nil {
|
||||
t.Fatal("online parse did not call the document fetch API")
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data docsScriptParseResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if envelope.Data.Profile.BlockCount != 1 || blockCount(envelope.Data.Profile.Blocks, "p") != 1 {
|
||||
t.Fatalf("profile = %+v, want one paragraph", envelope.Data.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsContentAndDocTogether(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-input-conflict"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<p>local</p>`,
|
||||
"--doc", "doxcnScriptConflict",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--content", "--doc")
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsDocForMarkdownConversion(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-doc-convert"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--doc", "doxcnScriptConvert",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--doc")
|
||||
}
|
||||
|
||||
func TestDocsScriptConvertsMarkdownFromStdin(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-markdown"))
|
||||
f.IOStreams.In = bytes.NewBufferString("# 标题\n\n- item")
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "-",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `<h1>标题</h1><ul><li>item</li></ul>`) {
|
||||
t.Fatalf("stdout missing converted XML: %s", stdout)
|
||||
}
|
||||
var envelope struct {
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(envelope.Data) != 1 || envelope.Data["xml"] == nil {
|
||||
t.Fatalf("data = %+v, want only xml", envelope.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptConvertsMarkdownToOutputFile(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
withDocsWorkingDir(t, workDir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output"))
|
||||
wantXML := `<h1>标题</h1><ul><li>item</li></ul>`
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# 标题\n\n- item",
|
||||
"--output", "draft.xml",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
gotXML, err := os.ReadFile("draft.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("read output XML: %v", err)
|
||||
}
|
||||
if string(gotXML) != wantXML {
|
||||
t.Fatalf("output XML = %q, want %q", gotXML, wantXML)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
SavedPath string `json:"saved_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
XML json.RawMessage `json:"xml"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if envelope.Data.SavedPath != filepath.Join(workDir, "draft.xml") {
|
||||
t.Fatalf("saved_path = %q, want %q", envelope.Data.SavedPath, filepath.Join(workDir, "draft.xml"))
|
||||
}
|
||||
if envelope.Data.SizeBytes != int64(len(wantXML)) {
|
||||
t.Fatalf("size_bytes = %d, want %d", envelope.Data.SizeBytes, len(wantXML))
|
||||
}
|
||||
if envelope.Data.XML != nil {
|
||||
t.Fatalf("data.xml should be omitted when --output is used: %s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreatesUniqueTempXMLFiles(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
withDocsWorkingDir(t, workDir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml"))
|
||||
|
||||
create := func() docsScriptTempXMLResult {
|
||||
t.Helper()
|
||||
stdout.Reset()
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptCreateTempXML,
|
||||
"--file-name", "川西",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
var envelope struct {
|
||||
Data docsScriptTempXMLResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
first := create()
|
||||
second := create()
|
||||
if first.Path == second.Path {
|
||||
t.Fatalf("temporary paths are identical: %q", first.Path)
|
||||
}
|
||||
for _, got := range []docsScriptTempXMLResult{first, second} {
|
||||
directory := filepath.Dir(got.Path)
|
||||
if filepath.Base(got.Path) != "川西.xml" || filepath.Base(directory) != directory ||
|
||||
!strings.HasPrefix(directory, "川西_") || !strings.HasSuffix(directory, "_folder") {
|
||||
t.Fatalf("path = %q, want 川西_<random>_folder/川西.xml", got.Path)
|
||||
}
|
||||
info, err := os.Stat(got.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat temporary XML %q: %v", got.Path, err)
|
||||
}
|
||||
if info.Size() != 0 {
|
||||
t.Fatalf("temporary XML %q size = %d, want 0", got.Path, info.Size())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreateTempXMLRejectsOtherFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
param string
|
||||
}{
|
||||
{name: "content", args: []string{"--content", "<p>text</p>"}, param: "--content"},
|
||||
{name: "doc", args: []string{"--doc", "doxcnScriptTemp"}, param: "--doc"},
|
||||
{name: "output", args: []string{"--output", "draft.xml"}, param: "--output"},
|
||||
{name: "overwrite", args: []string{"--overwrite"}, param: "--overwrite"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-flags"))
|
||||
args := []string{"+script", "--command", docsScriptCreateTempXML, "--file-name", "川西", "--as", "bot"}
|
||||
args = append(args, test.args...)
|
||||
err := mountAndRunDocs(t, DocsScript, args, f, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected %s validation error", test.param)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != test.param {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreateTempXMLValidatesFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
}{
|
||||
{name: "missing"},
|
||||
{name: "path", fileName: "folder/川西"},
|
||||
{name: "windows path", fileName: `folder\川西`},
|
||||
{name: "reserved character", fileName: "川西:一"},
|
||||
{name: "xml extension included", fileName: "川西.xml"},
|
||||
{name: "windows device", fileName: "CON"},
|
||||
{name: "surrounding whitespace", fileName: " 川西"},
|
||||
{name: "dangerous unicode", fileName: "川\u200b西"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-file-name"))
|
||||
args := []string{"+script", "--command", docsScriptCreateTempXML, "--as", "bot"}
|
||||
if test.fileName != "" {
|
||||
args = append(args, "--file-name", test.fileName)
|
||||
}
|
||||
err := mountAndRunDocs(t, DocsScript, args, f, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected --file-name validation error for %q", test.fileName)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--file-name" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptOutputRequiresExplicitOverwrite(t *testing.T) {
|
||||
withDocsWorkingDir(t, t.TempDir())
|
||||
if err := os.WriteFile("draft.xml", []byte("old"), 0o600); err != nil {
|
||||
t.Fatalf("write existing output: %v", err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-overwrite"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# new",
|
||||
"--output", "draft.xml",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected existing output error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
got, readErr := os.ReadFile("draft.xml")
|
||||
if readErr != nil || string(got) != "old" {
|
||||
t.Fatalf("existing output changed: content=%q err=%v", got, readErr)
|
||||
}
|
||||
|
||||
err = mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# new",
|
||||
"--output", "draft.xml",
|
||||
"--overwrite",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script with --overwrite: %v", err)
|
||||
}
|
||||
got, readErr = os.ReadFile("draft.xml")
|
||||
if readErr != nil || string(got) != "<h1>new</h1>" {
|
||||
t.Fatalf("overwritten output = %q, err=%v", got, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsOutputForParse(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-parse"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<p>text</p>`,
|
||||
"--output", "draft.xml",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected --output validation error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsUnsafeOutputPath(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-path"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# title",
|
||||
"--output", filepath.Join(t.TempDir(), "draft.xml"),
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output path error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptDryRunHasNoAPICall(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<p>text</p>`,
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script dry-run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []any `json:"api"`
|
||||
Command string `json:"command"`
|
||||
Network bool `json:"network"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(got.API) != 0 || got.Command != docsScriptParse || got.Network {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreateTempXMLDryRunDoesNotWrite(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
withDocsWorkingDir(t, workDir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptCreateTempXML,
|
||||
"--file-name", "川西",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script dry-run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []any `json:"api"`
|
||||
Command string `json:"command"`
|
||||
DirectoryPattern string `json:"directory_pattern"`
|
||||
FileName string `json:"file_name"`
|
||||
XMLFileName string `json:"xml_file_name"`
|
||||
CreatesFile bool `json:"creates_file"`
|
||||
Network bool `json:"network"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(got.API) != 0 || got.Command != docsScriptCreateTempXML ||
|
||||
got.DirectoryPattern != "川西_*_folder" || got.FileName != "川西" || got.XMLFileName != "川西.xml" ||
|
||||
got.CreatesFile || got.Network {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
entries, err := os.ReadDir(workDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read work directory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("dry-run created files: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptOnlineDryRunShowsFetchAPICall(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--doc", "https://example.larksuite.com/docx/doxcnScriptDryRun",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute online docs +script dry-run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Command string `json:"command"`
|
||||
DocumentID string `json:"document_id"`
|
||||
Network bool `json:"network"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(got.API) != 1 || got.API[0].Method != "POST" ||
|
||||
got.API[0].URL != "/open-apis/docs_ai/v1/documents/doxcnScriptDryRun/fetch" {
|
||||
t.Fatalf("dry-run API = %+v", got.API)
|
||||
}
|
||||
if got.API[0].Body["format"] != "xml" {
|
||||
t.Fatalf("dry-run body = %+v, want XML fetch", got.API[0].Body)
|
||||
}
|
||||
if got.Command != docsScriptParse || got.DocumentID != "doxcnScriptDryRun" || !got.Network {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptOutputDryRunDoesNotWrite(t *testing.T) {
|
||||
withDocsWorkingDir(t, t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# title",
|
||||
"--output", "draft.xml",
|
||||
"--overwrite",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script dry-run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []any `json:"api"`
|
||||
Command string `json:"command"`
|
||||
Network bool `json:"network"`
|
||||
Output string `json:"output"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(got.API) != 0 || got.Command != docsScriptMarkdownToXML || got.Network || got.Output != "draft.xml" || !got.Overwrite {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
if _, err := os.Stat("draft.xml"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("dry-run created output file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptReturnsTypedParseError(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-error"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<!DOCTYPE document><p>text</p>`,
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %+v, ok=%v", problem, ok)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--content" {
|
||||
t.Fatalf("error = %#v, want --content metadata", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsMalformedXML(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-malformed"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<p>text`,
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected malformed XML error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %+v, ok=%v", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptHelpExamplesAreCrossShellSafe(t *testing.T) {
|
||||
cmd := &cobra.Command{Short: "local document parser"}
|
||||
installDocsScriptHelp(cmd)
|
||||
if strings.Contains(cmd.Example, "cat ") {
|
||||
t.Fatalf("help examples require a platform-specific command: %q", cmd.Example)
|
||||
}
|
||||
if strings.Contains(cmd.Example, "--content @") {
|
||||
t.Fatalf("help examples contain an unquoted @file argument: %q", cmd.Example)
|
||||
}
|
||||
for _, want := range []string{`--command create-temp-xml --file-name "draft"`, `--content "@draft.xml"`, `--content "@draft.md"`, `--output "draft.xml"`} {
|
||||
if !strings.Contains(cmd.Example, want) {
|
||||
t.Errorf("help examples missing %q: %q", want, cmd.Example)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func blockCount(blocks []docxparse.BlockShare, typ string) int {
|
||||
for _, block := range blocks {
|
||||
if block.Type == typ {
|
||||
return block.Count
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,743 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
// Markdown conversion is scoped to the docs +script business domain.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
gmutil "github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
var markdownParser parser.Parser
|
||||
|
||||
func init() {
|
||||
markdown := goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
extension.DefinitionList,
|
||||
&mathExtension{},
|
||||
&underscoreHTMLExtension{},
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithBlockParsers(gmutil.Prioritized(&containerBlockParser{}, 90)),
|
||||
),
|
||||
)
|
||||
markdownParser = markdown.Parser()
|
||||
}
|
||||
|
||||
func parseMarkdown(source string) ([]*Node, error) {
|
||||
if err := validateSource(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source = strings.TrimPrefix(source, "\uFEFF")
|
||||
source = normalizeListIndent(source)
|
||||
source = preprocessCJKAdjacentMarkup(source)
|
||||
data := []byte(source)
|
||||
document := markdownParser.Parse(text.NewReader(data))
|
||||
return renderBlockChildren(document, data)
|
||||
}
|
||||
|
||||
func renderBlockChildren(parent gast.Node, source []byte) ([]*Node, error) {
|
||||
var out []*Node
|
||||
for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
nodes, err := renderBlockNode(child, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, nodes...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderBlockNode(node gast.Node, source []byte) ([]*Node, error) {
|
||||
switch node.Kind() {
|
||||
case gast.KindParagraph, gast.KindTextBlock:
|
||||
children, err := renderInlineChildren(node, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrapParagraphChildren(children), nil
|
||||
case gast.KindHeading:
|
||||
heading := newElement(headingTag(node.(*gast.Heading).Level), nil)
|
||||
children, err := renderInlineChildren(node, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
heading.addChild(child)
|
||||
}
|
||||
return []*Node{heading}, nil
|
||||
case gast.KindBlockquote:
|
||||
return renderContainer("blockquote", nil, node, source)
|
||||
case gast.KindList:
|
||||
return renderList(node.(*gast.List), source)
|
||||
case gast.KindFencedCodeBlock:
|
||||
block := node.(*gast.FencedCodeBlock)
|
||||
language := string(block.Language(source))
|
||||
content := trimOneTrailingNewline(string(node.Lines().Value(source)))
|
||||
lowerLanguage := strings.ToLower(language)
|
||||
if content != "" && (lowerLanguage == "mermaid" || lowerLanguage == "plantuml" || lowerLanguage == "svg") {
|
||||
whiteboard := newElement("whiteboard", map[string]string{"type": lowerLanguage})
|
||||
appendRawTextWithBreaks(whiteboard, content)
|
||||
return []*Node{whiteboard}, nil
|
||||
}
|
||||
attrs := map[string]string(nil)
|
||||
if language != "" {
|
||||
attrs = map[string]string{"lang": language}
|
||||
}
|
||||
pre := newElement("pre", attrs)
|
||||
code := newElement("code", nil)
|
||||
appendRawTextWithBreaks(code, content)
|
||||
pre.addChild(code)
|
||||
return []*Node{pre}, nil
|
||||
case gast.KindCodeBlock:
|
||||
pre := newElement("pre", nil)
|
||||
code := newElement("code", nil)
|
||||
appendRawTextWithBreaks(code, trimOneTrailingNewline(string(node.Lines().Value(source))))
|
||||
pre.addChild(code)
|
||||
return []*Node{pre}, nil
|
||||
case gast.KindThematicBreak:
|
||||
return []*Node{newElement("hr", nil)}, nil
|
||||
case gast.KindHTMLBlock:
|
||||
nodes, err := parseMarkdownHTMLBlock(string(node.Lines().Value(source)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripMarkdownEscapesInNodes(nodes, false, false)
|
||||
return nodes, nil
|
||||
case kindContainerBlock:
|
||||
container := node.(*containerBlock)
|
||||
return renderContainer(container.spec.tag, container.attrs, node, source)
|
||||
}
|
||||
|
||||
switch node.Kind() {
|
||||
case extast.KindTable:
|
||||
return renderTable(node, source)
|
||||
case extast.KindDefinitionList:
|
||||
return renderDefinitionList(node, source)
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(extractMarkdownText(node, source))
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
paragraph := newElement("p", nil)
|
||||
paragraph.addChild(newText(value))
|
||||
return []*Node{paragraph}, nil
|
||||
}
|
||||
|
||||
// parseMarkdownHTMLBlock handles the source-bearing LarkOpenCLI blocks whose
|
||||
// Markdown bodies are literal text, then delegates every other XML fragment to
|
||||
// the strict XML parser. Escaping literal code is part of Markdown conversion.
|
||||
func parseMarkdownHTMLBlock(fragment string) ([]*Node, error) {
|
||||
trimmed := strings.TrimSpace(fragment)
|
||||
for _, tag := range []string{"code", "whiteboard"} {
|
||||
closing := "</" + tag + ">"
|
||||
if !strings.HasPrefix(trimmed, "<"+tag) || !strings.HasSuffix(trimmed, closing) {
|
||||
continue
|
||||
}
|
||||
token, contentStart, state := scanXMLToken(trimmed, 0)
|
||||
if state != tokenOK || token.closing || token.selfClosing || token.name != tag {
|
||||
return nil, fmt.Errorf("invalid Markdown <%s> block", tag)
|
||||
}
|
||||
contentEnd := len(trimmed) - len(closing)
|
||||
if contentStart > contentEnd {
|
||||
return nil, fmt.Errorf("invalid Markdown <%s> block", tag)
|
||||
}
|
||||
attrs := normalizeAttributes(tag, tag, token.attrs)
|
||||
block := newElement(tag, attrs)
|
||||
appendRawTextWithBreaks(block, strings.Trim(trimmed[contentStart:contentEnd], "\r\n"))
|
||||
return []*Node{block}, nil
|
||||
}
|
||||
return parseXML(fragment)
|
||||
}
|
||||
|
||||
func renderContainer(tag string, attrs map[string]string, node gast.Node, source []byte) ([]*Node, error) {
|
||||
attrs = normalizeAttributes(tag, tag, attrs)
|
||||
container := newElement(tag, attrs)
|
||||
children, err := renderBlockChildren(node, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
container.addChild(child)
|
||||
}
|
||||
return []*Node{container}, nil
|
||||
}
|
||||
|
||||
func renderList(list *gast.List, source []byte) ([]*Node, error) {
|
||||
if isTaskList(list) {
|
||||
return renderTaskList(list, source)
|
||||
}
|
||||
tag := "ul"
|
||||
if list.IsOrdered() {
|
||||
tag = "ol"
|
||||
}
|
||||
listNode := newElement(tag, nil)
|
||||
for child := list.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != gast.KindListItem {
|
||||
continue
|
||||
}
|
||||
item, err := renderListItem(child.(*gast.ListItem), list.IsTight, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listNode.addChild(item)
|
||||
}
|
||||
return []*Node{listNode}, nil
|
||||
}
|
||||
|
||||
func isTaskList(list *gast.List) bool {
|
||||
first := list.FirstChild()
|
||||
if first == nil || first.Kind() != gast.KindListItem {
|
||||
return false
|
||||
}
|
||||
return findTaskCheckbox(first.(*gast.ListItem)) != nil
|
||||
}
|
||||
|
||||
func findTaskCheckbox(item *gast.ListItem) *extast.TaskCheckBox {
|
||||
for child := item.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != gast.KindTextBlock && child.Kind() != gast.KindParagraph {
|
||||
continue
|
||||
}
|
||||
if first := child.FirstChild(); first != nil && first.Kind() == extast.KindTaskCheckBox {
|
||||
return first.(*extast.TaskCheckBox)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderTaskList(list *gast.List, source []byte) ([]*Node, error) {
|
||||
var out []*Node
|
||||
for child := list.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != gast.KindListItem {
|
||||
continue
|
||||
}
|
||||
item := child.(*gast.ListItem)
|
||||
checkboxAST := findTaskCheckbox(item)
|
||||
if checkboxAST == nil {
|
||||
li, err := renderListItem(item, list.IsTight, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ul := newElement("ul", nil)
|
||||
ul.addChild(li)
|
||||
out = append(out, ul)
|
||||
continue
|
||||
}
|
||||
done := "false"
|
||||
if checkboxAST.IsChecked {
|
||||
done = "true"
|
||||
}
|
||||
checkbox := newElement("checkbox", map[string]string{"done": done})
|
||||
for block := item.FirstChild(); block != nil; block = block.NextSibling() {
|
||||
if block.Kind() == gast.KindTextBlock || block.Kind() == gast.KindParagraph {
|
||||
fragment, err := renderInlineFragment(block, source, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := parseMarkdownInlineFragment(fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
checkbox.addChild(node)
|
||||
}
|
||||
continue
|
||||
}
|
||||
nodes, err := renderBlockNode(block, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
checkbox.addChild(node)
|
||||
}
|
||||
}
|
||||
out = append(out, checkbox)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderListItem(item *gast.ListItem, tight bool, source []byte) (*Node, error) {
|
||||
li := newElement("li", nil)
|
||||
children, err := renderBlockChildren(item, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if child.tag == "p" && (tight || paragraphOnlyInline(child)) {
|
||||
for _, grandchild := range child.children {
|
||||
li.addChild(grandchild)
|
||||
}
|
||||
continue
|
||||
}
|
||||
li.addChild(child)
|
||||
}
|
||||
return li, nil
|
||||
}
|
||||
|
||||
func renderInlineChildren(node gast.Node, source []byte) ([]*Node, error) {
|
||||
fragment, err := renderInlineFragment(node, source, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := parseMarkdownInlineFragment(fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripMarkdownEscapesInNodes(nodes, false, false)
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// parseMarkdownInlineFragment wraps an inline fragment in a space-preserving
|
||||
// parent while parsing so XML normalization keeps semantic spaces between
|
||||
// adjacent inline elements. The wrapper is removed from the returned nodes.
|
||||
func parseMarkdownInlineFragment(fragment string) ([]*Node, error) {
|
||||
nodes, err := parseXML("<p>" + fragment + "</p>")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nodes) != 1 || nodes[0].typ != nodeElement || nodes[0].tag != "p" {
|
||||
return nil, fmt.Errorf("invalid Markdown inline fragment")
|
||||
}
|
||||
children := nodes[0].children
|
||||
for _, child := range children {
|
||||
child.parent = nil
|
||||
}
|
||||
return children, nil
|
||||
}
|
||||
|
||||
func renderInlineFragment(parent gast.Node, source []byte, skipCheckbox bool) (string, error) {
|
||||
var out strings.Builder
|
||||
for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if skipCheckbox && child.Kind() == extast.KindTaskCheckBox {
|
||||
continue
|
||||
}
|
||||
fragment, err := renderInlineNode(child, source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out.WriteString(fragment)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func renderInlineNode(node gast.Node, source []byte) (string, error) {
|
||||
switch node.Kind() {
|
||||
case gast.KindText:
|
||||
textNode := node.(*gast.Text)
|
||||
value := escapeXMLText(stripBackslashEscapes(string(textNode.Value(source))))
|
||||
switch {
|
||||
case textNode.HardLineBreak():
|
||||
value += "<br/>"
|
||||
case textNode.SoftLineBreak():
|
||||
value += " "
|
||||
}
|
||||
return value, nil
|
||||
case gast.KindString:
|
||||
return escapeXMLText(string(node.(*gast.String).Value)), nil
|
||||
case gast.KindEmphasis:
|
||||
tag := "em"
|
||||
if node.(*gast.Emphasis).Level >= 2 {
|
||||
tag = "b"
|
||||
}
|
||||
return renderInlineContainer(node, tag, nil, source)
|
||||
case gast.KindCodeSpan:
|
||||
return elementXML("code", nil, escapeXMLText(collectMarkdownChildText(node, source))), nil
|
||||
case gast.KindLink:
|
||||
link := node.(*gast.Link)
|
||||
attrs := map[string]string{"href": string(link.Destination)}
|
||||
if len(link.Title) > 0 {
|
||||
attrs["title"] = string(link.Title)
|
||||
}
|
||||
children, err := renderInlineFragment(node, source, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if children == "" {
|
||||
children = escapeXMLText(string(link.Destination))
|
||||
}
|
||||
return elementXML("a", attrs, children), nil
|
||||
case gast.KindImage:
|
||||
image := node.(*gast.Image)
|
||||
destination := string(image.Destination)
|
||||
attrs := map[string]string{}
|
||||
if strings.HasPrefix(destination, "http://") || strings.HasPrefix(destination, "https://") {
|
||||
attrs["href"] = destination
|
||||
} else {
|
||||
attrs["src"] = destination
|
||||
}
|
||||
if len(image.Title) > 0 {
|
||||
attrs["title"] = string(image.Title)
|
||||
}
|
||||
return elementXML("img", attrs, ""), nil
|
||||
case gast.KindRawHTML:
|
||||
return string(node.(*gast.RawHTML).Segments.Value(source)), nil
|
||||
case gast.KindAutoLink:
|
||||
link := node.(*gast.AutoLink)
|
||||
return elementXML("a", map[string]string{"href": string(link.URL(source))}, escapeXMLText(string(link.Label(source)))), nil
|
||||
}
|
||||
|
||||
switch node.Kind() {
|
||||
case extast.KindStrikethrough:
|
||||
return renderInlineContainer(node, "del", nil, source)
|
||||
case kindMathInline:
|
||||
return elementXML("latex", nil, escapeXMLText(stripLatexMarkdownEscapes(string(node.(*mathInline).content)))), nil
|
||||
case kindMathBlock:
|
||||
return elementXML("latex", nil, escapeXMLText(stripLatexMarkdownEscapes(string(node.(*mathBlock).content)))), nil
|
||||
case extast.KindTaskCheckBox:
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if node.Type() == gast.TypeBlock {
|
||||
return escapeXMLText(strings.TrimSpace(extractMarkdownText(node, source))), nil
|
||||
}
|
||||
return escapeXMLText(extractMarkdownText(node, source)), nil
|
||||
}
|
||||
|
||||
func renderInlineContainer(node gast.Node, tag string, attrs map[string]string, source []byte) (string, error) {
|
||||
children, err := renderInlineFragment(node, source, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return elementXML(tag, attrs, children), nil
|
||||
}
|
||||
|
||||
func elementXML(tag string, attrs map[string]string, inner string) string {
|
||||
node := newElement(tag, attrs)
|
||||
rendered := renderNodes([]*Node{node})
|
||||
if inner == "" {
|
||||
return rendered
|
||||
}
|
||||
close := "</" + tag + ">"
|
||||
if strings.HasSuffix(rendered, close) {
|
||||
return strings.TrimSuffix(rendered, close) + inner + close
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
func wrapParagraphChildren(children []*Node) []*Node {
|
||||
var out []*Node
|
||||
var inline []*Node
|
||||
flush := func() {
|
||||
if len(inline) == 0 {
|
||||
return
|
||||
}
|
||||
paragraph := newElement("p", nil)
|
||||
for _, child := range inline {
|
||||
paragraph.addChild(child)
|
||||
}
|
||||
out = append(out, paragraph)
|
||||
inline = nil
|
||||
}
|
||||
for _, child := range children {
|
||||
if child != nil && child.typ == nodeElement && layoutOf(child.tag) == layoutBlock {
|
||||
flush()
|
||||
out = append(out, child)
|
||||
continue
|
||||
}
|
||||
inline = append(inline, child)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
func paragraphOnlyInline(node *Node) bool {
|
||||
if node == nil || node.typ != nodeElement || node.tag != "p" {
|
||||
return false
|
||||
}
|
||||
for _, child := range node.children {
|
||||
if child.typ == nodeElement && layoutOf(child.tag) == layoutBlock {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderTable(node gast.Node, source []byte) ([]*Node, error) {
|
||||
table := newElement("table", nil)
|
||||
var body *Node
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch child.Kind() {
|
||||
case extast.KindTableHeader:
|
||||
head := newElement("thead", nil)
|
||||
row, err := renderTableRow(child, true, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head.addChild(row)
|
||||
table.addChild(head)
|
||||
case extast.KindTableRow:
|
||||
if body == nil {
|
||||
body = newElement("tbody", nil)
|
||||
table.addChild(body)
|
||||
}
|
||||
row, err := renderTableRow(child, false, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body.addChild(row)
|
||||
}
|
||||
}
|
||||
return []*Node{table}, nil
|
||||
}
|
||||
|
||||
func renderTableRow(node gast.Node, header bool, source []byte) (*Node, error) {
|
||||
row := newElement("tr", nil)
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != extast.KindTableCell {
|
||||
continue
|
||||
}
|
||||
cellAST := child.(*extast.TableCell)
|
||||
tag := "td"
|
||||
if header {
|
||||
tag = "th"
|
||||
}
|
||||
attrs := map[string]string(nil)
|
||||
switch cellAST.Alignment {
|
||||
case extast.AlignCenter:
|
||||
attrs = map[string]string{"align": "center"}
|
||||
case extast.AlignRight:
|
||||
attrs = map[string]string{"align": "right"}
|
||||
}
|
||||
cell := newElement(tag, attrs)
|
||||
content, err := renderInlineChildren(cellAST, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, inline := range content {
|
||||
cell.addChild(inline)
|
||||
}
|
||||
row.addChild(cell)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func renderDefinitionList(node gast.Node, source []byte) ([]*Node, error) {
|
||||
var out []*Node
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch child.Kind() {
|
||||
case extast.KindDefinitionTerm:
|
||||
fragment, err := renderInlineFragment(child, source, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := parseXML(fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paragraph := newElement("p", nil)
|
||||
bold := newElement("b", nil)
|
||||
for _, node := range nodes {
|
||||
bold.addChild(node)
|
||||
}
|
||||
paragraph.addChild(bold)
|
||||
out = append(out, paragraph)
|
||||
case extast.KindDefinitionDescription:
|
||||
quote, err := renderContainer("blockquote", nil, child, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, quote...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func appendRawTextWithBreaks(parent *Node, content string) {
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
start := 0
|
||||
for i := 0; i < len(content); i++ {
|
||||
if content[i] != '\n' && content[i] != '\r' {
|
||||
continue
|
||||
}
|
||||
if i > start {
|
||||
parent.addChild(newText(content[start:i]))
|
||||
}
|
||||
if content[i] == '\r' && i+1 < len(content) && content[i+1] == '\n' {
|
||||
i++
|
||||
}
|
||||
parent.addChild(newElement("br", nil))
|
||||
start = i + 1
|
||||
}
|
||||
if start < len(content) {
|
||||
parent.addChild(newText(content[start:]))
|
||||
}
|
||||
}
|
||||
|
||||
func stripMarkdownEscapesInNodes(nodes []*Node, inCode, inLatex bool) {
|
||||
for _, node := range nodes {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
if node.typ == nodeText {
|
||||
switch {
|
||||
case inCode:
|
||||
case inLatex:
|
||||
node.text = stripLatexMarkdownEscapes(node.text)
|
||||
default:
|
||||
node.text = stripBackslashEscapes(node.text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
stripMarkdownEscapesInNodes(node.children, inCode || node.tag == "code" || node.tag == "pre", inLatex || node.tag == "latex")
|
||||
}
|
||||
}
|
||||
|
||||
func stripBackslashEscapes(value string) string {
|
||||
if !strings.Contains(value, `\`) {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == '\\' && i+1 < len(value) && isASCIIPunctuation(value[i+1]) {
|
||||
out.WriteByte(value[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out.WriteByte(value[i])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func stripLatexMarkdownEscapes(value string) string {
|
||||
if !strings.Contains(value, `\`) {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == '\\' && i+1 < len(value) && strings.ContainsRune("_^&*[]$~<>`#+-=:", rune(value[i+1])) {
|
||||
out.WriteByte(value[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out.WriteByte(value[i])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func isASCIIPunctuation(ch byte) bool {
|
||||
return ch >= '!' && ch <= '/' || ch >= ':' && ch <= '@' || ch >= '[' && ch <= '`' || ch >= '{' && ch <= '~'
|
||||
}
|
||||
|
||||
func trimOneTrailingNewline(value string) string {
|
||||
if strings.HasSuffix(value, "\r\n") {
|
||||
return value[:len(value)-2]
|
||||
}
|
||||
return strings.TrimSuffix(value, "\n")
|
||||
}
|
||||
|
||||
func collectMarkdownChildText(node gast.Node, source []byte) string {
|
||||
var out strings.Builder
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch child.Kind() {
|
||||
case gast.KindText:
|
||||
out.Write(child.(*gast.Text).Value(source))
|
||||
case gast.KindString:
|
||||
out.Write(child.(*gast.String).Value)
|
||||
default:
|
||||
out.WriteString(collectMarkdownChildText(child, source))
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func extractMarkdownText(node gast.Node, source []byte) string {
|
||||
switch node.Kind() {
|
||||
case gast.KindText:
|
||||
return string(node.(*gast.Text).Value(source))
|
||||
case gast.KindString:
|
||||
return string(node.(*gast.String).Value)
|
||||
case gast.KindCodeSpan:
|
||||
return collectMarkdownChildText(node, source)
|
||||
}
|
||||
if node.Type() == gast.TypeBlock && node.Lines() != nil && node.Lines().Len() > 0 {
|
||||
return string(node.Lines().Value(source))
|
||||
}
|
||||
var out strings.Builder
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
out.WriteString(extractMarkdownText(child, source))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func headingTag(level int) string {
|
||||
if level < 1 || level > 6 {
|
||||
return "p"
|
||||
}
|
||||
return fmt.Sprintf("h%d", level)
|
||||
}
|
||||
|
||||
func normalizeListIndent(markdown string) string {
|
||||
lines := strings.Split(markdown, "\n")
|
||||
type stackEntry struct{ indent int }
|
||||
var stack []stackEntry
|
||||
inFence := false
|
||||
changed := false
|
||||
lastOriginal, lastNormalized := 0, 0
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimLeft(line, " ")
|
||||
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
||||
inFence = !inFence
|
||||
continue
|
||||
}
|
||||
if inFence || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
indent := len(line) - len(trimmed)
|
||||
if markdownListMarkerLength(trimmed) > 0 {
|
||||
for len(stack) > 0 && indent <= stack[len(stack)-1].indent {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
normalized := len(stack) * 4
|
||||
stack = append(stack, stackEntry{indent: indent})
|
||||
lastOriginal, lastNormalized = indent, normalized
|
||||
if indent != normalized {
|
||||
lines[i] = strings.Repeat(" ", normalized) + trimmed
|
||||
changed = true
|
||||
}
|
||||
} else if len(stack) > 0 && indent > lastOriginal {
|
||||
delta := lastNormalized - lastOriginal
|
||||
if delta != 0 {
|
||||
normalized := indent + delta
|
||||
if normalized < 0 {
|
||||
normalized = 0
|
||||
}
|
||||
lines[i] = strings.Repeat(" ", normalized) + trimmed
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return markdown
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func markdownListMarkerLength(value string) int {
|
||||
if len(value) >= 2 && (value[0] == '-' || value[0] == '*' || value[0] == '+') && value[1] == ' ' {
|
||||
return 2
|
||||
}
|
||||
i := 0
|
||||
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i > 0 && i+1 < len(value) && (value[i] == '.' || value[i] == ')') && value[i+1] == ' ' {
|
||||
return i + 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// preprocessCJKAdjacentMarkup disambiguates a narrow CommonMark pattern common in
|
||||
// Chinese prose: emphasis that ends in punctuation and is immediately followed
|
||||
// by a letter (for example **结论。**下一步). Goldmark correctly follows
|
||||
// CommonMark's delimiter rules, while LarkOpenCLI accepts this authoring form.
|
||||
// Rewriting simple CJK delimiter spans to equivalent DocxXML
|
||||
// before parsing removes the ambiguity while leaving nested Markdown, links,
|
||||
// code, fenced blocks, and source-bearing XML untouched.
|
||||
func preprocessCJKAdjacentMarkup(markdown string) string {
|
||||
if !strings.Contains(markdown, "**") && !strings.Contains(markdown, "~~") {
|
||||
return markdown
|
||||
}
|
||||
lines := strings.SplitAfter(markdown, "\n")
|
||||
var out strings.Builder
|
||||
fenceMarker := rune(0)
|
||||
fenceLength := 0
|
||||
rawSourceTag := ""
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimLeft(line, " \t>")
|
||||
if marker, length, ok := markdownFence(trimmed); ok {
|
||||
if fenceMarker == 0 {
|
||||
fenceMarker, fenceLength = marker, length
|
||||
} else if marker == fenceMarker && length >= fenceLength && strings.TrimSpace(runeTail(trimmed, length)) == "" {
|
||||
fenceMarker, fenceLength = 0, 0
|
||||
}
|
||||
out.WriteString(line)
|
||||
continue
|
||||
}
|
||||
if fenceMarker != 0 || leadingIndent(line) >= 4 {
|
||||
out.WriteString(line)
|
||||
continue
|
||||
}
|
||||
out.WriteString(rewriteCJKMarkupLine(line, &rawSourceTag))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func markdownFence(line string) (rune, int, bool) {
|
||||
runes := []rune(line)
|
||||
if len(runes) < 3 || runes[0] != '`' && runes[0] != '~' {
|
||||
return 0, 0, false
|
||||
}
|
||||
marker := runes[0]
|
||||
length := 0
|
||||
for length < len(runes) && runes[length] == marker {
|
||||
length++
|
||||
}
|
||||
return marker, length, length >= 3
|
||||
}
|
||||
|
||||
func runeTail(value string, start int) string {
|
||||
runes := []rune(value)
|
||||
if start >= len(runes) {
|
||||
return ""
|
||||
}
|
||||
return string(runes[start:])
|
||||
}
|
||||
|
||||
func leadingIndent(line string) int {
|
||||
count := 0
|
||||
for _, r := range line {
|
||||
switch r {
|
||||
case ' ':
|
||||
count++
|
||||
case '\t':
|
||||
count += 4
|
||||
default:
|
||||
return count
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
type cjkMarkupRule struct {
|
||||
delimiter []rune
|
||||
openXML string
|
||||
closeXML string
|
||||
}
|
||||
|
||||
var cjkMarkupRules = []cjkMarkupRule{
|
||||
{delimiter: []rune("***"), openXML: "<em><b>", closeXML: "</b></em>"},
|
||||
{delimiter: []rune("~~"), openXML: "<del>", closeXML: "</del>"},
|
||||
{delimiter: []rune("**"), openXML: "<b>", closeXML: "</b>"},
|
||||
}
|
||||
|
||||
func rewriteCJKMarkupLine(line string, rawSourceTag *string) string {
|
||||
if *rawSourceTag != "" {
|
||||
runes := []rune(line)
|
||||
closeTag := []rune("</" + *rawSourceTag + ">")
|
||||
closeAt := indexRunesFold(runes, 0, closeTag)
|
||||
if closeAt < 0 {
|
||||
return line
|
||||
}
|
||||
closeEnd := closeAt + len(closeTag)
|
||||
prefix := string(runes[:closeEnd])
|
||||
*rawSourceTag = ""
|
||||
return prefix + rewriteCJKMarkupLine(string(runes[closeEnd:]), rawSourceTag)
|
||||
}
|
||||
|
||||
runes := []rune(line)
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(runes); {
|
||||
if runes[i] == '`' && !runeEscaped(runes, i) {
|
||||
if end := codeSpanEnd(runes, i); end > i {
|
||||
out.WriteString(string(runes[i:end]))
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
if runes[i] == '<' {
|
||||
if tag, end, selfClosing, ok := rawTagAt(runes, i); ok {
|
||||
out.WriteString(string(runes[i:end]))
|
||||
i = end
|
||||
if !selfClosing && (tag == "code" || tag == "pre" || tag == "whiteboard") {
|
||||
close := []rune("</" + tag + ">")
|
||||
if closeAt := indexRunesFold(runes, i, close); closeAt >= 0 {
|
||||
closeEnd := closeAt + len(close)
|
||||
out.WriteString(string(runes[i:closeEnd]))
|
||||
i = closeEnd
|
||||
} else {
|
||||
out.WriteString(string(runes[i:]))
|
||||
*rawSourceTag = tag
|
||||
return out.String()
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rewritten := false
|
||||
for _, rule := range cjkMarkupRules {
|
||||
if !exactDelimiterAt(runes, i, rule.delimiter) || runeEscaped(runes, i) {
|
||||
continue
|
||||
}
|
||||
closeAt := delimiterCloser(runes, i+len(rule.delimiter), rule.delimiter)
|
||||
if closeAt < 0 {
|
||||
continue
|
||||
}
|
||||
content := runes[i+len(rule.delimiter) : closeAt]
|
||||
if !shouldRewriteCJKMarkup(content) {
|
||||
continue
|
||||
}
|
||||
out.WriteString(rule.openXML)
|
||||
out.WriteString(escapeXMLText(stripBackslashEscapes(string(content))))
|
||||
out.WriteString(rule.closeXML)
|
||||
i = closeAt + len(rule.delimiter)
|
||||
rewritten = true
|
||||
break
|
||||
}
|
||||
if rewritten {
|
||||
continue
|
||||
}
|
||||
out.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func rawTagAt(runes []rune, start int) (tag string, end int, selfClosing, ok bool) {
|
||||
if start+1 >= len(runes) || !isASCIILetterRune(runes[start+1]) {
|
||||
return "", 0, false, false
|
||||
}
|
||||
i := start + 1
|
||||
for i < len(runes) && (isASCIILetterRune(runes[i]) || isASCIIDigitRune(runes[i]) || runes[i] == '-' || runes[i] == '_') {
|
||||
i++
|
||||
}
|
||||
tag = strings.ToLower(string(runes[start+1 : i]))
|
||||
quote := rune(0)
|
||||
for ; i < len(runes); i++ {
|
||||
if runes[i] == '\'' || runes[i] == '"' {
|
||||
if quote == 0 {
|
||||
quote = runes[i]
|
||||
} else if quote == runes[i] {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if runes[i] == '>' && quote == 0 {
|
||||
trimmed := strings.TrimSpace(string(runes[start : i+1]))
|
||||
return tag, i + 1, strings.HasSuffix(trimmed, "/>"), true
|
||||
}
|
||||
}
|
||||
return "", 0, false, false
|
||||
}
|
||||
|
||||
func indexRunesFold(haystack []rune, start int, needle []rune) int {
|
||||
for i := start; i+len(needle) <= len(haystack); i++ {
|
||||
if strings.EqualFold(string(haystack[i:i+len(needle)]), string(needle)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func codeSpanEnd(runes []rune, open int) int {
|
||||
length := 0
|
||||
for open+length < len(runes) && runes[open+length] == '`' {
|
||||
length++
|
||||
}
|
||||
for i := open + length; i < len(runes); i++ {
|
||||
if runes[i] != '`' || runeEscaped(runes, i) {
|
||||
continue
|
||||
}
|
||||
end := i
|
||||
for end < len(runes) && runes[end] == '`' {
|
||||
end++
|
||||
}
|
||||
if end-i == length {
|
||||
return end
|
||||
}
|
||||
i = end - 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func exactDelimiterAt(runes []rune, start int, delimiter []rune) bool {
|
||||
if start+len(delimiter) > len(runes) {
|
||||
return false
|
||||
}
|
||||
for i, want := range delimiter {
|
||||
if runes[start+i] != want {
|
||||
return false
|
||||
}
|
||||
}
|
||||
marker := delimiter[0]
|
||||
return (start == 0 || runes[start-1] != marker) && (start+len(delimiter) == len(runes) || runes[start+len(delimiter)] != marker)
|
||||
}
|
||||
|
||||
func delimiterCloser(runes []rune, start int, delimiter []rune) int {
|
||||
for i := start; i+len(delimiter) <= len(runes); i++ {
|
||||
if runes[i] == '\n' {
|
||||
return -1
|
||||
}
|
||||
if exactDelimiterAt(runes, i, delimiter) && !runeEscaped(runes, i) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func shouldRewriteCJKMarkup(content []rune) bool {
|
||||
if len(content) == 0 || unicode.IsSpace(content[0]) || unicode.IsSpace(content[len(content)-1]) {
|
||||
return false
|
||||
}
|
||||
for _, r := range content {
|
||||
if r == '`' || r == '[' || r == ']' || r == '<' || r == '>' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !containsCJK(content) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsCJK(value []rune) bool {
|
||||
for _, r := range value {
|
||||
if isCJKRune(r) || r > unicode.MaxASCII && (unicode.IsPunct(r) || unicode.IsSymbol(r)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCJKRune(r rune) bool {
|
||||
return unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul)
|
||||
}
|
||||
|
||||
func runeEscaped(runes []rune, index int) bool {
|
||||
count := 0
|
||||
for i := index - 1; i >= 0 && runes[i] == '\\'; i-- {
|
||||
count++
|
||||
}
|
||||
return count%2 == 1
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
// This file contains the small Goldmark extensions needed to match the
|
||||
// LarkOpenCLI's Markdown surface: math, DocxXML tag names containing
|
||||
// underscores, and Markdown-aware callout/grid/column containers.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
gmutil "github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// ---------- Math ----------
|
||||
|
||||
var kindMathInline = gast.NewNodeKind("DocxMathInline")
|
||||
var kindMathBlock = gast.NewNodeKind("DocxMathBlock")
|
||||
|
||||
type mathInline struct {
|
||||
gast.BaseInline
|
||||
content []byte
|
||||
}
|
||||
|
||||
func (n *mathInline) Kind() gast.NodeKind { return kindMathInline }
|
||||
func (n *mathInline) Dump(source []byte, level int) {
|
||||
gast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
type mathBlock struct {
|
||||
gast.BaseInline
|
||||
content []byte
|
||||
}
|
||||
|
||||
func (n *mathBlock) Kind() gast.NodeKind { return kindMathBlock }
|
||||
func (n *mathBlock) Dump(source []byte, level int) {
|
||||
gast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
var (
|
||||
mathBlockMultiLine = regexp.MustCompile(`(?s)^\$\$(.+?)\$\$`)
|
||||
mathInlineMultiLine = regexp.MustCompile(`(?s)^\$([^ \t$].*?)\$`)
|
||||
)
|
||||
|
||||
type mathInlineParser struct{}
|
||||
|
||||
func (p *mathInlineParser) Trigger() []byte { return []byte{'$'} }
|
||||
|
||||
func (p *mathInlineParser) Parse(_ gast.Node, reader text.Reader, _ parser.Context) gast.Node {
|
||||
line, _ := reader.PeekLine()
|
||||
if len(line) == 0 || line[0] != '$' {
|
||||
return nil
|
||||
}
|
||||
if len(line) >= 2 && line[1] == '$' {
|
||||
if content, advance := scanMathClose(line[2:], "$$"); advance >= 0 && len(content) > 0 {
|
||||
reader.Advance(2 + advance)
|
||||
return &mathBlock{content: append([]byte(nil), content...)}
|
||||
}
|
||||
match := reader.FindSubMatch(mathBlockMultiLine)
|
||||
if len(match) >= 2 && len(bytes.TrimSpace(match[1])) > 0 && !bytes.Contains(match[1], []byte("<latex")) {
|
||||
return &mathBlock{content: append([]byte(nil), bytes.TrimSpace(match[1])...)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(line) < 2 || line[1] == ' ' || line[1] == '\t' || line[1] == '$' {
|
||||
return nil
|
||||
}
|
||||
if content, advance := scanMathClose(line[1:], "$"); advance >= 0 && len(content) > 0 {
|
||||
if content[len(content)-1] == ' ' || content[len(content)-1] == '\t' {
|
||||
return nil
|
||||
}
|
||||
reader.Advance(1 + advance)
|
||||
return &mathInline{content: append([]byte(nil), content...)}
|
||||
}
|
||||
match := reader.FindSubMatch(mathInlineMultiLine)
|
||||
if len(match) < 2 || bytes.Contains(match[1], []byte("<latex")) {
|
||||
return nil
|
||||
}
|
||||
trimmed := bytes.TrimRight(match[1], "\n\r")
|
||||
if len(trimmed) == 0 || trimmed[len(trimmed)-1] == ' ' || trimmed[len(trimmed)-1] == '\t' {
|
||||
return nil
|
||||
}
|
||||
return &mathInline{content: append([]byte(nil), trimmed...)}
|
||||
}
|
||||
|
||||
func scanMathClose(data []byte, delimiter string) ([]byte, int) {
|
||||
delim := []byte(delimiter)
|
||||
for offset := 0; offset < len(data); {
|
||||
if data[offset] == '\\' && offset+1 < len(data) && data[offset+1] == '$' {
|
||||
offset += 2
|
||||
continue
|
||||
}
|
||||
rel := bytes.Index(data[offset:], delim)
|
||||
if rel < 0 {
|
||||
return nil, -1
|
||||
}
|
||||
end := offset + rel
|
||||
if bytes.Contains(data[:end], []byte("<latex")) {
|
||||
return nil, -1
|
||||
}
|
||||
return data[:end], end + len(delim)
|
||||
}
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
type mathExtension struct{}
|
||||
|
||||
func (e *mathExtension) Extend(markdown goldmark.Markdown) {
|
||||
markdown.Parser().AddOptions(parser.WithInlineParsers(
|
||||
gmutil.Prioritized(&mathInlineParser{}, 100),
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- Underscore-bearing raw XML tags ----------
|
||||
|
||||
type underscoreHTMLExtension struct{}
|
||||
|
||||
func (e *underscoreHTMLExtension) Extend(markdown goldmark.Markdown) {
|
||||
markdown.Parser().AddOptions(
|
||||
parser.WithInlineParsers(gmutil.Prioritized(&underscoreRawHTMLParser{}, 99)),
|
||||
parser.WithBlockParsers(gmutil.Prioritized(&underscoreHTMLBlockParser{}, 99)),
|
||||
)
|
||||
}
|
||||
|
||||
var (
|
||||
extendedTagNamePattern = `([A-Za-z][A-Za-z0-9_-]*)`
|
||||
extendedAttributePattern = `(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^"'=<>` + "`" + `\x00-\x20]+|'[^']*'|"[^"]*"))?)`
|
||||
extendedOpenTag = regexp.MustCompile("^<" + extendedTagNamePattern + extendedAttributePattern + `*\s*/?>`)
|
||||
extendedCloseTag = regexp.MustCompile("^</" + extendedTagNamePattern + `\s*>`)
|
||||
peekExtendedOpenTag = regexp.MustCompile(`^<([A-Za-z][A-Za-z0-9_-]*)`)
|
||||
peekExtendedCloseTag = regexp.MustCompile(`^</([A-Za-z][A-Za-z0-9_-]*)`)
|
||||
extendedBlockTag = regexp.MustCompile(`^[ ]{0,3}<(/)?\s*([a-zA-Z0-9_\-]+)(` + extendedAttributePattern + `*)\s*(?:>|/>)\s*\n?$`)
|
||||
)
|
||||
|
||||
type underscoreRawHTMLParser struct{}
|
||||
|
||||
func (p *underscoreRawHTMLParser) Trigger() []byte { return []byte{'<'} }
|
||||
|
||||
func (p *underscoreRawHTMLParser) Parse(_ gast.Node, reader text.Reader, _ parser.Context) gast.Node {
|
||||
line, _ := reader.PeekLine()
|
||||
if len(line) > 1 && gmutil.IsAlphaNumeric(line[1]) {
|
||||
if match := peekExtendedOpenTag.FindSubmatch(line); match != nil && bytes.IndexByte(match[1], '_') >= 0 {
|
||||
return p.parseMultiLine(extendedOpenTag, reader)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(line) > 2 && line[1] == '/' && gmutil.IsAlphaNumeric(line[2]) {
|
||||
if match := peekExtendedCloseTag.FindSubmatch(line); match != nil && bytes.IndexByte(match[1], '_') >= 0 {
|
||||
return p.parseMultiLine(extendedCloseTag, reader)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *underscoreRawHTMLParser) parseMultiLine(re *regexp.Regexp, reader text.Reader) gast.Node {
|
||||
startLine, startSegment := reader.Position()
|
||||
if !reader.Match(re) {
|
||||
return nil
|
||||
}
|
||||
endLine, endSegment := reader.Position()
|
||||
reader.SetPosition(startLine, startSegment)
|
||||
node := gast.NewRawHTML()
|
||||
for {
|
||||
line, segment := reader.PeekLine()
|
||||
if line == nil {
|
||||
break
|
||||
}
|
||||
lineNo, _ := reader.Position()
|
||||
start := segment.Start
|
||||
if lineNo == startLine {
|
||||
start = startSegment.Start
|
||||
}
|
||||
end := segment.Stop
|
||||
if lineNo == endLine {
|
||||
end = endSegment.Start
|
||||
}
|
||||
node.Segments.Append(text.NewSegment(start, end))
|
||||
if lineNo == endLine {
|
||||
reader.Advance(end - start)
|
||||
break
|
||||
}
|
||||
reader.AdvanceLine()
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
type underscoreHTMLBlockParser struct{}
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Trigger() []byte { return []byte{'<'} }
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Open(_ gast.Node, reader text.Reader, pc parser.Context) (gast.Node, parser.State) {
|
||||
line, segment := reader.PeekLine()
|
||||
pos := pc.BlockOffset()
|
||||
if pos < 0 || pos >= len(line) || line[pos] != '<' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
match := extendedBlockTag.FindSubmatchIndex(line)
|
||||
if match == nil {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
tag := string(line[match[4]:match[5]])
|
||||
if !strings.Contains(tag, "_") {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
isClose := match[2] > -1 && bytes.Equal(line[match[2]:match[3]], []byte("/"))
|
||||
hasAttrs := match[6] != match[7]
|
||||
if isClose && hasAttrs {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
node := gast.NewHTMLBlock(gast.HTMLBlockType7)
|
||||
node.Lines().Append(segment)
|
||||
reader.Advance(segment.Len() - 1)
|
||||
return node, parser.NoChildren
|
||||
}
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State {
|
||||
line, segment := reader.PeekLine()
|
||||
if gmutil.IsBlank(line) {
|
||||
return parser.Close
|
||||
}
|
||||
node.Lines().Append(segment)
|
||||
reader.Advance(segment.Len() - 1)
|
||||
return parser.Continue | parser.NoChildren
|
||||
}
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Close(gast.Node, text.Reader, parser.Context) {}
|
||||
func (p *underscoreHTMLBlockParser) CanInterruptParagraph() bool { return false }
|
||||
func (p *underscoreHTMLBlockParser) CanAcceptIndentedLine() bool { return false }
|
||||
|
||||
// ---------- Markdown-aware DocxXML containers ----------
|
||||
|
||||
type containerSpec struct {
|
||||
tag string
|
||||
}
|
||||
|
||||
var containerSpecs = map[string]*containerSpec{
|
||||
"callout": {tag: "callout"},
|
||||
"grid": {tag: "grid"},
|
||||
"column": {tag: "column"},
|
||||
"div": {tag: "div"},
|
||||
}
|
||||
|
||||
var kindContainerBlock = gast.NewNodeKind("DocxContainerBlock")
|
||||
|
||||
type containerBlock struct {
|
||||
gast.BaseBlock
|
||||
spec *containerSpec
|
||||
attrs map[string]string
|
||||
}
|
||||
|
||||
func (n *containerBlock) Kind() gast.NodeKind { return kindContainerBlock }
|
||||
func (n *containerBlock) Dump(source []byte, level int) {
|
||||
gast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
type containerBlockParser struct{}
|
||||
|
||||
func (p *containerBlockParser) Trigger() []byte { return []byte{'<'} }
|
||||
|
||||
var containerOpenTag = regexp.MustCompile(`^<([A-Za-z][A-Za-z0-9_-]*)`)
|
||||
|
||||
func (p *containerBlockParser) Open(_ gast.Node, reader text.Reader, _ parser.Context) (gast.Node, parser.State) {
|
||||
line, _ := reader.PeekLine()
|
||||
trimmed := bytes.TrimLeft(line, " \t")
|
||||
leading := len(line) - len(trimmed)
|
||||
if len(trimmed) < 2 || trimmed[0] != '<' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
match := containerOpenTag.FindSubmatch(trimmed)
|
||||
if match == nil {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
spec := containerSpecs[strings.ToLower(string(match[1]))]
|
||||
if spec == nil {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
openEnd := bytes.IndexByte(trimmed, '>')
|
||||
if openEnd < 0 || openEnd >= 1 && trimmed[openEnd-1] == '/' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
tagEnd := len(match[0])
|
||||
node := &containerBlock{spec: spec, attrs: parseAttributes(string(trimmed[tagEnd:openEnd]))}
|
||||
reader.Advance(leading + openEnd + 1)
|
||||
return node, parser.HasChildren
|
||||
}
|
||||
|
||||
func (p *containerBlockParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State {
|
||||
container := node.(*containerBlock)
|
||||
line, segment := reader.PeekLine()
|
||||
trimmed := bytes.TrimLeft(line, " \t")
|
||||
if hasCloseTagPrefix(trimmed, container.spec.tag) {
|
||||
reader.Advance(len(line) - len(trimmed) + closeTagLength(container.spec.tag))
|
||||
return parser.Close
|
||||
}
|
||||
if isXMLTagLine(trimmed) {
|
||||
indent := len(line) - len(trimmed)
|
||||
if indent > 0 && segment.Start+indent <= segment.Stop {
|
||||
reader.AdvanceAndSetPadding(indent, 0)
|
||||
}
|
||||
}
|
||||
return parser.Continue | parser.HasChildren
|
||||
}
|
||||
|
||||
func (p *containerBlockParser) Close(gast.Node, text.Reader, parser.Context) {}
|
||||
func (p *containerBlockParser) CanInterruptParagraph() bool { return true }
|
||||
func (p *containerBlockParser) CanAcceptIndentedLine() bool { return true }
|
||||
|
||||
func closeTagLength(tag string) int { return len(tag) + len("</>") }
|
||||
|
||||
func hasCloseTagPrefix(line []byte, tag string) bool {
|
||||
want := []byte("</" + tag + ">")
|
||||
return len(line) >= len(want) && bytes.EqualFold(line[:len(want)], want)
|
||||
}
|
||||
|
||||
func isXMLTagLine(line []byte) bool {
|
||||
if len(line) < 2 || line[0] != '<' {
|
||||
return false
|
||||
}
|
||||
if line[1] == '/' {
|
||||
return len(line) >= 3 && isASCIILetter(line[2])
|
||||
}
|
||||
return isASCIILetter(line[1])
|
||||
}
|
||||
|
||||
func isASCIILetter(ch byte) bool {
|
||||
return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package docxparse parses LarkOpenCLI DocxXML and Markdown into a small,
|
||||
// offline DOM for the docs +script shortcut.
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Format is an accepted source document format.
|
||||
type Format string
|
||||
|
||||
const (
|
||||
FormatXML Format = "xml"
|
||||
FormatMarkdown Format = "markdown"
|
||||
)
|
||||
|
||||
// ParseResult is the complete result returned by Parse.
|
||||
type ParseResult struct {
|
||||
Format Format `json:"format"`
|
||||
XML string `json:"xml"`
|
||||
Profile Profile `json:"profile"`
|
||||
}
|
||||
|
||||
type nodeType uint8
|
||||
|
||||
const (
|
||||
nodeText nodeType = iota
|
||||
nodeElement
|
||||
)
|
||||
|
||||
// Node is the internal DocxXML DOM representation.
|
||||
type Node struct {
|
||||
typ nodeType
|
||||
tag string
|
||||
attrs map[string]string
|
||||
children []*Node
|
||||
text string
|
||||
parent *Node
|
||||
}
|
||||
|
||||
func newText(text string) *Node {
|
||||
return &Node{typ: nodeText, text: text}
|
||||
}
|
||||
|
||||
func newElement(tag string, attrs map[string]string) *Node {
|
||||
return &Node{typ: nodeElement, tag: tag, attrs: attrs}
|
||||
}
|
||||
|
||||
func (n *Node) addChild(child *Node) {
|
||||
if n == nil || child == nil {
|
||||
return
|
||||
}
|
||||
child.parent = n
|
||||
n.children = append(n.children, child)
|
||||
}
|
||||
|
||||
func (n *Node) writeXML(out *strings.Builder) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
if n.typ == nodeText {
|
||||
out.WriteString(escapeXMLText(n.text))
|
||||
return
|
||||
}
|
||||
|
||||
out.WriteByte('<')
|
||||
out.WriteString(n.tag)
|
||||
keys := make([]string, 0, len(n.attrs))
|
||||
for key := range n.attrs {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
wi, iWeighted := attributeWeight[keys[i]]
|
||||
wj, jWeighted := attributeWeight[keys[j]]
|
||||
switch {
|
||||
case iWeighted && jWeighted && wi != wj:
|
||||
return wi < wj
|
||||
case iWeighted != jWeighted:
|
||||
return iWeighted
|
||||
default:
|
||||
return keys[i] < keys[j]
|
||||
}
|
||||
})
|
||||
for _, key := range keys {
|
||||
out.WriteByte(' ')
|
||||
out.WriteString(key)
|
||||
out.WriteString(`="`)
|
||||
out.WriteString(escapeXMLAttr(n.attrs[key]))
|
||||
out.WriteByte('"')
|
||||
}
|
||||
|
||||
if isVoidTag(n.tag) {
|
||||
out.WriteString("/>")
|
||||
return
|
||||
}
|
||||
out.WriteByte('>')
|
||||
for _, child := range n.children {
|
||||
child.writeXML(out)
|
||||
}
|
||||
out.WriteString("</")
|
||||
out.WriteString(n.tag)
|
||||
out.WriteByte('>')
|
||||
}
|
||||
|
||||
func renderNodes(nodes []*Node) string {
|
||||
var out strings.Builder
|
||||
for _, node := range nodes {
|
||||
node.writeXML(&out)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
var attributeWeight = map[string]int{
|
||||
"id": 0,
|
||||
"name": 1,
|
||||
"top-block-id": 2,
|
||||
"parent-block-path": 3,
|
||||
"mode": 4,
|
||||
"start-block-id": 5,
|
||||
"end-block-id": 6,
|
||||
"hit-block-ids": 7,
|
||||
}
|
||||
|
||||
func escapeXMLText(value string) string {
|
||||
if !strings.ContainsAny(value, "&<>") {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value) + 8)
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
out.WriteString("&")
|
||||
case '<':
|
||||
out.WriteString("<")
|
||||
case '>':
|
||||
out.WriteString(">")
|
||||
default:
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
if !strings.ContainsAny(value, "&<>\"'") {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value) + 8)
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
out.WriteString("&")
|
||||
case '<':
|
||||
out.WriteString("<")
|
||||
case '>':
|
||||
out.WriteString(">")
|
||||
case '"':
|
||||
out.WriteString(""")
|
||||
case '\'':
|
||||
out.WriteString("'")
|
||||
default:
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseXMLBuildsBlockDistribution(t *testing.T) {
|
||||
result, err := Parse(`<title>T</title><p>P</p><ul><li>A</li><li>B</li></ul>`, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<title>T</title><p>P</p><ul><li>A</li><li>B</li></ul>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
if result.Profile.BlockCount != 5 {
|
||||
t.Fatalf("block total = %d, want 5", result.Profile.BlockCount)
|
||||
}
|
||||
shares := map[string]BlockShare{}
|
||||
for _, share := range result.Profile.Blocks {
|
||||
shares[share.Type] = share
|
||||
}
|
||||
if got := shares["li"]; got.Count != 2 || got.Ratio != 0.4 {
|
||||
t.Fatalf("li share = %+v, want count=2 ratio=0.4", got)
|
||||
}
|
||||
for _, typ := range []string{"title", "p", "ul"} {
|
||||
if got := shares[typ]; got.Count != 1 || got.Ratio != 0.2 {
|
||||
t.Errorf("%s share = %+v, want count=1 ratio=0.2", typ, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLRejectsInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
}{
|
||||
{name: "unsupported tag", source: `<unknown>x</unknown>`},
|
||||
{name: "missing closing tag", source: `<p>one`},
|
||||
{name: "invalid nesting", source: `<span>x<table><tr><td>y</td></tr></table></span>`},
|
||||
{name: "malformed block id", source: `<block_id="8,9"/>`},
|
||||
{name: "unterminated cdata", source: `<code><![CDATA[a < b</code>`},
|
||||
{name: "tag spacing", source: `< p>text< / p>`},
|
||||
{name: "self closing slash spacing", source: `<p/ >`},
|
||||
{name: "unquoted attribute", source: `<p align=center>text</p>`},
|
||||
{name: "invalid entity", source: `<p>one &unknown;</p>`},
|
||||
{name: "invalid attribute entity", source: `<img href="https://example.com/&unknown;"/>`},
|
||||
{name: "missing required ancestor", source: `<td>cell</td>`},
|
||||
{name: "missing required attribute", source: `<img/>`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := Parse(tt.source, FormatXML); err == nil {
|
||||
t.Fatalf("Parse(%q) succeeded, want validation error", tt.source)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutoDetectsXMLAndMarkdown(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
blocks int
|
||||
}{
|
||||
{name: "xml", source: `<title>T</title><p>P</p>`, blocks: 2},
|
||||
{name: "markdown", source: "# T\n\nP", blocks: 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile, err := ParseAuto(tt.source)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuto() error = %v", err)
|
||||
}
|
||||
if profile.BlockCount != tt.blocks {
|
||||
t.Fatalf("profile = %+v, want %d blocks", profile, tt.blocks)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutoDoesNotTreatMalformedXMLAsMarkdown(t *testing.T) {
|
||||
if _, err := ParseAuto(`<p>text`); err == nil {
|
||||
t.Fatal("ParseAuto() succeeded, want malformed XML error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLAcceptsPublicTagAliasesWithoutChangingInput(t *testing.T) {
|
||||
source := `<P>one<strong>two</strong><br></P><image href="https://example.com/image.png">`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
if result.Profile.BlockCount != 2 {
|
||||
t.Fatalf("profile = %+v, want p and img blocks", result.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLAcceptsPublicAttributeAliasesWithoutChangingInput(t *testing.T) {
|
||||
source := `<callout color="blue" icon="💡"><p>x</p></callout><at id="ou_legacy"></at><img url="https://example.com/image.png"/>`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
if result.Profile.BlockCount != 3 {
|
||||
t.Fatalf("profile = %+v, want callout, p, and img blocks", result.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLAcceptsBareAmpersandsInAttributes(t *testing.T) {
|
||||
source := `<block_insert><parameter><block_id>-1</block_id><content><img href="https://picsum.photos/320/200?seed=lark-cli&raw=1"/></content></parameter></block_insert>`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeXMLAttributeAmpersandsPreservesEntityReferences(t *testing.T) {
|
||||
source := `https://example.com?a=1&b=2&c=3&d=4&e=5&unknown;`
|
||||
want := `https://example.com?a=1&b=2&c=3&d=4&e=5&unknown;`
|
||||
if got := normalizeXMLAttributeAmpersands(source); got != want {
|
||||
t.Fatalf("normalizeXMLAttributeAmpersands() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLPreservesValidCDATA(t *testing.T) {
|
||||
source := `<code><![CDATA[a < b && c > d]]></code>`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLPreservesUTF8BOM(t *testing.T) {
|
||||
source := "\uFEFF<p>text</p>"
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original input", result.XML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownConvertsLarkOpenCLIBlocks(t *testing.T) {
|
||||
source := "# 标题\n\nHello **world**.\n\n- [x] Done\n- [ ] Todo\n\n" +
|
||||
"| A | B |\n| --- | --- |\n| 1 | 2 |\n\n" +
|
||||
"```go\nfmt.Println(\"x\")\n```\n\n$E=mc^2$\n"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`<h1>标题</h1>`,
|
||||
`<p>Hello <b>world</b>.</p>`,
|
||||
`<checkbox done="true">Done</checkbox>`,
|
||||
`<checkbox done="false">Todo</checkbox>`,
|
||||
`<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>`,
|
||||
`<pre lang="go"><code>fmt.Println("x")</code></pre>`,
|
||||
`<p><latex>E=mc^2</latex></p>`,
|
||||
} {
|
||||
if !strings.Contains(result.XML, fragment) {
|
||||
t.Errorf("XML missing %q:\n%s", fragment, result.XML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownPreservesLineBreakSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "soft breaks become spaces",
|
||||
source: "**文号:桂汛旱指〔2026〕17号**\n**签发人:XXX**\n**发布日期:2026年7月13日**",
|
||||
want: `<p><b>文号:桂汛旱指〔2026〕17号</b> <b>签发人:XXX</b> <b>发布日期:2026年7月13日</b></p>`,
|
||||
},
|
||||
{
|
||||
name: "hard breaks remain line breaks",
|
||||
source: "**文号:A** \n**签发人:B**",
|
||||
want: `<p><b>文号:A</b><br/><b>签发人:B</b></p>`,
|
||||
},
|
||||
{
|
||||
name: "blank lines remain paragraph breaks",
|
||||
source: "**文号:A**\n\n**签发人:B**",
|
||||
want: `<p><b>文号:A</b></p><p><b>签发人:B</b></p>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Parse(tt.source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != tt.want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownContainerKeepsMarkdownChildren(t *testing.T) {
|
||||
source := "<callout emoji=\"💡\">\n\n## Note\n\n- item\n\n</callout>\n"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<callout emoji="💡"><h2>Note</h2><ul><li>item</li></ul></callout>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownMatchesLarkOpenCLIFixtures(t *testing.T) {
|
||||
t.Run("deep nested list", func(t *testing.T) {
|
||||
result, err := Parse("1. 第一层\n - 第二层\n - 第三层\n - 第四层\n", FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if strings.Contains(result.XML, "<pre>") || strings.Contains(result.XML, "<code>") || !strings.Contains(result.XML, "第四层") {
|
||||
t.Fatalf("nested list converted incorrectly: %s", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fenced mermaid", func(t *testing.T) {
|
||||
result, err := Parse("```mermaid\nflowchart LR\nA-->B\n```", FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<whiteboard type="mermaid">flowchart LR<br/>A-->B</whiteboard>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw whiteboard source", func(t *testing.T) {
|
||||
source := "<whiteboard type=\"mermaid\">\nflowchart LR\n A --> B\n</whiteboard>"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<whiteboard type="mermaid">flowchart LR<br/> A --> B</whiteboard>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw code stays literal", func(t *testing.T) {
|
||||
source := "<code lang=\"go\">\nif a < b && c > d {\n fmt.Println(\"**raw**\")\n}\n</code>"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<code lang="go">if a < b && c > d {<br/> fmt.Println("**raw**")<br/>}</code>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("underscore tags", func(t *testing.T) {
|
||||
result, err := Parse(`text <synced_reference src-block-id="abc" src-token="def"/> more`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(result.XML, `<synced_reference`) || strings.Contains(result.XML, `<synced_reference`) {
|
||||
t.Fatalf("underscore tag was not preserved: %s", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canonical user cite", func(t *testing.T) {
|
||||
result, err := Parse(`hello <cite type="user" user-id="ou_user"></cite>`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`<cite`, `type="user"`, `user-id="ou_user"`} {
|
||||
if !strings.Contains(result.XML, want) {
|
||||
t.Errorf("XML missing %q: %s", want, result.XML)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public tag alias converts to canonical XML", func(t *testing.T) {
|
||||
result, err := Parse(`hello <strong>world</strong>`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<p>hello <b>world</b></p>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public cite alias converts attributes", func(t *testing.T) {
|
||||
result, err := Parse(`hello <at id="ou_legacy"></at>`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<p>hello <cite type="user" user-id="ou_legacy"></cite></p>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("markdown backslash escapes", func(t *testing.T) {
|
||||
result, err := Parse(`"source\_token": \[abc\] path\\to`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`source_token`, `[abc]`, `path\to`} {
|
||||
if !strings.Contains(result.XML, want) {
|
||||
t.Errorf("XML missing %q: %s", want, result.XML)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adjacent CJK emphasis", func(t *testing.T) {
|
||||
source := `***你好。***S 和 ~~再见。~~T。**agent team 做 brownfield 项目,带来的感知会强烈得多**——前提。**这个时刻,才是真正属于 agent team 的"闪光时刻"。**翟霖`
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<em><b>你好。</b></em>S`,
|
||||
`<del>再见。</del>T`,
|
||||
`<b>agent team 做 brownfield 项目,带来的感知会强烈得多</b>`,
|
||||
`<b>这个时刻,才是真正属于 agent team 的"闪光时刻"。</b>翟霖`,
|
||||
} {
|
||||
if !strings.Contains(result.XML, want) {
|
||||
t.Errorf("XML missing %q: %s", want, result.XML)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("div parses markdown children", func(t *testing.T) {
|
||||
result, err := Parse("<div>\n\n**bold**\n\n</div>", FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<div><p><b>bold</b></p></div>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPreprocessCJKAdjacentMarkupUsesRuneOffsetsAfterRawBlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lineEnding string
|
||||
final string
|
||||
}{
|
||||
{name: "EOF", lineEnding: "\n"},
|
||||
{name: "LF", lineEnding: "\n", final: "\n"},
|
||||
{name: "CRLF", lineEnding: "\r\n", final: "\r\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
source := "<code>**raw**" + tt.lineEnding + "Ⱥ</code>**你好。**S" + tt.final
|
||||
want := "<code>**raw**" + tt.lineEnding + "Ⱥ</code><b>你好。</b>S" + tt.final
|
||||
if got := preprocessCJKAdjacentMarkup(source); got != want {
|
||||
t.Fatalf("preprocessCJKAdjacentMarkup() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextProfileMatchesLarkOpenCLIContract(t *testing.T) {
|
||||
result, err := Parse(`<title>标题</title><p>一个苹果是 an apple。</p>`, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
profile := result.Profile
|
||||
if profile.WordCount != 10 || profile.CharCount != 15 {
|
||||
t.Fatalf("profile = %+v, want word_count=10 char_count=15", profile)
|
||||
}
|
||||
if profile.Breakdown.HanChars != 7 || profile.Breakdown.EnglishWords != 2 || profile.Breakdown.ChinesePunctuations != 1 {
|
||||
t.Fatalf("breakdown = %+v", profile.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextProfileMatchesAuthoringCounterCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
words int
|
||||
chars int
|
||||
blocks int
|
||||
english int
|
||||
numbers int
|
||||
han int
|
||||
listItems int
|
||||
}{
|
||||
{
|
||||
name: "english number and punctuation",
|
||||
source: `<p>Hello world 123.45。</p>`,
|
||||
words: 4, chars: 17, blocks: 1, english: 2, numbers: 1,
|
||||
},
|
||||
{
|
||||
name: "list and checkbox markers",
|
||||
source: `<ul><li>甲</li><li>two</li></ul><checkbox done="true">完成</checkbox>`,
|
||||
words: 7, chars: 9, blocks: 4, english: 1, han: 3, listItems: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Parse(tt.source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
profile := result.Profile
|
||||
if profile.WordCount != tt.words || profile.CharCount != tt.chars || profile.BlockCount != tt.blocks {
|
||||
t.Fatalf("profile = %+v, want words=%d chars=%d blocks=%d", profile, tt.words, tt.chars, tt.blocks)
|
||||
}
|
||||
if profile.Breakdown.EnglishWords != tt.english || profile.Breakdown.NumberWords != tt.numbers || profile.Breakdown.HanChars != tt.han {
|
||||
t.Fatalf("breakdown = %+v", profile.Breakdown)
|
||||
}
|
||||
if got := blockCountForTest(profile.Blocks, "li"); got != tt.listItems {
|
||||
t.Fatalf("li count = %d, want %d", got, tt.listItems)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextProfileUsesVisibleAttributeFallbacks(t *testing.T) {
|
||||
result, err := Parse(`<p text="Hello"/><p><span title="world"/></p><img href="https://example.com/image.png" caption="图"/>`, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
profile := result.Profile
|
||||
if profile.WordCount != 3 || profile.CharCount != 11 {
|
||||
t.Fatalf("profile = %+v, want word_count=3 char_count=11", profile)
|
||||
}
|
||||
if profile.Breakdown.EnglishWords != 2 || profile.Breakdown.HanChars != 1 {
|
||||
t.Fatalf("breakdown = %+v", profile.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsUnsafeXMLDeclarations(t *testing.T) {
|
||||
_, err := Parse(`<!DOCTYPE foo [<!ENTITY x "value">]><p>&x;</p>`, FormatXML)
|
||||
if err == nil || !strings.Contains(err.Error(), "DOCTYPE or ENTITY") {
|
||||
t.Fatalf("Parse() error = %v, want unsafe declaration rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsInvalidUTF8(t *testing.T) {
|
||||
_, err := Parse(string([]byte{'<', 'p', '>', 0xff, '<', '/', 'p', '>'}), FormatXML)
|
||||
if err == nil || !strings.Contains(err.Error(), "valid UTF-8") {
|
||||
t.Fatalf("Parse() error = %v, want UTF-8 rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsExcessiveNesting(t *testing.T) {
|
||||
source := strings.Repeat("<span>", MaxNestingDepth+1)
|
||||
_, err := Parse(source, FormatXML)
|
||||
if err == nil || !strings.Contains(err.Error(), "nesting exceeds") {
|
||||
t.Fatalf("Parse() error = %v, want nesting limit rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLRejectsNestedInvalidTagStarts(t *testing.T) {
|
||||
if _, err := Parse(`<<<<p>text</p>`, FormatXML); err == nil {
|
||||
t.Fatal("Parse() succeeded, want invalid XML token error")
|
||||
}
|
||||
}
|
||||
|
||||
func blockCountForTest(blocks []BlockShare, typ string) int {
|
||||
for _, block := range blocks {
|
||||
if block.Type == typ {
|
||||
return block.Count
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Profile describes LarkOpenCLI document structure and visible text without
|
||||
// requiring callers to inspect the full XML.
|
||||
type Profile struct {
|
||||
WordCount int `json:"word_count"`
|
||||
CharCount int `json:"char_count"`
|
||||
Breakdown TextBreakdown `json:"breakdown"`
|
||||
BlockCount int `json:"block_count"`
|
||||
Blocks []BlockShare `json:"blocks"`
|
||||
}
|
||||
|
||||
// BlockShare reports one LarkOpenCLI block type's count and share. Structural
|
||||
// and inline-only tags are intentionally excluded.
|
||||
type BlockShare struct {
|
||||
Type string `json:"type"`
|
||||
Count int `json:"count"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
}
|
||||
|
||||
// TextProfile is the internal result of the LarkOpenCLI semantic counter.
|
||||
type TextProfile struct {
|
||||
WordCount int `json:"word_count"`
|
||||
CharCount int `json:"char_count"`
|
||||
Breakdown TextBreakdown `json:"breakdown"`
|
||||
}
|
||||
|
||||
type TextBreakdown struct {
|
||||
HanChars int `json:"han_chars"`
|
||||
EnglishWords int `json:"english_words"`
|
||||
NumberWords int `json:"number_words"`
|
||||
ChinesePunctuations int `json:"chinese_punctuations"`
|
||||
EnglishLetters int `json:"english_letters"`
|
||||
Digits int `json:"digits"`
|
||||
EnglishPunctuations int `json:"english_punctuations"`
|
||||
SymbolWords int `json:"symbol_words"`
|
||||
SymbolChars int `json:"symbol_chars"`
|
||||
}
|
||||
|
||||
// Parse validates XML or converts Markdown to DocxXML, then builds its
|
||||
// structure and visible-text profile.
|
||||
func Parse(source string, format Format) (ParseResult, error) {
|
||||
var (
|
||||
nodes []*Node
|
||||
outputXML string
|
||||
err error
|
||||
)
|
||||
switch format {
|
||||
case FormatXML:
|
||||
nodes, err = parseXML(source)
|
||||
outputXML = source
|
||||
case FormatMarkdown:
|
||||
nodes, err = parseMarkdown(source)
|
||||
default:
|
||||
return ParseResult{}, fmt.Errorf("unsupported input format %q", format)
|
||||
}
|
||||
if err != nil {
|
||||
return ParseResult{}, err
|
||||
}
|
||||
if err := validateStructure(nodes); err != nil {
|
||||
return ParseResult{}, err
|
||||
}
|
||||
if format == FormatMarkdown {
|
||||
outputXML = renderNodes(nodes)
|
||||
}
|
||||
|
||||
return ParseResult{
|
||||
Format: format,
|
||||
XML: outputXML,
|
||||
Profile: buildProfile(nodes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseAuto detects XML versus Markdown from the content and returns only the
|
||||
// document profile. XML-like input is parsed strictly; all other input is
|
||||
// interpreted as Markdown.
|
||||
func ParseAuto(source string) (Profile, error) {
|
||||
result, err := Parse(source, detectFormat(source))
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
return result.Profile, nil
|
||||
}
|
||||
|
||||
// MarkdownToXML converts Markdown to canonical LarkOpenCLI XML.
|
||||
func MarkdownToXML(source string) (string, error) {
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.XML, nil
|
||||
}
|
||||
|
||||
func detectFormat(source string) Format {
|
||||
trimmed := strings.TrimSpace(strings.TrimPrefix(source, "\uFEFF"))
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
return FormatXML
|
||||
}
|
||||
return FormatMarkdown
|
||||
}
|
||||
|
||||
func validateStructure(nodes []*Node) error {
|
||||
type frame struct {
|
||||
node *Node
|
||||
exit bool
|
||||
}
|
||||
frames := make([]frame, 0, len(nodes))
|
||||
for i := len(nodes) - 1; i >= 0; i-- {
|
||||
frames = append(frames, frame{node: nodes[i]})
|
||||
}
|
||||
ancestors := map[string]int{}
|
||||
depth := 0
|
||||
for len(frames) > 0 {
|
||||
current := frames[len(frames)-1]
|
||||
frames = frames[:len(frames)-1]
|
||||
node := current.node
|
||||
if node == nil || node.typ != nodeElement {
|
||||
continue
|
||||
}
|
||||
if current.exit {
|
||||
ancestors[node.tag]--
|
||||
depth--
|
||||
continue
|
||||
}
|
||||
if depth >= MaxNestingDepth {
|
||||
return fmt.Errorf("document nesting exceeds limit %d at <%s>", MaxNestingDepth, node.tag)
|
||||
}
|
||||
if err := validateRequiredAttributes(node); err != nil {
|
||||
return err
|
||||
}
|
||||
if required := requiredAncestorTags[node.tag]; len(required) > 0 {
|
||||
matched := false
|
||||
for tag := range required {
|
||||
if ancestors[tag] > 0 {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
allowed := make([]string, 0, len(required))
|
||||
for tag := range required {
|
||||
allowed = append(allowed, tag)
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
return fmt.Errorf("LarkOpenCLI tag <%s> requires an ancestor in [%s]", node.tag, strings.Join(allowed, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
ancestors[node.tag]++
|
||||
depth++
|
||||
frames = append(frames, frame{node: node, exit: true})
|
||||
for i := len(node.children) - 1; i >= 0; i-- {
|
||||
frames = append(frames, frame{node: node.children[i]})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRequiredAttributes(node *Node) error {
|
||||
for _, attr := range requiredAttributes[node.tag] {
|
||||
if strings.TrimSpace(node.attrs[attr]) == "" {
|
||||
return fmt.Errorf("LarkOpenCLI tag <%s> requires attribute %q", node.tag, attr)
|
||||
}
|
||||
}
|
||||
for _, alternatives := range requiredAnyAttributes[node.tag] {
|
||||
matched := false
|
||||
for _, attr := range alternatives {
|
||||
if strings.TrimSpace(node.attrs[attr]) != "" {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("LarkOpenCLI tag <%s> requires one of attributes [%s]", node.tag, strings.Join(alternatives, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildProfile(nodes []*Node) Profile {
|
||||
counts := map[string]int{}
|
||||
total := 0
|
||||
var walk func(*Node)
|
||||
walk = func(node *Node) {
|
||||
if node == nil || node.typ != nodeElement {
|
||||
return
|
||||
}
|
||||
layout := layoutOf(node.tag)
|
||||
isBlock := layout == layoutBlock || layout == layoutDual && node.parent == nil
|
||||
if isBlock {
|
||||
counts[node.tag]++
|
||||
total++
|
||||
}
|
||||
for _, child := range node.children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
for _, node := range nodes {
|
||||
walk(node)
|
||||
}
|
||||
|
||||
distribution := make([]BlockShare, 0, len(counts))
|
||||
for typ, count := range counts {
|
||||
ratio := 0.0
|
||||
if total > 0 {
|
||||
ratio = math.Round(float64(count)/float64(total)*1_000_000) / 1_000_000
|
||||
}
|
||||
distribution = append(distribution, BlockShare{Type: typ, Count: count, Ratio: ratio})
|
||||
}
|
||||
sort.Slice(distribution, func(i, j int) bool {
|
||||
if distribution[i].Count != distribution[j].Count {
|
||||
return distribution[i].Count > distribution[j].Count
|
||||
}
|
||||
return distribution[i].Type < distribution[j].Type
|
||||
})
|
||||
segments := extractSegments(nodes)
|
||||
stats := newTextCounter().countSegments(segments)
|
||||
return Profile{
|
||||
WordCount: stats.WordCount,
|
||||
CharCount: stats.CharCount,
|
||||
Breakdown: stats.Breakdown,
|
||||
BlockCount: total,
|
||||
Blocks: distribution,
|
||||
}
|
||||
}
|
||||
|
||||
type segmentKind uint8
|
||||
|
||||
const (
|
||||
segmentText segmentKind = iota
|
||||
segmentMarker
|
||||
segmentCode
|
||||
)
|
||||
|
||||
type textSegment struct {
|
||||
text string
|
||||
kind segmentKind
|
||||
}
|
||||
|
||||
var ignoredResourceTags = map[string]bool{
|
||||
"whiteboard": true, "sheet": true, "source": true, "chat_card": true,
|
||||
"base_refer": true, "bitable": true, "synced_reference": true,
|
||||
"poll": true, "isv": true, "mindnote": true, "sub-page-list": true,
|
||||
"okr": true, "html5-block": true,
|
||||
}
|
||||
|
||||
var ignoredInlineTags = map[string]bool{
|
||||
"button": true, "cite": true, "latex": true, "bookmark": true,
|
||||
}
|
||||
|
||||
func extractSegments(nodes []*Node) []textSegment {
|
||||
var segments []textSegment
|
||||
for _, node := range nodes {
|
||||
extractNodeSegments(node, &segments)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func extractNodeSegments(node *Node, segments *[]textSegment) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.typ == nodeText {
|
||||
if strings.TrimSpace(node.text) != "" {
|
||||
*segments = append(*segments, textSegment{text: node.text})
|
||||
}
|
||||
return
|
||||
}
|
||||
if ignoredInlineTags[node.tag] || ignoredResourceTags[node.tag] {
|
||||
return
|
||||
}
|
||||
if node.tag == "task" {
|
||||
return
|
||||
}
|
||||
if node.tag == "synced-source" && len(node.children) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
switch node.tag {
|
||||
case "ul", "ol":
|
||||
sequence := 1
|
||||
for _, child := range node.children {
|
||||
if child.typ == nodeElement && child.tag == "li" {
|
||||
if node.tag == "ul" {
|
||||
*segments = append(*segments, textSegment{text: "•", kind: segmentMarker})
|
||||
} else {
|
||||
marker := sequence
|
||||
if raw := child.attrs["seq"]; raw != "" {
|
||||
if _, err := fmt.Sscanf(raw, "%d", &marker); err == nil {
|
||||
sequence = marker
|
||||
}
|
||||
}
|
||||
*segments = append(*segments, textSegment{text: fmt.Sprintf("%d.", marker)})
|
||||
sequence++
|
||||
}
|
||||
}
|
||||
extractNodeSegments(child, segments)
|
||||
}
|
||||
return
|
||||
case "checkbox":
|
||||
marker := "☐"
|
||||
if node.attrs["done"] == "true" {
|
||||
marker = "☑"
|
||||
}
|
||||
*segments = append(*segments, textSegment{text: marker, kind: segmentMarker})
|
||||
}
|
||||
|
||||
kind := segmentText
|
||||
if node.tag == "pre" || node.tag == "code" && (node.parent == nil || node.parent.tag != "p") {
|
||||
kind = segmentCode
|
||||
}
|
||||
text := visibleInlineText(node)
|
||||
if strings.TrimSpace(text) == "" && !hasBlockChildren(node) {
|
||||
if node.tag == "img" {
|
||||
text = node.attrs["caption"]
|
||||
} else {
|
||||
text = firstNonEmpty(node.attrs["text"], node.attrs["name"], node.attrs["title"], node.attrs["alt"], node.attrs["caption"])
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(text) != "" {
|
||||
*segments = append(*segments, textSegment{text: text, kind: kind})
|
||||
}
|
||||
|
||||
for _, child := range node.children {
|
||||
if child.typ != nodeElement || isInlineForExtraction(child.tag) {
|
||||
continue
|
||||
}
|
||||
extractNodeSegments(child, segments)
|
||||
}
|
||||
}
|
||||
|
||||
func visibleInlineText(node *Node) string {
|
||||
var out strings.Builder
|
||||
var walk func(*Node)
|
||||
walk = func(current *Node) {
|
||||
if current.typ == nodeText {
|
||||
out.WriteString(current.text)
|
||||
return
|
||||
}
|
||||
if current != node && !isInlineForExtraction(current.tag) {
|
||||
return
|
||||
}
|
||||
if ignoredInlineTags[current.tag] {
|
||||
return
|
||||
}
|
||||
if current.tag == "br" {
|
||||
out.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
if current != node {
|
||||
if display := firstNonEmpty(current.attrs["text"], current.attrs["name"], current.attrs["title"], current.attrs["alt"]); display != "" {
|
||||
out.WriteString(display)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, child := range current.children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
for _, child := range node.children {
|
||||
walk(child)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func hasBlockChildren(node *Node) bool {
|
||||
for _, child := range node.children {
|
||||
if child.typ == nodeElement && !isInlineForExtraction(child.tag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isInlineForExtraction(tag string) bool {
|
||||
layout := layoutOf(tag)
|
||||
return layout == layoutInline || layout == layoutDual
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type tagLayout string
|
||||
|
||||
const (
|
||||
layoutBlock tagLayout = "block"
|
||||
layoutInline tagLayout = "inline"
|
||||
layoutDual tagLayout = "dual"
|
||||
layoutStructural tagLayout = "structural"
|
||||
layoutCommand tagLayout = "command"
|
||||
)
|
||||
|
||||
type tagSpec struct {
|
||||
canonical string
|
||||
layout tagLayout
|
||||
}
|
||||
|
||||
var tagSpecs = map[string]tagSpec{}
|
||||
|
||||
// tagAliases mirrors the public compatibility aliases declared by the
|
||||
// LarkOpenCLI SDK. Parsing keeps the caller's XML unchanged; aliases are only
|
||||
// canonicalized in the in-memory tree used for profiling and Markdown output.
|
||||
var tagAliases = map[string]string{
|
||||
"strong": "b",
|
||||
"text": "span",
|
||||
"equation": "latex",
|
||||
"lark-table": "table",
|
||||
"lark-tr": "tr",
|
||||
"lark-td": "td",
|
||||
"image": "img",
|
||||
"reference-synced": "synced_reference",
|
||||
"source-synced": "synced-source",
|
||||
"at": "cite",
|
||||
"chat-card": "chat_card",
|
||||
"folder_manager": "folder-manager",
|
||||
}
|
||||
|
||||
type attributeAliasRule struct {
|
||||
canonical string
|
||||
transform func(string) (string, bool)
|
||||
}
|
||||
|
||||
var commonAttributeAliases = map[string]attributeAliasRule{
|
||||
"color": {canonical: "text-color"},
|
||||
"textcolor": {canonical: "text-color"},
|
||||
"text_color": {canonical: "text-color"},
|
||||
"bgcolor": {canonical: "background-color"},
|
||||
"background_color": {canonical: "background-color"},
|
||||
}
|
||||
|
||||
var tagAttributeAliases = map[string]map[string]attributeAliasRule{
|
||||
"img": {
|
||||
"url": {canonical: "href"},
|
||||
"file_key": {canonical: "img_key"},
|
||||
},
|
||||
"callout": {
|
||||
"color": {canonical: "background-color"},
|
||||
"icon": {canonical: "emoji"},
|
||||
},
|
||||
"column": {
|
||||
"width": {canonical: "width-ratio", transform: normalizeWidthRatio},
|
||||
},
|
||||
"chat_card": {
|
||||
"id": {canonical: "chat-id", transform: requireChatID},
|
||||
},
|
||||
"cite": {
|
||||
"user_id": {canonical: "user-id"},
|
||||
},
|
||||
}
|
||||
|
||||
var rawTagAttributeAliases = map[string]map[string]attributeAliasRule{
|
||||
"at": {
|
||||
"id": {canonical: "user-id"},
|
||||
"user_id": {canonical: "user-id"},
|
||||
},
|
||||
}
|
||||
|
||||
var requiredAttributes = map[string][]string{
|
||||
"task": {"task-id"},
|
||||
}
|
||||
|
||||
var requiredAnyAttributes = map[string][][]string{
|
||||
"img": {{"src", "img_key", "href"}},
|
||||
"whiteboard": {{"token", "type"}},
|
||||
"chat_card": {{"token", "chat-id"}},
|
||||
"bookmark": {{"href", "name"}},
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerTags(layoutBlock,
|
||||
"title", "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "h9", "p",
|
||||
"div", "ul", "ol", "li", "blockquote", "grid", "column", "table", "thead",
|
||||
"tbody", "tfoot", "tr", "hr", "pre", "img", "source", "bitable", "sheet",
|
||||
"mindnote", "whiteboard", "base_refer", "synced_reference", "isv", "html5-block",
|
||||
"view", "synced-source", "readonly-block", "figure", "callout", "checkbox",
|
||||
"chat_card", "okr", "okr-objective", "okr-key-result", "okr-progress", "poll",
|
||||
"agenda", "folder-manager", "sub-page-list", "wiki_catalog", "wiki_recent_update",
|
||||
"chart-embedded", "chart-refer-host-perm", "chart_embedded", "chart_refer_host_perm",
|
||||
"bookmark", "task", "vc-tabs", "vc-summary-tab", "vc-transcribe-tab", "append",
|
||||
)
|
||||
registerTags(layoutInline, "b", "em", "u", "del", "i", "span", "br", "inline-file", "mention-date", "cite", "button", "time", "a")
|
||||
registerTags(layoutDual, "latex", "code")
|
||||
registerTags(layoutStructural, "th", "td", "colgroup", "col", "sub-page")
|
||||
registerTags(layoutCommand,
|
||||
"comment", "block_delete", "str_delete", "str_replace", "block_replace", "block_insert",
|
||||
"block_move", "block_copy_insert_after", "src_block_ids", "create", "answer", "response",
|
||||
"identifier", "genre", "anchor", "type", "revision", "pattern", "replacement",
|
||||
"replace_content", "action", "content", "parameter", "generation", "block_id",
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func registerTags(layout tagLayout, tags ...string) {
|
||||
for _, tag := range tags {
|
||||
tagSpecs[tag] = tagSpec{canonical: tag, layout: layout}
|
||||
}
|
||||
}
|
||||
|
||||
func lookupTag(raw string) (tagSpec, bool) {
|
||||
key := strings.ToLower(strings.TrimSpace(raw))
|
||||
if canonical, ok := tagAliases[key]; ok {
|
||||
key = canonical
|
||||
}
|
||||
spec, ok := tagSpecs[key]
|
||||
if !ok {
|
||||
return tagSpec{}, false
|
||||
}
|
||||
return spec, true
|
||||
}
|
||||
|
||||
func layoutOf(tag string) tagLayout {
|
||||
spec, ok := lookupTag(tag)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return spec.layout
|
||||
}
|
||||
|
||||
var voidTags = map[string]bool{
|
||||
"br": true,
|
||||
"col": true,
|
||||
"hr": true,
|
||||
"img": true,
|
||||
"source": true,
|
||||
"sub-page": true,
|
||||
}
|
||||
|
||||
func isVoidTag(tag string) bool { return voidTags[tag] }
|
||||
|
||||
var preserveSpaceTags = map[string]bool{
|
||||
"title": true, "h1": true, "h2": true, "h3": true, "h4": true,
|
||||
"h5": true, "h6": true, "h7": true, "h8": true, "h9": true,
|
||||
"p": true, "i": true, "b": true, "em": true, "u": true, "del": true,
|
||||
"code": true, "li": true, "a": true, "span": true,
|
||||
}
|
||||
|
||||
var strictPhrasingTags = map[string]bool{
|
||||
"title": true, "span": true, "b": true, "em": true,
|
||||
"u": true, "del": true, "a": true,
|
||||
}
|
||||
|
||||
var autoCloseTags = map[string]map[string]bool{
|
||||
"li": {"li": true},
|
||||
"tr": {"tr": true},
|
||||
"td": {"td": true, "th": true, "tr": true, "tbody": true, "tfoot": true},
|
||||
"th": {"th": true, "td": true, "tr": true, "tbody": true, "tfoot": true},
|
||||
"tbody": {"tbody": true, "tfoot": true},
|
||||
"thead": {"tbody": true, "tfoot": true},
|
||||
"column": {"column": true},
|
||||
}
|
||||
|
||||
var requiredAncestorTags = map[string]map[string]bool{
|
||||
"column": {"grid": true},
|
||||
"thead": {"table": true},
|
||||
"tbody": {"table": true},
|
||||
"tfoot": {"table": true},
|
||||
"tr": {"table": true, "thead": true, "tbody": true, "tfoot": true},
|
||||
"th": {"tr": true},
|
||||
"td": {"tr": true},
|
||||
"colgroup": {"table": true},
|
||||
"col": {"table": true, "colgroup": true},
|
||||
"okr-objective": {"okr": true},
|
||||
"okr-key-result": {"okr": true, "okr-objective": true},
|
||||
"okr-progress": {"okr-objective": true, "okr-key-result": true},
|
||||
"sub-page": {"sub-page-list": true},
|
||||
}
|
||||
|
||||
func shouldAutoClose(openTag, nextTag string) bool {
|
||||
if strictPhrasingTags[openTag] && layoutOf(nextTag) == layoutBlock {
|
||||
return true
|
||||
}
|
||||
return autoCloseTags[openTag] != nil && autoCloseTags[openTag][nextTag]
|
||||
}
|
||||
|
||||
func normalizeAttributes(rawTag, canonical string, attrs map[string]string) map[string]string {
|
||||
rules := make(map[string]attributeAliasRule, len(commonAttributeAliases)+4)
|
||||
for alias, rule := range commonAttributeAliases {
|
||||
rules[alias] = rule
|
||||
}
|
||||
for alias, rule := range tagAttributeAliases[canonical] {
|
||||
rules[alias] = rule
|
||||
}
|
||||
rawKey := strings.ToLower(strings.TrimSpace(rawTag))
|
||||
for alias, rule := range rawTagAttributeAliases[rawKey] {
|
||||
rules[alias] = rule
|
||||
}
|
||||
|
||||
aliases := make([]string, 0, len(rules))
|
||||
for alias := range rules {
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
sort.Strings(aliases)
|
||||
for _, alias := range aliases {
|
||||
value, exists := attrs[alias]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
rule := rules[alias]
|
||||
if rule.transform != nil {
|
||||
var ok bool
|
||||
value, ok = rule.transform(value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if canonicalValue, exists := attrs[rule.canonical]; !exists || strings.TrimSpace(canonicalValue) == "" {
|
||||
if attrs == nil {
|
||||
attrs = map[string]string{}
|
||||
}
|
||||
attrs[rule.canonical] = value
|
||||
}
|
||||
delete(attrs, alias)
|
||||
}
|
||||
|
||||
if rawKey == "at" {
|
||||
if attrs == nil {
|
||||
attrs = map[string]string{}
|
||||
}
|
||||
attrs["type"] = "user"
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func normalizeWidthRatio(value string) (string, bool) {
|
||||
trimmed := strings.TrimSuffix(strings.TrimSpace(value), "%")
|
||||
if trimmed == "" {
|
||||
return value, false
|
||||
}
|
||||
width, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return value, false
|
||||
}
|
||||
return strconv.FormatFloat(width/100, 'f', 6, 64), true
|
||||
}
|
||||
|
||||
func requireChatID(value string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
return trimmed, strings.HasPrefix(trimmed, "oc_")
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
// This file implements the LarkOpenCLI document text-counting contract.
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/width"
|
||||
)
|
||||
|
||||
const chinesePunctuation = ",。!?;:、()《》〈〉“”‘’【】「」『』〔〕…—~·¥"
|
||||
const englishPunctuation = `!"#$%&'()*+,-./:;<=>?@[\]^_` + "`" + `{|}~`
|
||||
|
||||
var (
|
||||
urlToken = regexp.MustCompile(`^https?://[!-~]+`)
|
||||
asciiCompoundToken = regexp.MustCompile(`^[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+`)
|
||||
)
|
||||
|
||||
type lexemeKind uint8
|
||||
|
||||
const (
|
||||
lexemeNone lexemeKind = iota
|
||||
lexemeEnglish
|
||||
lexemeNumber
|
||||
)
|
||||
|
||||
type textCounter struct {
|
||||
stats TextProfile
|
||||
lexeme lexemeKind
|
||||
lexemeHasDigit bool
|
||||
symbolRunLength int
|
||||
atBoundary bool
|
||||
}
|
||||
|
||||
func newTextCounter() *textCounter {
|
||||
return &textCounter{atBoundary: true}
|
||||
}
|
||||
|
||||
func (c *textCounter) countSegments(segments []textSegment) TextProfile {
|
||||
for _, segment := range segments {
|
||||
c.endUnit()
|
||||
c.atBoundary = true
|
||||
switch segment.kind {
|
||||
case segmentMarker:
|
||||
c.writeMarker(segment.text)
|
||||
case segmentCode:
|
||||
c.writeCode(segment.text)
|
||||
default:
|
||||
c.write(segment.text)
|
||||
}
|
||||
c.endUnit()
|
||||
c.atBoundary = true
|
||||
}
|
||||
c.endUnit()
|
||||
return c.stats
|
||||
}
|
||||
|
||||
func (c *textCounter) write(value string) {
|
||||
for offset := 0; offset < len(value); {
|
||||
if token := matchASCIICompound(value[offset:]); token != "" {
|
||||
c.writeASCIICompound(token)
|
||||
offset += len(token)
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(value[offset:])
|
||||
if r == '/' && isVisibleHanSeparator(value, offset, size) {
|
||||
c.endUnit()
|
||||
c.stats.Breakdown.EnglishPunctuations++
|
||||
c.stats.Breakdown.SymbolWords++
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
offset += size
|
||||
continue
|
||||
}
|
||||
c.writeRune(r)
|
||||
offset += size
|
||||
}
|
||||
}
|
||||
|
||||
func (c *textCounter) writeMarker(value string) {
|
||||
for _, r := range value {
|
||||
if unicode.IsSpace(r) {
|
||||
continue
|
||||
}
|
||||
c.endUnit()
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *textCounter) writeCode(value string) {
|
||||
for _, r := range value {
|
||||
c.writeCodeRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *textCounter) writeCodeRune(r rune) {
|
||||
if unicode.IsSpace(r) {
|
||||
c.endUnit()
|
||||
c.atBoundary = true
|
||||
return
|
||||
}
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.HanChars++
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isASCIILetterRune(r) {
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.EnglishLetters++
|
||||
c.stats.CharCount++
|
||||
if c.lexeme == lexemeNone || c.lexeme == lexemeNumber {
|
||||
c.lexeme = lexemeEnglish
|
||||
}
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isASCIIDigitRune(r) {
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.Digits++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isChinesePunctuation(r) {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.ChinesePunctuations++
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isEnglishPunctuation(r) {
|
||||
keepsLexeme := c.lexeme == lexemeEnglish && (r == '\'' || r == '-')
|
||||
if !keepsLexeme {
|
||||
hadLexeme := c.lexeme != lexemeNone
|
||||
c.endLexeme()
|
||||
if !hadLexeme && (c.symbolRunLength > 0 || c.atBoundary) {
|
||||
c.symbolRunLength++
|
||||
}
|
||||
}
|
||||
c.stats.Breakdown.EnglishPunctuations++
|
||||
c.stats.CharCount++
|
||||
if keepsLexeme {
|
||||
c.atBoundary = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if unicode.Is(unicode.Symbol, r) {
|
||||
c.writeSymbol(r)
|
||||
return
|
||||
}
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.atBoundary = false
|
||||
}
|
||||
|
||||
func (c *textCounter) writeRune(r rune) {
|
||||
if unicode.IsSpace(r) {
|
||||
c.endUnit()
|
||||
c.atBoundary = true
|
||||
return
|
||||
}
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.HanChars++
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isASCIILetterRune(r) {
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.EnglishLetters++
|
||||
c.stats.CharCount++
|
||||
if c.lexeme == lexemeNone || c.lexeme == lexemeNumber {
|
||||
c.lexeme = lexemeEnglish
|
||||
}
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isASCIIDigitRune(r) {
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.Digits++
|
||||
c.stats.CharCount++
|
||||
c.lexemeHasDigit = true
|
||||
if c.lexeme == lexemeNone {
|
||||
c.lexeme = lexemeNumber
|
||||
}
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isChinesePunctuation(r) {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.stats.Breakdown.ChinesePunctuations++
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount++
|
||||
c.atBoundary = false
|
||||
return
|
||||
}
|
||||
if isEnglishPunctuation(r) {
|
||||
keepsLexeme := c.lexeme == lexemeEnglish && (r == '\'' || r == '-' || c.lexemeHasDigit && r == '.') ||
|
||||
c.lexeme == lexemeNumber && (r == '.' || r == ',' || r == '-')
|
||||
if !keepsLexeme {
|
||||
hadLexeme := c.lexeme != lexemeNone
|
||||
c.endLexeme()
|
||||
if !hadLexeme && (c.symbolRunLength > 0 || c.atBoundary) {
|
||||
c.symbolRunLength++
|
||||
}
|
||||
}
|
||||
c.stats.Breakdown.EnglishPunctuations++
|
||||
c.stats.CharCount++
|
||||
if keepsLexeme {
|
||||
c.atBoundary = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if unicode.Is(unicode.Symbol, r) {
|
||||
c.writeSymbol(r)
|
||||
return
|
||||
}
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
c.atBoundary = false
|
||||
}
|
||||
|
||||
func matchASCIICompound(value string) string {
|
||||
if match := urlToken.FindString(value); match != "" {
|
||||
return match
|
||||
}
|
||||
match := asciiCompoundToken.FindString(value)
|
||||
if match == "" || !strings.ContainsAny(match, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||
return ""
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
func (c *textCounter) writeASCIICompound(token string) {
|
||||
c.endUnit()
|
||||
c.stats.Breakdown.EnglishWords++
|
||||
c.stats.WordCount++
|
||||
for _, r := range token {
|
||||
switch {
|
||||
case isASCIILetterRune(r):
|
||||
c.stats.Breakdown.EnglishLetters++
|
||||
c.stats.CharCount++
|
||||
case isASCIIDigitRune(r):
|
||||
c.stats.Breakdown.Digits++
|
||||
c.stats.CharCount++
|
||||
case isEnglishPunctuation(r):
|
||||
c.stats.Breakdown.EnglishPunctuations++
|
||||
c.stats.CharCount++
|
||||
}
|
||||
}
|
||||
c.atBoundary = false
|
||||
}
|
||||
|
||||
func (c *textCounter) writeSymbol(r rune) {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(false)
|
||||
units := utf16Units(r)
|
||||
c.stats.Breakdown.SymbolWords++
|
||||
c.stats.Breakdown.SymbolChars += units
|
||||
c.stats.WordCount++
|
||||
c.stats.CharCount += units
|
||||
c.atBoundary = false
|
||||
}
|
||||
|
||||
func (c *textCounter) endUnit() {
|
||||
c.endLexeme()
|
||||
c.endSymbolRun(true)
|
||||
}
|
||||
|
||||
func (c *textCounter) endLexeme() {
|
||||
switch c.lexeme {
|
||||
case lexemeEnglish:
|
||||
c.stats.Breakdown.EnglishWords++
|
||||
c.stats.WordCount++
|
||||
case lexemeNumber:
|
||||
c.stats.Breakdown.NumberWords++
|
||||
c.stats.WordCount++
|
||||
}
|
||||
c.lexeme = lexemeNone
|
||||
c.lexemeHasDigit = false
|
||||
}
|
||||
|
||||
func (c *textCounter) endSymbolRun(countWord bool) {
|
||||
if c.symbolRunLength > 0 && countWord {
|
||||
c.stats.Breakdown.SymbolWords++
|
||||
c.stats.WordCount++
|
||||
}
|
||||
if c.symbolRunLength > 0 {
|
||||
c.atBoundary = false
|
||||
}
|
||||
c.symbolRunLength = 0
|
||||
}
|
||||
|
||||
func isVisibleHanSeparator(value string, offset, size int) bool {
|
||||
if offset == 0 || offset+size >= len(value) {
|
||||
return false
|
||||
}
|
||||
previous, _ := utf8.DecodeLastRuneInString(value[:offset])
|
||||
next, _ := utf8.DecodeRuneInString(value[offset+size:])
|
||||
return unicode.Is(unicode.Han, previous) && unicode.Is(unicode.Han, next)
|
||||
}
|
||||
|
||||
func isASCIILetterRune(r rune) bool { return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' }
|
||||
func isASCIIDigitRune(r rune) bool { return r >= '0' && r <= '9' }
|
||||
|
||||
func isChinesePunctuation(r rune) bool {
|
||||
if strings.ContainsRune(chinesePunctuation, r) {
|
||||
return true
|
||||
}
|
||||
kind := width.LookupRune(r).Kind()
|
||||
return unicode.Is(unicode.Punct, r) && (kind == width.EastAsianWide || kind == width.EastAsianFullwidth)
|
||||
}
|
||||
|
||||
func isEnglishPunctuation(r rune) bool {
|
||||
return r < utf8.RuneSelf && strings.ContainsRune(englishPunctuation, r)
|
||||
}
|
||||
|
||||
func utf16Units(r rune) int {
|
||||
if r > 0xffff {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxInputBytes = 20_000_000
|
||||
MaxNestingDepth = 1024
|
||||
)
|
||||
|
||||
var forbiddenXMLDeclaration = regexp.MustCompile(`(?i)<!\s*(?:DOCTYPE|ENTITY)\b`)
|
||||
|
||||
func validateSource(source string) error {
|
||||
if len(source) > MaxInputBytes {
|
||||
return fmt.Errorf("input is too large (%d bytes, limit %d)", len(source), MaxInputBytes)
|
||||
}
|
||||
if forbiddenXMLDeclaration.MatchString(source) {
|
||||
return fmt.Errorf("XML input must not contain DOCTYPE or ENTITY declarations")
|
||||
}
|
||||
if !utf8.ValidString(source) {
|
||||
return fmt.Errorf("input must be valid UTF-8")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseXML(source string) ([]*Node, error) {
|
||||
if err := validateSource(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source = strings.TrimPrefix(source, "\uFEFF")
|
||||
|
||||
root := newElement("__fragment__", nil)
|
||||
stack := []*Node{root}
|
||||
for i := 0; i < len(source); {
|
||||
lt := strings.IndexByte(source[i:], '<')
|
||||
if lt < 0 {
|
||||
if err := validateXMLText(source[i:], i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendText(stack[len(stack)-1], source[i:])
|
||||
break
|
||||
}
|
||||
lt += i
|
||||
if err := validateXMLText(source[i:lt], i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendText(stack[len(stack)-1], source[i:lt])
|
||||
|
||||
token, end, state := scanXMLToken(source, lt)
|
||||
switch state {
|
||||
case tokenComment, tokenProcessingInstruction:
|
||||
i = end
|
||||
continue
|
||||
case tokenCDATA:
|
||||
appendTextValue(stack[len(stack)-1], token.text)
|
||||
i = end
|
||||
continue
|
||||
case tokenInvalid:
|
||||
return nil, fmt.Errorf("invalid XML token at byte %d", lt)
|
||||
case tokenIncomplete:
|
||||
return nil, fmt.Errorf("unterminated XML tag at byte %d", lt)
|
||||
}
|
||||
|
||||
spec, allowed := lookupTag(token.name)
|
||||
if !allowed {
|
||||
return nil, fmt.Errorf("unsupported LarkOpenCLI tag <%s> at byte %d", token.name, lt)
|
||||
}
|
||||
canonical := spec.canonical
|
||||
if token.spacingNormalized {
|
||||
return nil, fmt.Errorf("invalid whitespace in XML tag <%s> at byte %d", token.name, lt)
|
||||
}
|
||||
|
||||
if token.closing {
|
||||
if isVoidTag(canonical) {
|
||||
return nil, fmt.Errorf("void tag <%s/> must not have a closing tag", canonical)
|
||||
}
|
||||
if len(stack) == 1 {
|
||||
return nil, fmt.Errorf("unexpected closing tag </%s> at byte %d", canonical, lt)
|
||||
}
|
||||
open := stack[len(stack)-1].tag
|
||||
if open != canonical {
|
||||
return nil, fmt.Errorf("mismatched closing tag </%s> at byte %d; expected </%s>", canonical, lt, open)
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
if len(stack) > 1 && shouldAutoClose(stack[len(stack)-1].tag, canonical) {
|
||||
return nil, fmt.Errorf("invalid <%s> inside <%s> at byte %d", canonical, stack[len(stack)-1].tag, lt)
|
||||
}
|
||||
attrs := normalizeAttributes(token.name, canonical, token.attrs)
|
||||
node := newElement(canonical, attrs)
|
||||
stack[len(stack)-1].addChild(node)
|
||||
if !token.selfClosing && !isVoidTag(canonical) {
|
||||
if len(stack) > MaxNestingDepth {
|
||||
return nil, fmt.Errorf("XML nesting exceeds limit %d at byte %d", MaxNestingDepth, lt)
|
||||
}
|
||||
stack = append(stack, node)
|
||||
}
|
||||
i = end
|
||||
}
|
||||
|
||||
if len(stack) > 1 {
|
||||
return nil, fmt.Errorf("missing closing tag </%s> at end of input", stack[len(stack)-1].tag)
|
||||
}
|
||||
normalizeParsedLineBreaks(root.children, false, false)
|
||||
for _, child := range root.children {
|
||||
child.parent = nil
|
||||
}
|
||||
return root.children, nil
|
||||
}
|
||||
|
||||
// normalizeParsedLineBreaks removes formatting newlines from ordinary XML,
|
||||
// while source-bearing code/whiteboard blocks keep semantic
|
||||
// line breaks as explicit <br/> nodes. str_replace pattern/replacement payloads
|
||||
// retain raw newlines because their string matching semantics depend on them.
|
||||
func normalizeParsedLineBreaks(nodes []*Node, sourceBlock, stringMutation bool) {
|
||||
for _, node := range nodes {
|
||||
if node == nil || node.typ != nodeElement {
|
||||
continue
|
||||
}
|
||||
nextSourceBlock := sourceBlock || node.tag == "code" || node.tag == "whiteboard"
|
||||
nextStringMutation := stringMutation || node.tag == "str_replace"
|
||||
preserveRaw := nextStringMutation && (node.tag == "pattern" || node.tag == "replacement")
|
||||
if node.tag == "code" || node.tag == "whiteboard" {
|
||||
trimSourceBlockBoundaryNewlines(node.children)
|
||||
}
|
||||
children := make([]*Node, 0, len(node.children))
|
||||
for _, child := range node.children {
|
||||
if child.typ != nodeText || !strings.ContainsAny(child.text, "\r\n") {
|
||||
children = append(children, child)
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case preserveRaw:
|
||||
children = append(children, child)
|
||||
case nextSourceBlock:
|
||||
for _, replacement := range rawTextWithBreakNodes(child.text) {
|
||||
replacement.parent = node
|
||||
children = append(children, replacement)
|
||||
}
|
||||
default:
|
||||
child.text = strings.NewReplacer("\r", "", "\n", "").Replace(child.text)
|
||||
if child.text != "" {
|
||||
children = append(children, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
node.children = children
|
||||
normalizeParsedLineBreaks(node.children, nextSourceBlock, nextStringMutation)
|
||||
}
|
||||
}
|
||||
|
||||
func trimSourceBlockBoundaryNewlines(children []*Node) {
|
||||
for _, child := range children {
|
||||
if child.typ == nodeText {
|
||||
child.text = strings.TrimLeft(child.text, "\r\n")
|
||||
break
|
||||
}
|
||||
if child.typ == nodeElement {
|
||||
break
|
||||
}
|
||||
}
|
||||
for i := len(children) - 1; i >= 0; i-- {
|
||||
child := children[i]
|
||||
if child.typ == nodeText {
|
||||
child.text = strings.TrimRight(child.text, "\r\n")
|
||||
break
|
||||
}
|
||||
if child.typ == nodeElement {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rawTextWithBreakNodes(content string) []*Node {
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
var nodes []*Node
|
||||
start := 0
|
||||
for i := 0; i < len(content); i++ {
|
||||
if content[i] != '\n' && content[i] != '\r' {
|
||||
continue
|
||||
}
|
||||
if i > start {
|
||||
nodes = append(nodes, newText(content[start:i]))
|
||||
}
|
||||
if content[i] == '\r' && i+1 < len(content) && content[i+1] == '\n' {
|
||||
i++
|
||||
}
|
||||
nodes = append(nodes, newElement("br", nil))
|
||||
start = i + 1
|
||||
}
|
||||
if start < len(content) {
|
||||
nodes = append(nodes, newText(content[start:]))
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
type tokenState uint8
|
||||
|
||||
const (
|
||||
tokenOK tokenState = iota
|
||||
tokenInvalid
|
||||
tokenIncomplete
|
||||
tokenComment
|
||||
tokenProcessingInstruction
|
||||
tokenCDATA
|
||||
)
|
||||
|
||||
type xmlToken struct {
|
||||
name string
|
||||
attrs map[string]string
|
||||
text string
|
||||
closing bool
|
||||
selfClosing bool
|
||||
spacingNormalized bool
|
||||
}
|
||||
|
||||
func scanXMLToken(source string, start int) (xmlToken, int, tokenState) {
|
||||
if strings.HasPrefix(source[start:], "<![CDATA[") {
|
||||
const marker = "<![CDATA["
|
||||
contentStart := start + len(marker)
|
||||
if closeAt := strings.Index(source[contentStart:], "]]>"); closeAt >= 0 {
|
||||
contentEnd := contentStart + closeAt
|
||||
return xmlToken{text: source[contentStart:contentEnd]}, contentEnd + len("]]>"), tokenCDATA
|
||||
}
|
||||
return xmlToken{}, len(source), tokenIncomplete
|
||||
}
|
||||
if strings.HasPrefix(source[start:], "<!--") {
|
||||
if closeAt := strings.Index(source[start+4:], "-->"); closeAt >= 0 {
|
||||
if strings.Contains(source[start+4:start+4+closeAt], "--") {
|
||||
return xmlToken{}, start + 1, tokenInvalid
|
||||
}
|
||||
return xmlToken{}, start + 4 + closeAt + 3, tokenComment
|
||||
}
|
||||
return xmlToken{}, len(source), tokenIncomplete
|
||||
}
|
||||
if strings.HasPrefix(source[start:], "<?") {
|
||||
if closeAt := strings.Index(source[start+2:], "?>"); closeAt >= 0 {
|
||||
return xmlToken{}, start + 2 + closeAt + 2, tokenProcessingInstruction
|
||||
}
|
||||
return xmlToken{}, len(source), tokenIncomplete
|
||||
}
|
||||
|
||||
quote := byte(0)
|
||||
end := -1
|
||||
for i := start + 1; i < len(source); i++ {
|
||||
switch source[i] {
|
||||
case '\'', '"':
|
||||
if quote == 0 {
|
||||
quote = source[i]
|
||||
} else if quote == source[i] {
|
||||
quote = 0
|
||||
}
|
||||
case '>':
|
||||
if quote == 0 {
|
||||
end = i + 1
|
||||
i = len(source)
|
||||
}
|
||||
case '<':
|
||||
// A second unquoted '<' cannot belong to the current XML tag.
|
||||
// Stop here so a long sequence of invalid tag starts is scanned
|
||||
// once instead of repeatedly searching to a distant '>'.
|
||||
if quote == 0 {
|
||||
return xmlToken{}, start + 1, tokenInvalid
|
||||
}
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
candidate := strings.TrimSpace(source[start+1:])
|
||||
if candidate == "" || !isTagNameStart(candidate[0]) && candidate[0] != '/' {
|
||||
return xmlToken{}, start + 1, tokenInvalid
|
||||
}
|
||||
return xmlToken{}, len(source), tokenIncomplete
|
||||
}
|
||||
|
||||
body := source[start+1 : end-1]
|
||||
if body == "" {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
token := xmlToken{}
|
||||
position := 0
|
||||
for position < len(body) && isXMLSpace(body[position]) {
|
||||
position++
|
||||
}
|
||||
if position > 0 {
|
||||
token.spacingNormalized = true
|
||||
}
|
||||
if position >= len(body) || body[position] == '!' {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
if body[position] == '/' {
|
||||
token.closing = true
|
||||
position++
|
||||
spaceStart := position
|
||||
for position < len(body) && isXMLSpace(body[position]) {
|
||||
position++
|
||||
}
|
||||
if position > spaceStart {
|
||||
token.spacingNormalized = true
|
||||
}
|
||||
}
|
||||
if position >= len(body) || !isTagNameStart(body[position]) {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
nameStart := position
|
||||
position++
|
||||
for position < len(body) && isTagNamePart(body[position]) {
|
||||
position++
|
||||
}
|
||||
token.name = body[nameStart:position]
|
||||
rawRemainder := body[position:]
|
||||
remainder := strings.TrimRightFunc(rawRemainder, unicode.IsSpace)
|
||||
if token.closing {
|
||||
if strings.TrimSpace(remainder) != "" {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
return token, end, tokenOK
|
||||
}
|
||||
if strings.HasSuffix(remainder, "/") {
|
||||
if len(remainder) != len(rawRemainder) {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
token.selfClosing = true
|
||||
remainder = strings.TrimRightFunc(strings.TrimSuffix(remainder, "/"), unicode.IsSpace)
|
||||
}
|
||||
trimmedAttrs := strings.TrimLeftFunc(remainder, unicode.IsSpace)
|
||||
if trimmedAttrs != "" && !isAttributeNameStart(trimmedAttrs[0]) {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
var ok bool
|
||||
token.attrs, ok = parseStrictAttributes(remainder)
|
||||
if !ok {
|
||||
return xmlToken{}, end, tokenInvalid
|
||||
}
|
||||
return token, end, tokenOK
|
||||
}
|
||||
|
||||
func isXMLSpace(ch byte) bool {
|
||||
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'
|
||||
}
|
||||
|
||||
func isTagNameStart(ch byte) bool {
|
||||
return ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z'
|
||||
}
|
||||
|
||||
func isTagNamePart(ch byte) bool {
|
||||
return isTagNameStart(ch) || ch >= '0' && ch <= '9' || ch == '_' || ch == '-' || ch == '.' || ch == ':'
|
||||
}
|
||||
|
||||
func isAttributeNameStart(ch byte) bool {
|
||||
return isTagNameStart(ch) || ch == '_' || ch == ':'
|
||||
}
|
||||
|
||||
func parseAttributes(source string) map[string]string {
|
||||
attrs := map[string]string{}
|
||||
for i := 0; i < len(source); {
|
||||
for i < len(source) && unicode.IsSpace(rune(source[i])) {
|
||||
i++
|
||||
}
|
||||
if i >= len(source) {
|
||||
break
|
||||
}
|
||||
start := i
|
||||
for i < len(source) && isAttributeNameByte(source[i]) {
|
||||
i++
|
||||
}
|
||||
if start == i {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
name := source[start:i]
|
||||
for i < len(source) && unicode.IsSpace(rune(source[i])) {
|
||||
i++
|
||||
}
|
||||
value := ""
|
||||
if i < len(source) && source[i] == '=' {
|
||||
i++
|
||||
for i < len(source) && unicode.IsSpace(rune(source[i])) {
|
||||
i++
|
||||
}
|
||||
if i < len(source) && (source[i] == '\'' || source[i] == '"') {
|
||||
quote := source[i]
|
||||
i++
|
||||
start = i
|
||||
for i < len(source) && source[i] != quote {
|
||||
i++
|
||||
}
|
||||
value = source[start:i]
|
||||
if i < len(source) {
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
start = i
|
||||
for i < len(source) && !unicode.IsSpace(rune(source[i])) {
|
||||
i++
|
||||
}
|
||||
value = source[start:i]
|
||||
}
|
||||
}
|
||||
attrs[name] = html.UnescapeString(value)
|
||||
}
|
||||
if len(attrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// parseStrictAttributes implements the quoted attribute grammar accepted by
|
||||
// XML. parseAttributes remains intentionally permissive for the Markdown
|
||||
// container extension, whose input is Markdown rather than an XML document.
|
||||
func parseStrictAttributes(source string) (map[string]string, bool) {
|
||||
attrs := map[string]string{}
|
||||
for i := 0; i < len(source); {
|
||||
spaceStart := i
|
||||
for i < len(source) && isXMLSpace(source[i]) {
|
||||
i++
|
||||
}
|
||||
if i >= len(source) {
|
||||
break
|
||||
}
|
||||
if i == spaceStart || !isAttributeNameStart(source[i]) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
nameStart := i
|
||||
i++
|
||||
for i < len(source) && isTagNamePart(source[i]) {
|
||||
i++
|
||||
}
|
||||
name := source[nameStart:i]
|
||||
if _, exists := attrs[name]; exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for i < len(source) && isXMLSpace(source[i]) {
|
||||
i++
|
||||
}
|
||||
if i >= len(source) || source[i] != '=' {
|
||||
return nil, false
|
||||
}
|
||||
i++
|
||||
for i < len(source) && isXMLSpace(source[i]) {
|
||||
i++
|
||||
}
|
||||
if i >= len(source) || (source[i] != '\'' && source[i] != '"') {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
quote := source[i]
|
||||
i++
|
||||
valueStart := i
|
||||
for i < len(source) && source[i] != quote {
|
||||
if source[i] == '<' {
|
||||
return nil, false
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i >= len(source) {
|
||||
return nil, false
|
||||
}
|
||||
rawValue := normalizeXMLAttributeAmpersands(source[valueStart:i])
|
||||
if invalidXMLEntityAt(rawValue) >= 0 {
|
||||
return nil, false
|
||||
}
|
||||
attrs[name] = html.UnescapeString(rawValue)
|
||||
i++
|
||||
}
|
||||
if len(attrs) == 0 {
|
||||
return nil, true
|
||||
}
|
||||
return attrs, true
|
||||
}
|
||||
|
||||
func isAttributeNameByte(ch byte) bool {
|
||||
return ch > ' ' && ch != '=' && ch != '/' && ch != '>'
|
||||
}
|
||||
|
||||
func appendText(parent *Node, raw string) {
|
||||
if parent == nil || raw == "" {
|
||||
return
|
||||
}
|
||||
appendTextValue(parent, html.UnescapeString(raw))
|
||||
}
|
||||
|
||||
func appendTextValue(parent *Node, text string) {
|
||||
if parent == nil || text == "" {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(text) == "" && !preserveSpaceTags[parent.tag] && parent.tag != "whiteboard" {
|
||||
return
|
||||
}
|
||||
if count := len(parent.children); count > 0 && parent.children[count-1].typ == nodeText {
|
||||
parent.children[count-1].text += text
|
||||
return
|
||||
}
|
||||
parent.addChild(newText(text))
|
||||
}
|
||||
|
||||
func validateXMLText(value string, absoluteOffset int) error {
|
||||
if offset := strings.Index(value, "]]>"); offset >= 0 {
|
||||
return fmt.Errorf("invalid ]]> sequence in XML text at byte %d", absoluteOffset+offset)
|
||||
}
|
||||
if offset := invalidXMLEntityAt(value); offset >= 0 {
|
||||
return fmt.Errorf("invalid XML entity at byte %d", absoluteOffset+offset)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidXMLEntityAt(value string) int {
|
||||
for cursor := 0; cursor < len(value); {
|
||||
relative := strings.IndexByte(value[cursor:], '&')
|
||||
if relative < 0 {
|
||||
return -1
|
||||
}
|
||||
start := cursor + relative
|
||||
endRelative := strings.IndexByte(value[start+1:], ';')
|
||||
if endRelative < 0 {
|
||||
return start
|
||||
}
|
||||
end := start + 1 + endRelative
|
||||
if !isValidXMLEntity(value[start+1 : end]) {
|
||||
return start
|
||||
}
|
||||
cursor = end + 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func isValidXMLEntity(entity string) bool {
|
||||
switch entity {
|
||||
case "amp", "lt", "gt", "quot", "apos":
|
||||
return true
|
||||
}
|
||||
|
||||
base := 10
|
||||
digits := ""
|
||||
switch {
|
||||
case strings.HasPrefix(entity, "#x"):
|
||||
base = 16
|
||||
digits = entity[2:]
|
||||
case strings.HasPrefix(entity, "#"):
|
||||
digits = entity[1:]
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if digits == "" {
|
||||
return false
|
||||
}
|
||||
value, err := strconv.ParseUint(digits, base, 32)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
r := rune(value)
|
||||
return r == '\t' || r == '\n' || r == '\r' ||
|
||||
r >= 0x20 && r <= 0xD7FF ||
|
||||
r >= 0xE000 && r <= 0xFFFD ||
|
||||
r >= 0x10000 && r <= utf8.MaxRune
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeXMLAttributeAmpersands escapes bare ampersands in an XML attribute
|
||||
// value so local parsing matches the server SDK. Complete entity references
|
||||
// remain untouched for the strict parser to validate.
|
||||
func normalizeXMLAttributeAmpersands(value string) string {
|
||||
firstBare := -1
|
||||
for cursor := 0; cursor < len(value); {
|
||||
relative := strings.IndexByte(value[cursor:], '&')
|
||||
if relative < 0 {
|
||||
break
|
||||
}
|
||||
ampersand := cursor + relative
|
||||
if isBareXMLAttributeAmpersand(value, ampersand) {
|
||||
firstBare = ampersand
|
||||
break
|
||||
}
|
||||
cursor = ampersand + 1
|
||||
}
|
||||
if firstBare < 0 {
|
||||
return value
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
out.Grow(len(value))
|
||||
out.WriteString(value[:firstBare])
|
||||
|
||||
for i := firstBare; i < len(value); i++ {
|
||||
if value[i] == '&' && isBareXMLAttributeAmpersand(value, i) {
|
||||
out.WriteString("&")
|
||||
continue
|
||||
}
|
||||
out.WriteByte(value[i])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func isBareXMLAttributeAmpersand(value string, start int) bool {
|
||||
if start+1 >= len(value) {
|
||||
return true
|
||||
}
|
||||
if value[start+1] == '#' {
|
||||
return false
|
||||
}
|
||||
if !isTagNameStart(value[start+1]) && value[start+1] != '_' {
|
||||
return true
|
||||
}
|
||||
for i := start + 2; i < len(value); i++ {
|
||||
if value[i] == ';' {
|
||||
return false
|
||||
}
|
||||
if !isTagNamePart(value[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -33,8 +33,6 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||
case "history-list", "history-revert", "history-revert-status":
|
||||
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||
case "script":
|
||||
return docsSkillReadCommand + " references/lark-doc-script.md"
|
||||
default:
|
||||
return docsSkillReadCommand
|
||||
}
|
||||
@@ -54,8 +52,6 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
||||
return "lark-cli docs +history-revert --help"
|
||||
case "history-revert-status":
|
||||
return "lark-cli docs +history-revert-status --help"
|
||||
case "script":
|
||||
return "lark-cli docs +script --help"
|
||||
default:
|
||||
return "lark-cli docs --help"
|
||||
}
|
||||
@@ -68,7 +64,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DocsCreate,
|
||||
DocsFetch,
|
||||
DocsUpdate,
|
||||
DocsScript,
|
||||
DocsHistoryList,
|
||||
DocsHistoryRevert,
|
||||
DocsHistoryRevertStatus,
|
||||
|
||||
325
shortcuts/drive/drive_member_list.go
Normal file
325
shortcuts/drive/drive_member_list.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveMemberListSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
Fields string
|
||||
PermType string
|
||||
}
|
||||
|
||||
var driveMemberListTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
|
||||
var driveMemberListPermTypes = []string{"container", "single_page"}
|
||||
|
||||
var driveMemberListURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
|
||||
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
return driveMemberListSpec{
|
||||
Token: token,
|
||||
Type: resourceType,
|
||||
Fields: fields,
|
||||
PermType: permType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
|
||||
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, parseErr := url.Parse(raw)
|
||||
if parseErr != nil || parsed.Hostname() == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
|
||||
}
|
||||
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return ref.Token, ref.Type, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(driveMemberListTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(raw, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return raw, explicitType, nil
|
||||
}
|
||||
|
||||
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
|
||||
for _, mapping := range driveMemberListURLPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
if changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
field := strings.ToLower(strings.TrimSpace(part))
|
||||
if field == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
if field == "*" {
|
||||
if len(parts) != 1 {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
|
||||
}
|
||||
return "*", nil
|
||||
}
|
||||
if !driveMemberListFieldAllowed(field) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for --fields, allowed: %s, *",
|
||||
strings.TrimSpace(part),
|
||||
strings.Join(driveMemberListFields, ", "),
|
||||
).WithParam("--fields")
|
||||
}
|
||||
if !seen[field] {
|
||||
fields = append(fields, field)
|
||||
seen[field] = true
|
||||
}
|
||||
}
|
||||
return strings.Join(fields, ","), nil
|
||||
}
|
||||
|
||||
func driveMemberListFieldAllowed(field string) bool {
|
||||
for _, allowed := range driveMemberListFields {
|
||||
if field == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
|
||||
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resourceType != "wiki" && changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
|
||||
}
|
||||
return permType, nil
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(value, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for %s, allowed: %s",
|
||||
value,
|
||||
flagName,
|
||||
strings.Join(allowed, ", "),
|
||||
).WithParam(flagName)
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) apiPath() string {
|
||||
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) params() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": s.Type}
|
||||
if s.Fields != "" {
|
||||
params["fields"] = s.Fields
|
||||
}
|
||||
if s.PermType != "" {
|
||||
params["perm_type"] = s.PermType
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// DriveMemberList lists collaborator/member permissions on a Drive resource.
|
||||
var DriveMemberList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-list",
|
||||
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.member:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
|
||||
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
|
||||
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders.",
|
||||
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
|
||||
"--perm-type only applies to wiki nodes.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List Drive collaborator/member permissions").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if items, ok := data["items"].([]interface{}); ok {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderDriveMemberListPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No Drive members found.")
|
||||
return
|
||||
}
|
||||
for i, raw := range items {
|
||||
member, _ := raw.(map[string]interface{})
|
||||
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
|
||||
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
|
||||
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
|
||||
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
|
||||
fmt.Fprintf(w, " perm_type: %s\n", permType)
|
||||
}
|
||||
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
|
||||
fmt.Fprintf(w, " type: %s\n", memberType)
|
||||
}
|
||||
if name := driveMemberListValue(member["name"]); name != "-" {
|
||||
fmt.Fprintf(w, " name: %s\n", name)
|
||||
}
|
||||
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
|
||||
fmt.Fprintf(w, " avatar: %s\n", avatar)
|
||||
}
|
||||
if label, ok := member["external_label"]; ok {
|
||||
fmt.Fprintf(w, " external_label: %v\n", label)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func driveMemberListValue(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
426
shortcuts/drive/drive_member_list_test.go
Normal file
426
shortcuts/drive/drive_member_list_test.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +member-list"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("fields", "", "")
|
||||
cmd.Flags().String("perm-type", "", "")
|
||||
for name, value := range map[string]string{
|
||||
"token": token,
|
||||
"type": docType,
|
||||
"fields": fields,
|
||||
"perm-type": permType,
|
||||
} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
|
||||
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "folder",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "invalid fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "name,unknown",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "star mixed with fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "*,name",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "cannot be combined",
|
||||
},
|
||||
{
|
||||
name: "perm type rejected for non-wiki",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
permType: "single_page",
|
||||
wantParam: "--perm-type",
|
||||
wantMessage: "only applies when resource type is wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "default omits optional params",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
want: map[string]interface{}{"type": "docx"},
|
||||
},
|
||||
{
|
||||
name: "fields canonicalized and deduplicated",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "Name,avatar,name",
|
||||
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
|
||||
},
|
||||
{
|
||||
name: "wiki accepts perm type",
|
||||
token: "wikTok",
|
||||
docType: "WIKI",
|
||||
fields: "*",
|
||||
permType: "SINGLE_PAGE",
|
||||
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("params = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldTok",
|
||||
"--fields", "*",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.Data.API))
|
||||
}
|
||||
api := got.Data.API[0]
|
||||
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
|
||||
t.Fatalf("api = %#v", api)
|
||||
}
|
||||
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
|
||||
t.Fatalf("params = %#v", api.Params)
|
||||
}
|
||||
if _, ok := api.Params["perm_type"]; ok {
|
||||
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
var capturedQuery string
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok/members",
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"server_future": "preserved",
|
||||
"external_label": true,
|
||||
},
|
||||
},
|
||||
"server_top_level": "preserved",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--fields", "name,type,external_label",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(capturedQuery, "type=docx") ||
|
||||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
|
||||
t.Fatalf("captured query = %q", capturedQuery)
|
||||
}
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["server_top_level"] != "preserved" {
|
||||
t.Fatalf("server_top_level = %#v", data["server_top_level"])
|
||||
}
|
||||
for _, key := range []string{"token", "type", "count"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %#v, want one item", data["items"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if item["server_future"] != "preserved" || item["external_label"] != true {
|
||||
t.Fatalf("item future fields not preserved: %#v", item)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
|
||||
t.Fatalf("stderr = %q, want count log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListPrettyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/wikTok/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"perm_type": "single_page",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "wikTok",
|
||||
"--type", "wiki",
|
||||
"--perm-type", "single_page",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type drivePermissionGetSettingSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
|
||||
rawToken := strings.TrimSpace(runtime.Str("token"))
|
||||
explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
|
||||
|
||||
if rawToken == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token is required",
|
||||
).WithParam("--token")
|
||||
}
|
||||
|
||||
if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(rawToken, "://") {
|
||||
ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken)
|
||||
if !ok {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
rawToken,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token (allowed: %s)",
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(rawToken, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
for _, mapping := range drivePermissionGetSettingURLPathToType {
|
||||
if !strings.HasPrefix(parsed.Path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := parsed.Path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingTypeAllowed(docType string) bool {
|
||||
for _, allowed := range drivePermissionGetSettingTypes {
|
||||
if docType == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
|
||||
if runtime != nil && runtime.Config != nil {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return common.BuildResourceURL("", s.Type, s.Token)
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
|
||||
return map[string]interface{}{"type": s.Type}
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) apiPath() string {
|
||||
return drivePermissionPublicV2Path(s.Token)
|
||||
}
|
||||
|
||||
func drivePermissionPublicV2Path(token string) string {
|
||||
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
permissionPublic := common.GetMap(data, "permission_public")
|
||||
if permissionPublic == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"drive permission get response missing data.permission_public",
|
||||
)
|
||||
}
|
||||
return permissionPublic, nil
|
||||
}
|
||||
|
||||
// DrivePermissionGetSetting queries permission_public settings for a Drive
|
||||
// document, file, wiki node, or folder.
|
||||
var DrivePermissionGetSetting = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+permission-get-setting",
|
||||
Description: "Get public access, sharing, collaborator management, security, and comment permission settings",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.setting:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Get Drive permission settings").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
spec.apiPath(),
|
||||
spec.params(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
permissionPublic, err := drivePermissionGetSettingPermissionPublic(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"encode drive permission settings for pretty output",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{"permission_public": permissionPublic}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Type: %s\n", spec.Type)
|
||||
fmt.Fprintf(w, "Token: %s\n", spec.Token)
|
||||
if url := spec.url(runtime); url != "" {
|
||||
fmt.Fprintf(w, "URL: %s\n", url)
|
||||
}
|
||||
fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if token != "" {
|
||||
if err := cmd.Flags().Set("token", token); err != nil {
|
||||
t.Fatalf("set --token: %v", err)
|
||||
}
|
||||
}
|
||||
if docType != "" {
|
||||
if err := cmd.Flags().Set("type", docType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "wiki URL",
|
||||
token: "https://example.feishu.cn/wiki/wikTok",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare file token",
|
||||
token: "boxTok",
|
||||
docType: "file",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok {
|
||||
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
|
||||
}
|
||||
if spec.Type != tt.wantType {
|
||||
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "sheet",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid --type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr, ok := err.(*errs.ValidationError); ok {
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantURL string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: "fldTok",
|
||||
docType: "folder",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
out := string(data)
|
||||
for _, want := range []string{
|
||||
`"` + tt.wantURL + `"`,
|
||||
`"GET"`,
|
||||
`"type":"` + tt.wantType + `"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"folder_token"`) {
|
||||
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
for _, key := range []string{"type", "token", "url"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
permissionPublic, _ := data["permission_public"].(map[string]interface{})
|
||||
if permissionPublic == nil {
|
||||
t.Fatalf("permission_public missing in output: %#v", data)
|
||||
}
|
||||
for key, want := range map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
} {
|
||||
if permissionPublic[key] != want {
|
||||
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{"unexpected": "response"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid response error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Permission settings:",
|
||||
`"link_share_entity": "closed"`,
|
||||
`"server_future_field": "preserved"`,
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
|
||||
}
|
||||
for _, flag := range DrivePermissionGetSetting.Flags {
|
||||
if flag.Name == "token" && !flag.Required {
|
||||
t.Fatal("--token must be declared required")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveMemberList,
|
||||
DrivePermissionGetSetting,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -39,6 +39,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+member-list",
|
||||
"+permission-get-setting",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -17,9 +17,10 @@ import (
|
||||
)
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
|
||||
// legacy synthetic-token prefix or a 28-character token whose interleaved
|
||||
// product/region marker is "OFL0X". The backend requires
|
||||
// "office_sheet_file" for those imported spreadsheets.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
@@ -27,22 +28,37 @@ const (
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
// officePrefixes are the legacy synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping either the
|
||||
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
|
||||
// "office_sheet_file".
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
func isOfficeSpreadsheet(spreadsheetToken string) bool {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(spreadsheetToken) != 28 {
|
||||
return false
|
||||
}
|
||||
// The five-character marker occupies positions 5, 10, 15, 20, and 25
|
||||
// (1-based) in the interleaved token.
|
||||
marker := []byte{
|
||||
spreadsheetToken[4],
|
||||
spreadsheetToken[9],
|
||||
spreadsheetToken[14],
|
||||
spreadsheetToken[19],
|
||||
spreadsheetToken[24],
|
||||
}
|
||||
return string(marker) == "OFL0X"
|
||||
}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
if isOfficeSpreadsheet(spreadsheetToken) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "fake_office_abc123",
|
||||
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
@@ -117,10 +117,10 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"office_sheet_file"`) {
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for interleaved OFL0X token, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"sheet_image"`) {
|
||||
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
|
||||
t.Fatalf("dry-run must not emit sheet_image for interleaved OFL0X token, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
|
||||
// "office" spreadsheet (token prefixed with "fake_office_") uploads with
|
||||
// "office" spreadsheet (token carrying the interleaved "OFL0X" marker) uploads with
|
||||
// parent_type=office_sheet_file instead of the native sheet_image.
|
||||
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
@@ -259,7 +259,7 @@ func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
const officeToken = "fake_office_abc123"
|
||||
const officeToken = "aaaaOaaaaFaaaaLaaaa0aaaaXaaa"
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", officeToken,
|
||||
|
||||
@@ -53,9 +53,10 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
}
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
|
||||
// legacy synthetic-token prefix or a 28-character token whose interleaved
|
||||
// product/region marker is "OFL0X". The backend requires
|
||||
// "office_sheet_file" for those imported spreadsheets.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
@@ -63,21 +64,38 @@ const (
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
// officePrefixes are the legacy synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
func isOfficeSpreadsheet(spreadsheetToken string) bool {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(spreadsheetToken) != 28 {
|
||||
return false
|
||||
}
|
||||
// The five-character marker occupies positions 5, 10, 15, 20, and 25
|
||||
// (1-based) in the interleaved token.
|
||||
marker := []byte{
|
||||
spreadsheetToken[4],
|
||||
spreadsheetToken[9],
|
||||
spreadsheetToken[14],
|
||||
spreadsheetToken[19],
|
||||
spreadsheetToken[24],
|
||||
}
|
||||
return string(marker) == "OFL0X"
|
||||
}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken. It is the single
|
||||
// place that maps a spreadsheet token to its parent_type so every image-upload
|
||||
// entry point (and its dry-run preview) stays consistent.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
if isOfficeSpreadsheet(spreadsheetToken) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ import (
|
||||
|
||||
// TestSheetMediaParentType pins the token→parent_type mapping that every
|
||||
// sheets image-upload entry point funnels through. Native spreadsheet tokens
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
|
||||
// "local_office_" synthetic token and must upload with "office_sheet_file".
|
||||
// use "sheet_image"; imported "office" spreadsheets use either a legacy
|
||||
// prefix or the interleaved "OFL0X" marker and must upload with
|
||||
// "office_sheet_file".
|
||||
func TestSheetMediaParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -40,6 +41,13 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
{"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType},
|
||||
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
|
||||
{"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType},
|
||||
{"interleaved OFL0X office token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
|
||||
{"interleaved exlcn token", "abcdeefghxijkllmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved shtcn native token", "abcdsefghhijkltmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved pptcn token", "abcdpefghpijkltmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved wodcn token", "abcdwefghoijkldmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved OFL0X marker with short length", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", sheetImageParentType},
|
||||
{"interleaved OFL0X marker with long length", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", sheetImageParentType},
|
||||
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
|
||||
}
|
||||
@@ -57,7 +65,7 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
// to end (the Execute path the dry-run tests don't reach), asserting the
|
||||
// parent_type that actually goes out on the wire is derived from the token: a
|
||||
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
|
||||
// (fake_office_-prefixed token) as office_sheet_file.
|
||||
// (legacy prefix or interleaved OFL0X marker) as office_sheet_file.
|
||||
func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -67,6 +75,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
|
||||
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
|
||||
{"interleaved OFL0X imported spreadsheet", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -43,7 +43,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-id", Type: "string_slice", Desc: "slide page identifier (repeat or comma-separated for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
|
||||
@@ -55,7 +55,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if strings.TrimSpace(runtime.Str("content")) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
@@ -71,7 +71,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -96,7 +96,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
@@ -146,7 +146,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -198,7 +198,7 @@ func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
@@ -217,7 +217,7 @@ func executeRenderScreenshot(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
|
||||
@@ -185,6 +185,139 @@ func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListBySlideIDCSV(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_id": "slide_1",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_2",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1,slide_2",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideIDs []string `json:"slide_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
|
||||
t.Fatalf("slide_ids = %#v, want [slide_1 slide_2]", body.SlideIDs)
|
||||
}
|
||||
|
||||
path1 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_1.png")
|
||||
if _, err := os.ReadFile(path1); err != nil {
|
||||
t.Fatalf("read first CSV slide screenshot: %v", err)
|
||||
}
|
||||
path2 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_2.png")
|
||||
if _, err := os.ReadFile(path2); err != nil {
|
||||
t.Fatalf("read second CSV slide screenshot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListBySlideIDCSVDeduplicatesAndTrims(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_id": "slide_1",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_2",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
// CSV with a duplicate and blank segments should normalize the same way
|
||||
// normalizeSlideIDs already does for repeated --slide-id flags.
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1, slide_2,slide_1,",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideIDs []string `json:"slide_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
|
||||
t.Fatalf("slide_ids = %#v, want deduplicated [slide_1 slide_2]", body.SlideIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListRejectsMoreThanTenSlideIDsCSV(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %v, want typed validation error", err)
|
||||
}
|
||||
if problem.Hint != "request at most 10 pages at a time" {
|
||||
t.Fatalf("hint = %q, want max 10 pages guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
@@ -387,6 +520,27 @@ func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsSlideNumberSelector(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
// Exercises the --slide-number-only side of the --content conflict check
|
||||
// (TestSlidesScreenshotRenderRejectsSlideSelectors above only covers the
|
||||
// --slide-id side of that same `||` condition).
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--slide-number", "1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
|
||||
t.Fatalf("error = %v, want content/slide selector conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
|
||||
@@ -57,12 +57,12 @@ metadata:
|
||||
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) |
|
||||
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 |
|
||||
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md)(filter 条件结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md));其余配置先 get 现状,再按返回结构更新 |
|
||||
| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
@@ -116,6 +116,7 @@ metadata:
|
||||
## 表单与视图细节
|
||||
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-questions-update` 是题目配置全量覆盖,不是 patch;未传字段会回落默认值,传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
@@ -146,13 +147,14 @@ metadata:
|
||||
## 保留 Reference
|
||||
|
||||
- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT;`+data-query` 的 `filters` 结构是独立对象 DSL,不使用公共 tuple filter 协议
|
||||
- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
|
||||
- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
|
||||
- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
|
||||
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
|
||||
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
|
||||
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
|
||||
- [lark-base-filter-condition.md](references/lark-base-filter-condition.md):视图 filter、记录 `--filter-json`、表单 `visible_rule` 的 tuple 条件结构公共协议 SSOT;不适用于 `+data-query`
|
||||
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
|
||||
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
|
||||
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT
|
||||
|
||||
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Base Filter 条件结构(公共协议)
|
||||
|
||||
Filter 是一组「字段/操作符/值」条件的组合,用 `logic`(`and` / `or`)把多条 `conditions` 连接起来,用于描述「满足什么条件」。视图筛选 `filter`、记录读取/搜索的 `--filter-json`、表单题目显隐条件 `visible_rule` 复用同一套 tuple 结构,本文件是其公共协议(SSOT)。
|
||||
|
||||
## 0. 适用范围
|
||||
|
||||
本协议只适用于以下场景:
|
||||
|
||||
- `+view-set-filter` / `+view-get-filter` 的视图筛选配置。
|
||||
- `+record-list --filter-json` / `+record-search --filter-json` 的结构化记录筛选。
|
||||
- `+form-questions-create` / `+form-questions-update` 中的 `visible_rule` 显隐条件。
|
||||
|
||||
本协议**不适用于 `+data-query`**。`+data-query` 支持过滤,但使用的是 LiteQuery DSL 的 `filters` 对象结构:`{"type":1,"conjunction":"and","conditions":[{"field_name":"状态","operator":"is","value":["有效"]}]}`,不是这里的 tuple 条件 `["状态","==","有效"]`。构造 `+data-query --dsl` 时请阅读 [lark-base-data-query.md](lark-base-data-query.md) 的 FilterGroup / Condition 章节。
|
||||
|
||||
## 1. 顶层结构
|
||||
|
||||
- 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["状态", "intersects", ["Doing"]],
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]],
|
||||
["截止时间", "empty"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
清空写法:
|
||||
|
||||
```json
|
||||
{
|
||||
"conditions": []
|
||||
}
|
||||
```
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
value 类型取决于条件引用对象(字段 / 题目)的类型。
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 易错点
|
||||
|
||||
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`。
|
||||
- `user` / `group_chat` / `link` 不要写成单个标量。
|
||||
- `empty` / `non_empty` 不要硬塞无意义的 value。
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前配置或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 5. 参考
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
@@ -19,10 +19,7 @@ lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"type":"text","title":"您的姓名是?","required":true},
|
||||
{"type":"text","title":"您的联系方式是?","required":false}
|
||||
]'
|
||||
--questions '[{"type":"text","title":"您的姓名是?","required":true},{"type":"text","title":"您的联系方式是?","required":false}]'
|
||||
|
||||
# 添加单选题(带选项)
|
||||
lark-cli base +form-questions-create \
|
||||
@@ -50,6 +47,13 @@ lark-cli base +form-questions-create \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"text","title":"反馈建议","description":"更多详情请查看[帮助文档](https://example.com/help)"}]'
|
||||
|
||||
# 添加带显隐条件(visible_rule)的问题:当「是否需要发票」选择「是」时才显示「发票抬头」
|
||||
lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"select","title":"是否需要发票","required":true,"options":[{"name":"是","hue":"Blue"},{"name":"否","hue":"Gray"}]},{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -78,6 +82,7 @@ lark-cli base +form-questions-create \
|
||||
| `multiple` | 否 | 是否多选(`select`/`user` 类型有效,bool) |
|
||||
| `options` | 否 | 选项列表(仅 `select` 有效):`[{"name":"选项1","hue":"Blue"}]`,hue 可选:`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Purple`/`Gray` |
|
||||
| `style` | 否 | 字段样式配置(见下方说明) |
|
||||
| `visible_rule` | 否 | 题目显隐条件(见下方「`visible_rule` 显隐条件」) |
|
||||
|
||||
### `style` 字段说明
|
||||
|
||||
@@ -88,6 +93,30 @@ lark-cli base +form-questions-create \
|
||||
| `number`(评分) | `{"type":"rating","icon":"star","min":1,"max":5}` | icon 可选:`star`/`heart`/`thumbsup`/`fire`/`smile`/`lightning`/`flower`/`number` |
|
||||
| `datetime` | `{"format":"yyyy/MM/dd"}` | format 可选:`yyyy/MM/dd`、`yyyy/MM/dd HH:mm`、`MM-dd`、`MM/dd/yyyy`、`dd/MM/yyyy` |
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目在表单中的显示/隐藏:当条件满足时题目显示,不满足时隐藏;不传或 `conditions` 为空数组则题目始终显示。
|
||||
|
||||
- **结构与视图筛选 `filter` 完全一致**,即 `{logic?, conditions?}`,共用同一套公共协议。
|
||||
- 与视图 `filter` 唯一的区别:`conditions` 中的 `field` 引用的是**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID 以避免重名歧义),而不是数据表字段。
|
||||
- **只能引用前序题目**:条件只能引用排在当前题目之前的题目——创建时按 `questions` 数组顺序判定(可引用同批次更靠前的新题目或表单中已有题目),不支持循环引用。
|
||||
- 引用的题目必须真实存在,否则会报错。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["是否需要发票", "==", "是"],
|
||||
["报销金额", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
返回创建成功的问题列表:
|
||||
@@ -115,4 +144,5 @@ lark-cli base +form-questions-create \
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -2,40 +2,60 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量更新多维表格表单/问卷中的问题(标题、描述、是否必填)。
|
||||
批量更新多维表格表单/问卷中的问题配置(标题、描述、是否必填、显隐条件等)。
|
||||
|
||||
> [!CAUTION]
|
||||
> `+form-questions-update` 是**题目配置全量覆盖**,不是 patch。对每个传入的题目,未携带的属性会回落为默认值,显式传空字符串 / `null` / 空数组会直接写入空或清空;如果要保留现有属性,必须先用 `+form-questions-list` 查出现状,再把要保留的字段一起带回 `--questions`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 更新一个问题的标题
|
||||
lark-cli base +form-questions-update \
|
||||
# 先读取现有题目配置,作为 read-modify-write 的基线
|
||||
lark-cli base +form-questions-list \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","title":"您的真实姓名是?"}]'
|
||||
--form-id <form_id>
|
||||
|
||||
# 同时更新多个问题
|
||||
# 更新一个问题的标题,同时带回要保留的 required / description / visible_rule 等字段
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"id":"q_001","title":"姓名(必填)","required":true},
|
||||
{"id":"q_002","title":"联系方式","required":false}
|
||||
]'
|
||||
--questions '[{"id":"q_001","title":"您的真实姓名是?","description":"请填写真实姓名","required":true,"visible_rule":null}]'
|
||||
|
||||
# 同时更新多个问题;每个对象都应是该题目的目标完整配置
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","title":"姓名(必填)","required":true},{"id":"q_002","title":"联系方式","required":false}]'
|
||||
|
||||
# 更新问题描述(纯文本)
|
||||
# 更新问题描述(纯文本),同时带回要保留的 title / required / visible_rule
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","description":"请填写您的真实姓名"}]'
|
||||
# 更新问题描述(含链接)
|
||||
--questions '[{"id":"q_001","title":"您的姓名","description":"请填写您的真实姓名","required":true,"visible_rule":null}]'
|
||||
# 更新问题描述(含链接),同时带回要保留的 title / required / visible_rule
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","description":"更多说明请参考[帮助文档](https://example.com/help)"}]'
|
||||
--questions '[{"id":"q_001","title":"反馈建议","description":"更多说明请参考[帮助文档](https://example.com/help)","required":false,"visible_rule":null}]'
|
||||
|
||||
# 更新题目显隐条件(visible_rule),同时带回要保留的 title / description / required
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":{"logic":"and","conditions":[["q_001","==","是"]]}}]'
|
||||
|
||||
# 清空题目显隐条件(使题目始终显示),同时带回要保留的 title / description / required
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":null}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -52,15 +72,46 @@ lark-cli base +form-questions-update \
|
||||
|
||||
## `--questions` 格式
|
||||
|
||||
每个问题对象必须包含 `id`,其余字段按需传入:
|
||||
每个问题对象必须包含 `id`。注意:对象不是增量 patch,而是该题目的目标完整配置;未携带字段会按服务端默认值重建。
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | **是** | 问题 ID(field_id),不可修改 |
|
||||
| `title` | 否 | 新的问题标题 |
|
||||
| `description` | 否 | 新的问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`) |
|
||||
| `required` | 否 | 是否必填 |
|
||||
| `option_display_mode` | 否 | 选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向 |
|
||||
| `title` | 否 | 目标问题标题;省略会回落为字段名,传空字符串会写入空标题(若服务端允许) |
|
||||
| `description` | 否 | 目标问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`);省略或传空字符串都会清空描述 |
|
||||
| `required` | 否 | 目标是否必填;省略会回落为 `false` |
|
||||
| `option_display_mode` | 否 | 目标选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向;省略会回落默认展示方式 |
|
||||
| `visible_rule` | 否 | 目标题目显隐条件;传完整 `{logic, conditions}` 对象覆盖,传 `null` 或省略都会清空(见下方说明) |
|
||||
|
||||
## 全量覆盖语义
|
||||
|
||||
- 先执行 `+form-questions-list`,读取被更新题目的当前 `id`、`title`、`description`、`required`、`option_display_mode`、`visible_rule`。
|
||||
- 构造 `--questions` 时,只改用户明确要求变化的字段;所有仍要保留的字段必须按当前值一并传回。
|
||||
- 不要用“只传要改的字段”的方式更新题目。比如只传 `{"id":"q_002","title":"新标题"}` 会让 `description` 清空、`required` 回落为 `false`、`visible_rule` 清空。
|
||||
- 用户明确要求清空时才传空值:`description:""` 清空描述,`visible_rule:null` 清空显隐条件,`conditions:[]` 也表示无条件显示。
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置或修改显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目显示/隐藏,**结构与视图筛选 `filter` 完全一致**(`{logic?, conditions?}`),共用同一套公共协议。
|
||||
|
||||
- `conditions` 中的 `field` 引用**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID)。
|
||||
- 更新时按表单中题目的**实际顺序**判定,只能引用排在当前题目之前的题目;不支持循环引用。
|
||||
- 更新 `visible_rule` 需传**完整**的 `{logic, conditions}` 对象(整体覆盖);要保留现有显隐条件就必须把当前 `visible_rule` 原样带回;传 `null`、省略 `visible_rule` 或传空 `conditions` 都会使题目始终显示。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["q_001", "==", "是"],
|
||||
["q_003", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
@@ -82,11 +133,13 @@ lark-cli base +form-questions-update \
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** — 执行前必须向用户确认。
|
||||
|
||||
1. 先用 `+form-questions-list` 获取现有问题及其 `id`
|
||||
2. 构造包含 `id` 的更新数组
|
||||
3. 执行命令并报告更新结果
|
||||
1. 先用 `+form-questions-list` 获取现有问题及其 `id` 和完整配置。
|
||||
2. 以现有配置为基线,只修改用户明确要求变化的字段;要保留的字段必须原样带回。
|
||||
3. 构造包含 `id` 和目标完整配置的更新数组。
|
||||
4. 执行命令并报告更新结果。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -4,142 +4,13 @@
|
||||
|
||||
更新视图筛选配置。
|
||||
|
||||
## 1. 顶层规则
|
||||
## 1. filter 结构
|
||||
|
||||
`--json` 就是一个 filter 条件对象,结构见公共协议 SSOT [lark-base-filter-condition.md](lark-base-filter-condition.md),即 `{logic?, conditions?}`。此处 `conditions` 中的 `field` 引用**数据表字段名或字段 id**。
|
||||
|
||||
- `--json` 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
- 支持 `filter` 的视图类型:`grid`、`kanban`、`gallery`、`calendar`、`gantt`。
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 推荐命令
|
||||
## 2. 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +view-set-filter \
|
||||
@@ -149,7 +20,7 @@ lark-cli base +view-set-filter \
|
||||
--json '{"logic":"and","conditions":[["状态","intersects",["Doing"]],["负责人","intersects",[{"id":"ou_xxx"}]],["截止时间","empty"]]}'
|
||||
```
|
||||
|
||||
## 5. JSON 写法
|
||||
## 3. JSON 写法
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -170,14 +41,16 @@ lark-cli base +view-set-filter \
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 使用建议
|
||||
完整的 operator 列表与各字段类型的 value 写法(`text` / `number` / `select` / `user` / `datetime` / `formula` / `lookup` 等),见 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 4. 使用建议
|
||||
|
||||
- 先读取当前筛选配置,理解现有 `logic` 和 `conditions` 的组合关系;只替换用户要求变更的条件,未提到的条件默认保留。
|
||||
- 优先传字段 id,不要依赖字段名。
|
||||
- 拿不准字段 type 或真实取值时,先用 `+field-list` / `+record-list` 确认,再按对应字段类型的 value 写法构造条件;别按字段名猜 type、凭印象猜枚举取值。
|
||||
- 需要清空全部筛选时,直接传 `{"conditions":[]}`。
|
||||
|
||||
## 7. 易错点
|
||||
## 5. 易错点
|
||||
|
||||
- 本 tuple DSL 由 `+view-set-filter` 与 `+record-list` / `+record-search` 的 `--filter-json` 共用;不要写成 `+data-query` 的对象风格 `{"field_name":...,"operator":...}`(会报校验失败)。
|
||||
- 标量类字段(`text` / `number` / `datetime` 等)的 value 用标量、别包成数组(各类型详见 value 写法一节)。
|
||||
@@ -186,6 +59,7 @@ lark-cli base +view-set-filter \
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前 filter 或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 8. 参考
|
||||
## 6. 参考
|
||||
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md):filter/visible_rule 条件结构公共协议 SSOT
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
|
||||
@@ -16,14 +16,16 @@ metadata:
|
||||
|
||||
## 身份
|
||||
|
||||
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
|
||||
按**日程归属**选身份:
|
||||
|
||||
- 查看/管理登录用户本人的日程 → `--as user`(默认,绝大多数场景)。
|
||||
- 查看/管理 bot 自己创建/拥有的日程 → `--as bot`
|
||||
|
||||
```bash
|
||||
# BAD — bot 身份查用户日程,返回空列表
|
||||
lark-cli calendar +agenda --as bot
|
||||
|
||||
# GOOD — user 身份查日程
|
||||
# 用户本人日程 → user
|
||||
lark-cli calendar +agenda --as user
|
||||
# bot 自建或参与的日程 → bot
|
||||
lark-cli calendar +agenda --as bot
|
||||
```
|
||||
|
||||
## Shortcuts
|
||||
@@ -48,6 +50,8 @@ lark-cli calendar +agenda --as user
|
||||
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
日程描述统一使用 `description` 一个字段,按 **Markdown** 富文本处理。读取日程时 `description` 返回 Markdown 富文本(仅有纯文本描述时返回该纯文本);创建/更新日程时也通过 `--description` 传入 Markdown。
|
||||
|
||||
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||
|
||||
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`。
|
||||
@@ -186,6 +190,8 @@ lark-cli contact +search-user --query <query> --as user
|
||||
lark-cli im +chat-search --query <query> --as user
|
||||
```
|
||||
|
||||
> 搜索用户接口不支持 bot 身份,必须用 `--as user`;搜到的 `ou_` open_id 用于日程参与人操作(如添加日程参与人)。
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
|
||||
@@ -32,19 +32,19 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| `--summary <text>` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 |
|
||||
| `--start <time>` | 是 | 开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`) |
|
||||
| `--end <time>` | 是 | 结束时间(ISO 8601) |
|
||||
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||
| `--description <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ` 到 `### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(直接粘贴裸链接,或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径` 或 `-`(stdin)读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。|
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀。bot 可作为合法参会人,无需剔除 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||
> `--description` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
||||
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
|
||||
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -50,12 +50,13 @@ lark-cli calendar +room-find \
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
|
||||
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
||||
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。**不要传入 bot 的 open_id**:bot 是虚拟身份,不占会议室席位、无会议室偏好,传入只会干扰推荐结果。 |
|
||||
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai`) |
|
||||
|
||||
## 规则
|
||||
|
||||
- 构造 `--attendee-ids` 前,先剔除 bot 参会人:bot 不占席位、无偏好,不应参与会议室推荐。
|
||||
- 多个 `--slot` 会由 CLI 内部并发调用单时间块接口,再聚合成一次输出
|
||||
- `+room-find` 的时间输入必须是**确定时间块**,不是时间区间搜索。
|
||||
- 如果是重复性日程,必须校验返回中的 `reserve_until_time`(该会议室最晚可预约时间)是否覆盖 `event-rrule` 对应的重复范围。
|
||||
|
||||
@@ -39,6 +39,7 @@ lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 参与人含 **bot**:无需为 bot 查询忙闲。bot 是虚拟身份,可并行多个会议、无忙闲语义,检查它没有意义。
|
||||
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||
|
||||
@@ -45,7 +45,7 @@ lark-cli calendar +suggestion \
|
||||
| ------------------------------- | ----- | ------------------------------------------------------------------- |
|
||||
| `--start <time>` | 否 | 搜索区间开始时间(支持日期/ISO 8601等格式,默认**当前时间**) |
|
||||
| `--end <time>` | 否 | 搜索区间结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户(`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔 |
|
||||
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户(`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔。**不要传入 bot 的 open_id**:bot 是虚拟身份,可并行多个会议、无忙闲语义,传入会干扰推荐时段的忙闲计算。 |
|
||||
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--duration-minutes <min>` | 否 | 会议时长(分钟)。优先使用用户显式指定的值,若未指定则尝试根据上下文推断,推断失败则不传 |
|
||||
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai`) |
|
||||
|
||||
@@ -43,7 +43,7 @@ lark-cli calendar +update \
|
||||
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID,详见 [重复性日程操作规范](lark-calendar-recurring.md) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用 `primary`) |
|
||||
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
||||
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
|
||||
| `--description <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径` 或 `-`(stdin)读取。仅在显式传入时更新;传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。 |
|
||||
| `--start <time>` | 否 | 新开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`)。更新日程时间时必须同时传 `--end` |
|
||||
| `--end <time>` | 否 | 新结束时间(ISO 8601)。更新日程时间时必须同时传 `--start` |
|
||||
| `--rrule <rrule>` | 否 | 新重复规则(RFC5545)。**不要使用 COUNT;如需限制次数,推算后转为 UNTIL** |
|
||||
@@ -58,9 +58,12 @@ lark-cli calendar +update \
|
||||
|
||||
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
|
||||
- 对 `--summary`、`--description`,CLI 以“是否显式传入该 flag”判断是否更新,而不是以“值是否为空”判断;如果显式传入空字符串,会把对应字段清空。
|
||||
- 日程描述统一走 `--description`(按 Markdown 富文本处理)。
|
||||
- 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
- 只想增删参会人或会议室时,不需要同时传 `--summary`、`--start`、`--end` 等日程字段。
|
||||
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||
- bot 可作为合法参会人添加,无需剔除。
|
||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: lark-doc
|
||||
description: "飞书云文档(Docx / Wiki)内容操作:读取、创建、编辑文档,插入或下载图片附件,以及操作思维笔记。用户提供文档 URL/token(包括 doubao.com 的 /docx/、/wiki/)时使用;按 URL 路径/token 而非域名路由。文档内嵌资源按读取参考中的统一规则分流。文档评论走 lark-drive;表格或 Base 内部数据操作不在本 skill。"
|
||||
version: 2.0.0
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -9,41 +10,75 @@ metadata:
|
||||
|
||||
# docs
|
||||
|
||||
## 场景与 Shortcut 路由
|
||||
**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。**
|
||||
|
||||
**CRITICAL:先判断场景,再读取该场景的参考文件;不要在任务开始时一次性读取全部参考文件。每个文件只在首次进入对应阶段时读取一次。**
|
||||
```bash
|
||||
# 常用示例
|
||||
lark-cli docs +fetch --doc "文档URL或token;若 URL 存在 #share-... 锚点,优先使用锚点方式读取,不要全文拉取"
|
||||
lark-cli docs +create --content '<title>标题</title><p>内容</p>'
|
||||
lark-cli docs +update --doc "文档URL或token" --command append --content '<p>内容</p>'
|
||||
```
|
||||
|
||||
**身份:文档操作默认使用 `--as user`**
|
||||
## 前置条件 — 执行操作前必读
|
||||
|
||||
优先使用 `lark-cli docs +<verb>` Shortcut;思维笔记使用独立的 `mindnotes` 命令。
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)(写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
|
||||
### 文档正文
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
|
||||
- **读取 / 摘要 — [`+fetch`](references/lark-doc-fetch.md)**:先读参考再获取文档。只读或摘要默认用 `simple`;更新前定位用局部 `with-ids`;保真改写才读 `full`;带 `#share-...` 的选区链接按原 URL 传入。
|
||||
- **从零创作 — [`创建工作流`](references/lark-doc-create-workflow.md) → [`+create`](references/lark-doc-create.md)**:先完整执行创建工作流,**简单任务不是跳过的理由**;通过 Publish Gate 后再创建文档。
|
||||
- **导入 / 空文档 — [`+create`](references/lark-doc-create.md)**:仅创建空文档或原样导入用户提供的完整内容时,跳过创建工作流。
|
||||
- **编辑 / block 直达链接 — [`+update`](references/lark-doc-update.md)**:语义改写、润色、重组、补写或排版时先完整读取参考,按推荐流程 fetch 后局部更新;明确旧文本 → 新文本可直接 `str_replace`,但写后必须 fetch 验证;每次更新后重新获取最新 block ID;block 直达链接也按该参考生成。
|
||||
> **格式选择规则(全局):**
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML。
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
### 辅助能力
|
||||
## 快速决策
|
||||
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
|
||||
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
|
||||
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
||||
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型;Mermaid 可由主 Agent 直接插入,SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
- 文档内容中出现嵌入的 `<sheet>`、`<bitable>` 或 `<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
|
||||
|
||||
- **临时文件、解析与统计 — [`+script`](references/lark-doc-script.md)**:创建名称唯一的临时 XML,直接解析文档 URL / token 或本地 XML / Markdown、将 Markdown 转为 XML,或统计文档总字数 / 总字符数。
|
||||
- **历史版本 — [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md)**:查询、回滚文档历史版本或检查回滚任务状态。
|
||||
| 标签 / 属性 | 提取字段 | 切到技能 |
|
||||
|-|-|-|
|
||||
| `<sheet token="..." sheet-id="...">` | `token` -> spreadsheet_token, `sheet-id` | [`lark-sheets`](../lark-sheets/SKILL.md) |
|
||||
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
|
||||
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<vc-transcribe-tab vc-node-id="...">` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id <vc-node-id>` |
|
||||
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
|
||||
|
||||
### 资源、画板与思维笔记
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
- **插入本地素材 — [`+media-insert`](references/lark-doc-media-insert.md)**:在文末插入本地图片或文件。
|
||||
- **预览素材 — [`+media-preview`](references/lark-doc-media-preview.md)**:预览文档中的图片、附件或素材。
|
||||
- **下载素材 — [`+media-download`](references/lark-doc-media-download.md)**:下载文档中的图片、附件、素材或画板缩略图。
|
||||
- **Docx 封面 — [`+resource-download` / `+resource-update` / `+resource-delete`](references/lark-doc-resource-cover.md)**:下载、更新或删除 Docx 封面。
|
||||
- **画板 — [`画板工作流`](references/lark-doc-whiteboard.md)**:创建或更新画板时先读取工作流;更新已有画板必须复用现有 token,禁止新建空白画板;底层写入优先使用 [`whiteboard +update`](../lark-whiteboard/references/lark-whiteboard-update.md),`docs +whiteboard-update` 仅为别名。
|
||||
- **思维笔记 — `mindnotes`**:已有思维笔记走 [`思维笔记链路`](references/lark-doc-mindnote.md);新建思维笔记走 [`lark-doc-whiteboard`](references/lark-doc-whiteboard.md)。
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
### 认证与 Scope
|
||||
|
||||
先执行 `docs` / `mindnotes` Shortcut,不预读 [`lark-shared`](../lark-shared/SKILL.md) 或预跑 `auth status --verify`;仅遇到未认证、token / 身份或 scope 错误时读取该 Skill,修复后重试。认证、身份或 scope 管理请求则直接使用该 Skill。
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
||||
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
| [`+resource-download` / `+resource-update` / `+resource-delete`](references/lark-doc-resource-cover.md) | Download, update, or delete a Docx cover image resource with `--type cover` |
|
||||
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
|
||||
|
||||
## 不在本 Skill 范围
|
||||
|
||||
- **Drive 文件级操作**:找文档、导入导出、云空间文件上传 / 下载 / 权限管理 → [`lark-drive`](../lark-drive/SKILL.md)。复制文档、创建副本或另存为副本时,按其指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
|
||||
- **文档评论**:添加、查看、回复评论或增删 reaction → [`lark-drive`](../lark-drive/SKILL.md)。
|
||||
- **文档内嵌资源下钻**:处理不在本 Skill 范围的内嵌资源(如电子表格或 Base 内部数据)时,统一读取 [`lark-doc-fetch.md`](references/lark-doc-fetch.md#处理文档内嵌资源) 的「处理文档内嵌资源」,提取 token / ID 后按该节切到对应 Skill 或命令;根 Skill 不重复维护标签分发表。
|
||||
- 文档评论管理 → [`lark-drive`](../lark-drive/SKILL.md)
|
||||
- 电子表格或 Base 的数据操作 → [`lark-sheets`](../lark-sheets/SKILL.md) / [`lark-base`](../lark-base/SKILL.md)
|
||||
- 云空间文件上传、下载、权限管理 → [`lark-drive`](../lark-drive/SKILL.md)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Genre Contract: Business Analysis / 商业分析 (`report.business_analysis`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;结论前置、具体、条件化;模型只用于改变比较或暴露约束,不用管理黑话代替判断 |
|
||||
| 内容逻辑 | 围绕一个具体决策,比较现状 / 不行动与真实替代项;用统一目标和口径评价价值、全周期成本、风险、约束与可实施性,给出推荐、暂缓或验证门及翻转条件 |
|
||||
| 事实 / 边界 | 事实、估算、假设、未知和外部依赖分开;数字标来源、时点、单位、口径和置信范围;利益相关方、不可货币化影响和权限边界显著;分析建议不等于批准或承诺 |
|
||||
| 错误 | 为预选方案找论据;无现状基准或真实替代项;口径不一却排名;单一 ROI / BCR / 评分替代平衡判断;套 SWOT;估算冒充事实;忽略全周期成本、依赖或分配影响;未获批写成已承诺;建议不回链证据 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
比较投资、资源、市场、产品、经营或供应选项并支持判断,但正文不要求具名决策者作出选择 / 批准,也不形成授权、资源拨付或执行承诺入口。`商业`、`市场分析`、`SWOT`单独只用于召回;回答研究问题走 [`research-report.md`](research-report.md),纯指标解读走 [`data-report.md`](data-report.md),命中上述 ask / 授权入口时走 Workplace Proposal,接口、不变量和实现取舍为主走 Technical RFC。
|
||||
|
||||
## 子类型
|
||||
|
||||
投资 / 资源配置;build-buy-partner 或 vendor;市场进入 / 扩张;产品 / 组合优先级;经营模式 / 流程;定价 / 商业模式;高不确定性的试点或阶段门。分析深度随金额、复杂度、不可逆性、影响范围和风险提高。
|
||||
|
||||
## 证据与方法
|
||||
|
||||
- 定义问题、目标、成功标准、范围、约束、决策 owner / 时点和现状 / 不行动基准;记录选项生成与排除理由。
|
||||
- 对每个可行选项用相同维度比较收益、全生命周期成本、时间、能力 / 依赖、风险、受影响方、不可货币化影响和可逆性。
|
||||
- 现状数据与预测分开;按需说明币种、价格时点、折现和估算方法。不得从官网标价推断销量、收入或份额。
|
||||
- 对可能翻转结论的假设做范围、情景或敏感性分析,并给 switching value、决策门或验证信号;评分模型须解释权重和证据,不能只报总分。
|
||||
- 缺目标、成功标准、基准或可行选项时只产出 decision frame / options discovery;关键估算用 `[成本区间待核]` 和验证计划,可能翻转结论且无法界定时标记 `blocked`。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
推荐与条件 → case for change / 目标 / 现状基准 → 选项生成、排除理由与同口径比较 → 关键假设、风险、情景与翻转条件 → 建议为何优于替代 → 阶段门、监测 / 学习计划与未决条件。把现状当真实选项,用区间和场景替代伪精确单点,显著说明谁获益、谁承担成本,以及什么新证据会改变建议。
|
||||
@@ -1,32 +0,0 @@
|
||||
# Genre Contract: Data Report / 数据报告 (`report.data_report`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;准确、可复算、少形容词;标题表达发现、对象和时点,并保留不确定性 |
|
||||
| 内容逻辑 | 先建立指标契约和可比基线,再回答发生了什么、为何重要、还不能断言什么;观测、解释假设与行动条件分开,限制紧邻相关结论 |
|
||||
| 事实 / 边界 | 核心指标标定义、单位、分子分母、总体 / 分群、时间窗、来源 / 版本、更新时间和修订状态;比较须同口径,估计须披露可得不确定性,敏感小群体须汇总、抑制或限制访问;数据图须标轴、单位、分母、时点和来源,并提供文字等价信息 |
|
||||
| 错误 | 只列数字;隐藏分母或口径变化;不可比数据排名;选择性窗口 / 分群;相关性当因果;图轴、单位或来源缺失;统计显著冒充效应大小或业务胜出;伪精确;限制藏在附录 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
解读已定义指标、趋势、分布、漏斗、监控、估计或实验观察值。`有数据`、`有数字`、`分析一下`单独不决定路由;研究问题、抽样和可推广性为主走 [`research-report.md`](research-report.md),比较商业选项走 [`business-analysis.md`](business-analysis.md),组织状态、偏差和下一步走 Workplace 周期报告。
|
||||
|
||||
## 子类型
|
||||
|
||||
- KPI / 经营表现与趋势;分群、cohort 与分布;漏斗 / 路径与监控异常。
|
||||
- A/B 或实验 readout;设计和推断不足时只能报告观察值,不宣布因果胜出。
|
||||
- 预测、估计、修订或统计简报;须标模型 / 假设、适用期和修订状态。
|
||||
|
||||
## 证据与方法
|
||||
|
||||
- 保留可复算的基数、过滤、聚合、估计区间和质量说明;比较前核对定义、总体、时间窗、分母和处理方法。
|
||||
- 按误解风险同时给绝对值、绝对变化、相对变化和长期基线;不用多余小数位制造精确感。
|
||||
- 覆盖、缺失、偏差、口径变化和修订若会改变解释,须与对应发现同处,并说明可能方向、规模和影响。
|
||||
- 描述性差异不得写成因果;解释标为待验证假设。统计显著性不等于效应大小、实际重要性或完整决策依据。
|
||||
- 缺定义、分母、时间或来源时使用 `[指标定义待核]`、`[分母待核]`,对应值不得进入结论;不可比数据分开展示。核心决策依赖的质量缺口无法关闭时标记 `blocked`。
|
||||
|
||||
## 结构与高质量写法
|
||||
|
||||
关键发现与决策限制 → 指标契约 / 数据质量 → 总览与基线 → 必要分维、分布和反例 → 可支持的解释与待验证假设 → 条件式行动 / 验证门 → 方法、修订和来源。每段按“观测 → 基线 / 背景 → 限制 → 含义”推进;复杂图同时给出文字结论和必要精确值,任何视觉不得成为唯一证据。
|
||||
@@ -1,38 +0,0 @@
|
||||
# Genre Contract: Email / 邮件 (`platform.email`)
|
||||
|
||||
## 核心定位(硬约束)
|
||||
|
||||
- 交付物是可复制到邮件客户端的邮件成稿,不代表已发送,也不执行收件人查询、邮件发送、草稿箱或邮箱管理;实际邮件操作切到 `lark-mail`。
|
||||
- `presentation_mode` 默认使用 `formal`;仅在用户明确要求其他表达方式且不违反组织规范或所选 content contract 时调整。全文禁止使用 emoji,包括主题、正文、列表标记、组件图标和装饰符号。
|
||||
- 默认只使用短段落、列表和普通链接等基础结构。只有已由用户说明、平台文档或可信配置确认目标平台完整支持飞书富文本时,才允许使用 `rich` 或 rich block;不得根据“邮件”“HTML 邮件”、飞书文档承载或平台名称自行推断支持。
|
||||
- 一封邮件只承担一个主要沟通任务;主题、首段、正文和行动请求围绕同一目的。不得编造发件人身份、收件人关系、事实、权限、承诺、截止时间、附件或已完成动作;缺失但必需的信息使用清楚的占位符。
|
||||
- 不使用封面或目录。即使已确认平台能力,表格、图片、`callout`、画板及其他 rich block 也只能在信息确有需要时使用,并确保复制、投递和接收后的语义完整。
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用户明确要“写邮件、邮件成稿、邮件草稿、邮件措辞、email、e-mail”,或要求起草回复、跟进、通知、邀约、外联邮件时使用,内容保存在哪里不影响本合同生效。
|
||||
|
||||
查看、搜索、发送、回复或管理邮箱中的真实邮件属于 `lark-mail` 操作;邮件系统说明、邮件数据分析、营销方案或把邮件作为信息来源时不触发本合同。若任务既要成稿又要实际发送,先按本合同形成并确认内容,再切到 `lark-mail` 执行发送。
|
||||
|
||||
## 邮件主任务
|
||||
|
||||
| 主任务 | 内容脊柱 |
|
||||
|-|-|
|
||||
| 请求 / 决策 | 目的或结论 → 必要背景 → 明确请求 / 选项 → 期望时间或下一步 |
|
||||
| 通知 / 同步 | 关键变化 → 影响范围 → 接收方需知 / 需做 → 时间点与联系入口 |
|
||||
| 回复 / 跟进 | 对应的前情 → 新信息或直接答复 → 未决事项 → 下一步 |
|
||||
| 邀约 / 外联 | 联系缘由 → 与收件人的相关性 → 具体提议 → 低成本回应方式 |
|
||||
| 致歉 / 问题沟通 | 承认影响 → 已确认事实 → 补救动作 → 后续安排与边界 |
|
||||
|
||||
## 成稿要求
|
||||
|
||||
- 成稿先给主题,再给正文;只有用户要求或材料明确时才列出收件人、抄送人等信封字段。主题准确表达对象、事项或所需行动,不使用标题党、空泛寒暄或无信息量的“重要通知”。
|
||||
- 称呼依据已知关系和语境选择;关系不明时使用稳妥中性的称呼或显式占位符,不擅自套用亲密、职级或性别称谓。
|
||||
- 首段尽快说明来意、结论或与既有线程的关系。背景只保留收件人理解、判断或行动所需的信息,不把完整报告、会议纪要或思考过程原样搬入邮件。
|
||||
- 行动请求写清需要谁在何时以何种方式完成什么;材料没有给出负责人或时间时,不自行补造。多个并列事项使用列表,优先让收件人能直接逐项回应。
|
||||
- 提及链接、附件或引用材料时说明其用途;未实际提供或上传的材料写成待补占位符,不声称“见附件”。回复和跟进邮件只补充新信息,不机械复述整个线程。
|
||||
- 结尾与邮件目的匹配:请求类明确回应方式,通知类说明无需动作或下一节点,外联类保留易于拒绝或调整的空间。署名仅使用已知身份;身份不明时使用占位符,不虚构姓名、团队或联系方式。
|
||||
|
||||
## 交付前检查
|
||||
|
||||
确认收件人能从主题和首段判断“为什么收到、需要知道或做什么”,事实、责任人、时间和附件状态均有依据,正文没有无关铺垫或重复,语气符合关系与风险,全文无 emoji;若使用 rich block,已有目标平台支持飞书富文本的确认依据;复制到邮件客户端后仍清晰可读,且未把“成稿”误写成“已发送”。
|
||||
@@ -1,27 +0,0 @@
|
||||
# Genre Contract: Execution Plan / 执行计划 (`workplace.execution_plan`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | `normal`;以交付和判断为单位,具体、紧凑、可推进;计划可信度来自依赖、产能和验收闭环,不来自章节数量或精确到没有依据的日期 |
|
||||
| 内容逻辑 | 从已批准结果、成功标准、范围和约束出发,按“交付物 / 工作流 → 依赖与关键路径 → 带退出条件的里程碑 → owner / 接口 / 资源 → 风险触发与备选 → 治理、变更与验收”推进 |
|
||||
| 事实 / 边界 | 区分已确认承诺、估算、假设和待定项;时间、owner、预算、产能、权限、依赖与验收方须可追溯且算术相容;未批准方向不得写成承诺,关键资源或安全前提未知时收窄计划或 `blocked` |
|
||||
| 错误 | 任务清单冒充计划、活动无交付物 / 完成定义、里程碑只是日期、排期不服从依赖与产能、所有事项同优先级、接口或验收方缺失、风险无预警信号 / 动作 / owner、变更后不更新基线,任一出现即失败 |
|
||||
|
||||
## 适用与消歧
|
||||
|
||||
用于方向和目标已定后,组织一次性项目、迁移、发布、活动战役、专项治理或跨团队变更。主要任务仍是选择方向、申请预算 / 资源或授权时走 `proposal.md`;比较策略选项且不形成批准入口走 `business-analysis.md`;发布已授权规则 / 通知走 `formal-doc.md`;重复确定路径走 `sop-tutorial.md`;报当前状态走 `weekly-report.md`。
|
||||
|
||||
“项目计划、执行方案、实施计划、营销策划”只作召回词。营销策划若仍在决定打法或预算,按上述 Proposal / Business Analysis 消歧;只有已定打法的协同落地走本合同。
|
||||
|
||||
## 可执行性与证据
|
||||
|
||||
- 先写可验收结果、范围 / 非范围、约束和最迟决策点;再按交付物而非部门名称拆工作包。每个关键工作包说明 owner、输入 / 输出、依赖、完成定义和验收方。
|
||||
- 标出关键路径、可并行项、阶段入口 / 退出条件与资源瓶颈;日期由依赖、产能和必要审批 / 制作 / 校准时间推导。无法推导时用相对时间、区间或具体占位,不补造精确排期。
|
||||
- 风险写预警信号、影响、预防 / 响应动作、决策 owner 和备选路径;备选必须说明何时切换及切换后的安全或业务终态,不写“加强沟通”。
|
||||
- 治理只保留会产生判断的节奏:接口、升级条件、决策权、范围 / 基线变更和重新验收。密集对应关系可用一张排期、依赖或责任表,但表格不能替代关键路径和取舍说明。
|
||||
|
||||
## 高质量写法
|
||||
|
||||
让每个目标能一路回链到交付物、里程碑和工作包,让每个日期能回链依赖与产能,让每个风险能回链触发后的动作。资源不足时缩范围、分阶段或设决策门,不用“全渠道、全覆盖、同步推进”制造伪可行性。
|
||||
@@ -1,36 +0,0 @@
|
||||
# Genre Contract: Formal Document / 内部正式材料 (`workplace.formal_doc`)
|
||||
|
||||
## 体裁规则表(硬约束)
|
||||
|
||||
| 规则项 | 规则 |
|
||||
|-|-|
|
||||
| presentation_mode / 表达模式 | 固定 `formal`;格式中立的内容稿完成后再应用。庄重、准确、简洁、直接;正式性来自真实权威、事实、边界、责任和生命周期,不来自套话、层级或装饰 |
|
||||
| 允许 block | `title`(完整文稿最多 1 个)、`p`、`h1`、`h2`、`h3`、`h4`;标题层级连续且不超过四级 |
|
||||
| 限用 block | `ul`、`ol`容器及`li`子块仅承载真实并列或顺序;`table`容器及`thead`、`tbody`、`tfoot`、`tr`子块仅承载多对象同字段信息;`img`、`figure`仅承载有必要证据作用、来源说明和文字等价信息的材料 |
|
||||
| 禁止 block | 禁止未列入允许 / 限用清单的类型,包括`callout`、`checkbox`、`grid`容器及`column`子块、`whiteboard`、`blockquote`、`pre`、根级`code`和`hr`;禁止装饰色、贴纸、伪红头、伪印章和无证据作用的配图 |
|
||||
| 内容逻辑 | 先按读者任务选择规则 / 制度、已批准通知 / 安排、检查整改 / 台账或已核定正式说明之一;只写完成该任务所需的对象、依据、要求 / 发现、责任、核验和生命周期,不混写子类型 |
|
||||
| 事实 / 边界 | 只把已确认的授权、要求、事实和立场写成定论;来源陈述、原始记录、已复核事实和推断分开;外发前确认保密、商业秘密、个人信息、素材权利和发布权限;关键缺口使 `Publish Gate = blocked` |
|
||||
| 错误 | 因“正式”误判公文,把本 leaf 当方案 / 总结 / 简报兜底,伪造批准 / 生效,用通知偷渡未获授权的新规则,网络素材冒充本单位事实,检查线索写成责任结论,或措施与发现不对应,任一出现即失败 |
|
||||
|
||||
## 适用与收口
|
||||
|
||||
用于把已授权的非公文组织规则或安排、可复核的检查整改记录,或已核定的组织立场写成正式载体,使读者能够判断适用范围、应采取的行动、记录状态或核心立场。
|
||||
|
||||
待批准方向走 `proposal.md`;复杂一次性执行走 `execution-plan.md`;重复操作步骤走 `sop-tutorial.md`;党政机关公文走 `official-redhead.md`;高层简报走 `memo-brief.md`;周期状态走 `weekly-report.md`;学习总结走 Retrospective / Report。`正式、制度、通知、方案、计划、总结、简报、讲话稿`等词单独不触发本体裁,本体裁也不是不确定请求的 fallback。
|
||||
|
||||
## 按读者任务选择唯一内容路径
|
||||
|
||||
| 读者任务 | 内容主线 |
|
||||
|-|-|
|
||||
| 判断持续规则 | 目的与权威 → 适用 / 不适用范围 → 必要定义 → 规范要求 → 责任、例外与升级 → 生效、维护、复审和替代 |
|
||||
| 执行已批准通知 | 发布主体与批准状态 → 受影响对象及范围 → 已确认事项与生效时间 → 动作、责任与期限 → 例外、反馈和联系人 |
|
||||
| 复核检查整改 | 对象、范围、方法与证据状态 → 每项可观察发现、标准、影响和已支持原因 → 对应措施、责任与期限 → 核验、关闭证据和变更痕迹 |
|
||||
| 理解已核定立场 | 讲者或发布主体、场合、受众与时长 → 核心立场 → 必要事实和理由 → 期望理解或行动;不混入制度效力 |
|
||||
|
||||
## 证据与高质量写法
|
||||
|
||||
- 规则类按需写维护责任、版本、批准、生效、复审和替代状态;稳定描述做什么、谁负责、何时生效,易变操作方法链接到受控 SOP。规范词优先沿用组织现有定义,强度不明时标`[规范强度待确认]`。
|
||||
- 检查整改区分用户陈述、原始记录、已复核事实和待补证线索;关键日期、数量或结论证据不足时就近标`[证据待补:补证动作]`,不得推断原因或责任;归档补正保留原记录。
|
||||
- 检查措施必须对应具体发现并可核验;已批准通知只传达授权范围内的事项;正式讲话只使用已核定立场,并按真实语速朗读校验。
|
||||
- 使用主动句、明确主体和一致术语,一句只表达一个事实、判断、要求或许可;清单严守用户指定数量与字段,不机械补背景或文控字段。
|
||||
- 批准者、依据、权限、适用范围、生效状态或发布条件不明时使用具体占位并保持草案;不得以版式、标题或署名暗示已经批准、签发或生效。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user