Compare commits

..

26 Commits

Author SHA1 Message Date
zhmushan
3ad25048d0 test: pin miaoda-cli to frontend alpha for +init acceptance
Temporary acceptance-only change: point +init at
@lark-apaas/miaoda-cli@0.1.24-alpha.03a64f0 (contains the frontend ->
vite-react mapping) so reviewers can validate `apps +init` on frontend
apps before the miaoda-cli release lands on npm latest. Do NOT merge;
revert to @latest once miaoda-cli MR #129 is published.
2026-07-28 20:07:51 +08:00
zhmushan
0897d84c5b docs: address CodeRabbit feedback on frontend app-type refs
- create.md: state the app-type enum is matched exactly (lowercase), drop
  the incorrect claim that the CLI normalizes case
- local-dev.md: scope database debugging to full_stack (frontend/html have
  no DB) and add the +release-get finished-status poll to the frontend flow
2026-07-28 20:06:37 +08:00
zhmushan
a7262ef2fc docs: add frontend app-type to lark-apps command references
SKILL.md's routing table already covered frontend, but the per-command
reference docs still enumerated only html/full_stack. Update create/list/get
enum values, add a frontend local-dev section, and note frontend in the
release-create entry so agents document the third app type consistently.
2026-07-28 19:55:16 +08:00
zhmushan
dd8255d59d docs: add frontend app-type guidance to lark-apps skill 2026-07-28 11:32:29 +08:00
zhmushan
6aac6a1c4f docs: note FRONTEND in queryAppType comment 2026-07-28 11:31:05 +08:00
zhmushan
f859de0490 docs: clarify frontend app-type handling in apps +init 2026-07-28 11:30:33 +08:00
zhmushan
4222f998d3 feat: accept frontend app-type filter in apps +list 2026-07-28 11:29:34 +08:00
zhmushan
9b4077f3e9 feat: accept frontend app-type in apps +create 2026-07-28 11:28:47 +08:00
liangshuo-1
3d2c10cd0b fix(ci): validate static workflow identity (#2015) 2026-07-27 19:39:11 +08:00
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
127 changed files with 5542 additions and 6855 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -42,7 +42,6 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-cli-e2e-test/reports/
.lark-slides/
/notes/
/minutes/

View File

@@ -2,6 +2,53 @@
All notable changes to this project will be documented in this file.
## [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 +1685,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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))

View 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"
}

View 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)
}
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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}

View File

@@ -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)")

View File

@@ -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)
}

View 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()
}

View 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)
}
})
}
}

View File

@@ -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}

View File

@@ -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 {

View 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()
}

View 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)
}
}

View File

@@ -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) {

View 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
}
}

View 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 ""
}

View 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)
}
})
}

View 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"
}

View 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")
}
}

View 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 ""
}

View 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)
}
})
}
}

View 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 ""
}

View 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)
}
})
}
}

View 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
}

View 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)
}
}

View File

@@ -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.

View File

@@ -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
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.78",
"cpu": [
"x64",
"arm64",

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.78",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -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"

View File

@@ -13,7 +13,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
const createHint = "verify --app-type is html, frontend or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
// AppsCreate creates a new app.
var AppsCreate = common.Shortcut{
@@ -23,6 +23,7 @@ var AppsCreate = common.Shortcut{
Risk: "write",
Tips: []string{
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
`Example: lark-cli apps +create --name "工具页" --app-type frontend --description "纯前端工具"`,
`Example: lark-cli apps +create --name "活动页" --app-type html --description "活动报名"`,
},
Scopes: []string{"spark:app:write"},
@@ -30,7 +31,7 @@ var AppsCreate = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "name", Desc: "app display name", Required: true},
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "frontend", "full_stack"}},
{Name: "description", Desc: "app description"},
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
},
@@ -59,7 +60,7 @@ var AppsCreate = common.Shortcut{
}
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
// --app-type is constrained to the lowercase enum (html / full_stack) by the
// --app-type is constrained to the lowercase enum (html / frontend / full_stack) by the
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
// a server concern and is intentionally not surfaced by the CLI.
agent := envvars.AgentName()

View File

@@ -363,3 +363,18 @@ func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
}
}
// TestAppsCreate_AcceptsFrontend pins that --app-type frontend is a valid
// enum value and flows through to the request body as "frontend" verbatim.
func TestAppsCreate_AcceptsFrontend(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "frontend", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("frontend dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"app_type": "frontend"`) {
t.Fatalf("expected app_type frontend in body, got %s", got)
}
}

View File

@@ -39,7 +39,7 @@ const (
)
const (
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.24-alpha.03a64f0"
npmRegistry = "https://registry.npmmirror.com"
metaRelPath = ".spark/meta.json"
steeringRelPath = ".agent/skills/steering"
@@ -86,6 +86,10 @@ var appTypePolicies = map[string]appTypePolicy{
// no startup env vars to pull, no steering skills to sync, and no app sync.
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
"html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
// frontend (vite-react, a buildable front-end app) is intentionally NOT
// listed here: it takes the zero-value policy (install deps, pull env, sync
// skills) like full_stack, since it needs a build step — it is not a static
// HTML site and must not skip those steps.
}
// policyForAppType returns the +init control strategy for appType. Unlisted
@@ -438,6 +442,9 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
// --skip-install is appended per the app_type's policy (see appTypePolicy):
// types whose policy sets skipInstall (e.g. modern_html) skip the dependency
// install; others run it as usual.
// appType is forwarded verbatim (including "frontend") — the CLI does not
// translate the app type; mapping the app type to a concrete tech stack is the
// downstream tool's responsibility.
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"}
at := appType

View File

@@ -35,7 +35,7 @@ var AppsList = common.Shortcut{
Flags: []common.Flag{
{Name: "keyword", Desc: "fuzzy match on app name"},
{Name: "ownership", Desc: "ownership filter: all (created by me + shared with me) | mine | shared", Enum: []string{"all", "mine", "shared"}},
{Name: "app-type", Desc: "app type filter (html or full_stack)", Enum: []string{"html", "full_stack"}},
{Name: "app-type", Desc: "app type filter (html, frontend or full_stack)", Enum: []string{"html", "frontend", "full_stack"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},

View File

@@ -15,7 +15,7 @@ import (
// queryAppType fetches the app's type string from the server via
// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
// an app_id or a meta_token — the server resolves both. The server returns
// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
// uppercase app_type values ("HTML", "FRONTEND", "FULL_STACK", "MODERN_HTML");
// this function normalizes to lowercase. Returns an error when the API
// is unavailable or the response is malformed — callers must not proceed
// with a fallback type to avoid creating the wrong project scaffold.

View File

@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
}
}
collapseDescription(e)
filtered = append(filtered, e)
}
}

View File

@@ -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 (`![name](url)`; 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`, `![p](url)<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)

View File

@@ -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
}

View File

@@ -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())

View File

@@ -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 (`![name](url)`; 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`, `![p](url)<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

View 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.larkoffice.com"
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
}

View 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, "larkoffice"},
{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 ![a](https://example.com/a.png) more ![b](data:image/png;base64,xx)"
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", "![pic](./pic.png)",
"--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", "![pic](./pic.png)",
"--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", "![p](/etc/hosts)",
"--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)
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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'
}

View File

@@ -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("&amp;")
case '<':
out.WriteString("&lt;")
case '>':
out.WriteString("&gt;")
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("&amp;")
case '<':
out.WriteString("&lt;")
case '>':
out.WriteString("&gt;")
case '"':
out.WriteString("&#34;")
case '\'':
out.WriteString("&#39;")
default:
out.WriteRune(r)
}
}
return out.String()
}

View File

@@ -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&amp;c=3&#38;d=4&#x26;e=5&unknown;`
want := `https://example.com?a=1&amp;b=2&amp;c=3&#38;d=4&#x26;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: "**文号桂汛旱指202617号**\n**签发人XXX**\n**发布日期2026年7月13日**",
want: `<p><b>文号桂汛旱指202617号</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--&gt;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 --&gt; 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 &lt; b &amp;&amp; c &gt; 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, `&lt;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
}

View File

@@ -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 ""
}

View File

@@ -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_")
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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("&amp;")
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
}

View File

@@ -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,

View File

@@ -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") {

View File

@@ -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, ""))

View File

@@ -63,22 +63,28 @@ lark-cli auth login --domain apps
新建必先定 **app_type** 和**开发方式**两件正交的事修改已有先按「app_id 获取」指认到 app指认不到就问用户不擅自 `+create`。开发方式(本地 vs 云端)只看用户对"谁来写代码"的偏好,与应用复杂度、要不要数据库无关。
**app_type 三类边界**(先判"要不要把数据存到服务端",再判"纯展示还是有交互"
| 信号 | 判定 |
|---|---|
| 静态展示 / 单页 / PPT/deck / demo / 落地页 / 仪表盘 / UI mockup / 可交互原型 / 线框图 / 视觉探索 / 无后端状态 | `app_type=html`,加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) |
| 登录 / 数据库 / 持久化 / 多人协作 / 增删改查 / 报 / 投票 / 站会 / OKR / 泛称"系统·工具" | `app_type=full_stack` |
| 含数据库 / 后端持久化:登录 / 增删改查 / 报名·投票·站会存记录 / 多人协作 / 泛称"系统·工具"且明确要存数据 | `app_type=full_stack` |
| 纯静态展示(给人"看"的物料,无 JS 交互PPT/deck / demo / 落地页 / 报 / UI mockup / 线框图 / 静态仪表盘 / 视觉探索 | `app_type=html`,加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) |
| 有 JS 交互但无数据库(给人"用"的前端应用):可交互原型 / SPA / 表单校验 / 动态计算 / 调用外部 API / 泛称"工具·系统"但未明确要存数据 | `app_type=frontend`**默认倾向**:用户未明确提出数据库需求时默认引导 frontend不默认 full_stack |
| 类型模糊(尤其"要不要存数据"不清) | **追问**,话术偏向 frontend"看起来是个前端应用,需要保存数据吗?";确认要存数据再转 full_stack确认纯展示再转 html |
| 用户要自己写 / 本地 IDE·code agent / 拉源码到本地 / 交研发 | 本地开发,读 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) |
| 让妙搭 AI 云端生成 / 对话式 / 自己不碰代码 | 云端会话,读 [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
| 未表达"谁来写"偏好 | **必须先问**(本地代码开发 vs 云端 AI 生成);选定前不擅自选边、不暗示默认,不得以"需求不模糊"为由跳过提问直接 `+init` / `git clone` / `+session-create` / 首轮 `+chat` |
| 修改已有 + 当前目录是 `.spark/meta.json` 项目 | 直接继续本地按意图路由,不必问也不必判云端 |
| 修改已有 + 有云端偏好 | 云端会话;未表达偏好且非本地项目 → 默认本地;判不准先问 |
**类型升级**`frontend` 应用后续需要数据库/后端能力时,本地 CLI 不提供类型升级;引导用户到云端会话(打开 `https://miaoda.feishu.cn/app/{app_id}`),用自然语言描述后端需求(如"给这个应用加登录和数据存储")即可触发升级,无需特殊指令。
## 发布态护栏
- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。
- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`full_stack 应用):进应用编辑/开发态、管理与继续开发应用的入口。创意模式html应用开发态和发布态是同一个链接无需额外提供开发态链接。
- 发布态链接来源:`+release-get` 轮询 `finished``online_url` / `failed``error_logs`html full_stack 统一走 `+release-get`)。
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`full_stack / frontend 应用):进应用编辑/开发态、管理与继续开发应用的入口,也是 frontend 升级为 full_stack 的入口(云端会话)。创意模式html应用开发态和发布态是同一个链接无需额外提供开发态链接。
- 发布态链接来源:`+release-get` 轮询 `finished``online_url` / `failed``error_logs`html / frontend / full_stack 统一走 `+release-get`)。
- html 应用的主链路是创意模式开发方式:按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 初始化仓库、在仓库内产出 HTML 及关联文件,并通过 git commit / git push / `+release-create` / `+release-get` 发布部署。任何 git 操作clone / pull / push报错时先执行 `lark-cli apps +git-credential-init --app-id <app_id> --as user` 刷新本地 Git 凭证,再重试原 git 命令。如果刷新凭证也失败,**停止并向用户报告**:原始 git 错误、凭证刷新失败原因,以及是否可能是当前环境(操作系统、沙箱)限制导致(如 macOS Keychain 在沙箱中不可用、Linux 加密文件目录不可写等)。不要改走 `+html-publish`,也不要把 `+html-publish` 当作本地开发链路的 fallback。
- 创意模式html应用的链接格式为 `https://{租户域名}/page/{meta_token}`**开发态和发布态是同一个链接**(区别于 full_stack 应用两者分开)。此链接形似飞书文档链接。`+get --app-id <meta_token>` 可获取应用信息(含 `app_id``+get --app-id <app_id>` 可获取 `meta_token`。看到 `/page/xxx` 链接时,它是妙搭创意模式应用,不要当成飞书文档跳过。

View File

@@ -9,7 +9,7 @@
## 命令骨架
- 必填:`--name``--app-type`
- app type 语义取值为 `html` / `full_stack`CLI 会把输入归一成小写后校验
- app type 取值为小写 `html` / `frontend` / `full_stack`;框架按枚举精确校验(不做大小写归一),非法值直接报错
- 可选:`--description``--icon-url`
## 示例
@@ -17,6 +17,9 @@
```bash
lark-cli apps +create --name "客户调研问卷" --app-type html
lark-cli apps +create --name "JSON 格式化工具" --app-type frontend \
--description "纯前端交互工具,无需数据库"
lark-cli apps +create --name "审批系统" --app-type full_stack \
--description "部门审批系统,支持登录、提交申请、多级审批"
@@ -35,5 +38,5 @@ lark-cli apps +create --name "Demo" --app-type html --dry-run
创建后按用户路径继续:
- 本地应用开发(含 html full_stack读 [`lark-apps-local-dev.md`](lark-apps-local-dev.md)。
- 本地应用开发(含 html / frontend / full_stack读 [`lark-apps-local-dev.md`](lark-apps-local-dev.md)。
- 云端 Agent 生成/迭代:读 [`lark-apps-cloud-dev.md`](lark-apps-cloud-dev.md)。

View File

@@ -26,7 +26,7 @@ lark-cli apps +get --app-id app_xxx -q '.data.app.app_type'
| 字段 | 类型 | 说明 |
|------|------|------|
| `app_id` | string | 应用唯一标识 |
| `app_type` | string | 应用类型(如 HTML、FULL_STACK、MODERN_HTML |
| `app_type` | string | 应用类型(如 HTML、FRONTEND、FULL_STACK、MODERN_HTML |
| `name` | string | 应用显示名称 |
| `description` | string | 应用功能说明 |
| `icon_url` | string | 应用图标 URL |

View File

@@ -10,7 +10,7 @@
- 支持 `--keyword` 按应用名模糊搜索。
- `--ownership` 枚举:`all` / `mine` / `shared`(默认 `all` = 我创建的 + 共享给我的;`mine` = 仅我创建;`shared` = 仅共享给我)。
- `--app-type` 枚举:`html` / `full_stack`
- `--app-type` 枚举:`html` / `frontend` / `full_stack`
- 分页:`--page-size` 默认 20`--page-token` 传上一页 cursor。
## 示例

View File

@@ -1,6 +1,6 @@
# lark-apps 本地开发
适用用户要把妙搭应用full_stack 或 html源码拉到本地用本地 code agent/IDE 开发、调试数据库,再发布。
适用用户要把妙搭应用full_stack、frontend 或 html源码拉到本地用本地 code agent/IDE 开发、再发布。其中调试数据库仅 full_stack 适用frontend / html 无数据库)
## 新建 vs 已有应用
@@ -36,6 +36,32 @@ git push origin sprint/default
lark-cli apps +release-create --as user --app-id app_xxx --branch sprint/default
```
### frontend
纯前端应用vite-react无数据库。流程与 full_stack 基本一致——`+init` 装依赖、`npm run dev`、commit/push/release——差别是无 `+db-*` 调库步骤。后续需要数据库/后端能力时不在本地升级,按 SKILL.md「类型升级」引导到云端会话。
```bash
# 新建 frontend 应用
lark-cli apps +create --as user --name "JSON 格式化工具" --app-type frontend \
--description "纯前端交互工具,无需数据库"
# 初始化本地仓库(--dir 取值见下方「领域规则」,勿照抄此处示例值)
lark-cli apps +init --as user --app-id app_xxx --dir ./json-tool
# 进入仓库后按项目脚手架启动vite-react
cd ./json-tool
npm install
npm run dev
# 开发完成后:提交本次改动 -> git push origin sprint/default -> +release-create
git add <本次开发的文件>
git commit -m "feat: ..."
git push origin sprint/default
lark-cli apps +release-create --as user --app-id app_xxx --branch sprint/default
# 发布是异步的:用 +release-get 轮询到 status=finished 才算部署完成、拿到 online_url
lark-cli apps +release-get --as user --app-id app_xxx --release-id <上一步返回的 release_id>
```
### html
#### 首次开发(无 app无代码

View File

@@ -4,7 +4,7 @@
## 何时用
用于把应用的代码分支推进到发布流程html full_stack 统一走此入口)。
用于把应用的代码分支推进到发布流程html / frontend / full_stack 统一走此入口)。
## 命令骨架

View File

@@ -48,6 +48,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`

View File

@@ -32,13 +32,14 @@ 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 配合使用,仅关注当前日程信息 |
| `--description <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ``### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL直接粘贴裸链接或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径``-`stdin读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`|
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀 |
| `--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 分钟提醒。

View File

@@ -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 \|` + 分隔行 `\| --- \| --- \|`、以及图片 `![图片名](图片URL)`(标准 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,6 +58,8 @@ 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>`

View File

@@ -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 IDblock 直达链接也按该参考生成。
> **格式选择规则(全局):**
> - **创建 / 导入场景**`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)

View File

@@ -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 / 目标 / 现状基准 → 选项生成、排除理由与同口径比较 → 关键假设、风险、情景与翻转条件 → 建议为何优于替代 → 阶段门、监测 / 学习计划与未决条件。把现状当真实选项,用区间和场景替代伪精确单点,显著说明谁获益、谁承担成本,以及什么新证据会改变建议。

View File

@@ -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`
## 结构与高质量写法
关键发现与决策限制 → 指标契约 / 数据质量 → 总览与基线 → 必要分维、分布和反例 → 可支持的解释与待验证假设 → 条件式行动 / 验证门 → 方法、修订和来源。每段按“观测 → 基线 / 背景 → 限制 → 含义”推进;复杂图同时给出文字结论和必要精确值,任何视觉不得成为唯一证据。

View File

@@ -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已有目标平台支持飞书富文本的确认依据复制到邮件客户端后仍清晰可读且未把“成稿”误写成“已发送”。

View File

@@ -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 和备选路径;备选必须说明何时切换及切换后的安全或业务终态,不写“加强沟通”。
- 治理只保留会产生判断的节奏:接口、升级条件、决策权、范围 / 基线变更和重新验收。密集对应关系可用一张排期、依赖或责任表,但表格不能替代关键路径和取舍说明。
## 高质量写法
让每个目标能一路回链到交付物、里程碑和工作包,让每个日期能回链依赖与产能,让每个风险能回链触发后的动作。资源不足时缩范围、分阶段或设决策门,不用“全渠道、全覆盖、同步推进”制造伪可行性。

View File

@@ -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。规范词优先沿用组织现有定义强度不明时标`[规范强度待确认]`
- 检查整改区分用户陈述、原始记录、已复核事实和待补证线索;关键日期、数量或结论证据不足时就近标`[证据待补:补证动作]`,不得推断原因或责任;归档补正保留原记录。
- 检查措施必须对应具体发现并可核验;已批准通知只传达授权范围内的事项;正式讲话只使用已核定立场,并按真实语速朗读校验。
- 使用主动句、明确主体和一致术语,一句只表达一个事实、判断、要求或许可;清单严守用户指定数量与字段,不机械补背景或文控字段。
- 批准者、依据、权限、适用范围、生效状态或发布条件不明时使用具体占位并保持草案;不得以版式、标题或署名暗示已经批准、签发或生效。

View File

@@ -1,24 +0,0 @@
# Genre Contract: Meeting Minutes / 会议纪要 (`workplace.meeting_minutes`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;中性、精确、按议题和决定组织,用稳定标签区分决定、建议、未决和待确认,不重放发言顺序 |
| 内容逻辑 | 先说明会议身份和记录状态,再按议题写实际材料 / 必要讨论摘要 → 决定及理由 / 异议 → 未决项 → 行动 → 审阅材料;深度与治理风险相称 |
| 事实 / 边界 | 出席、法定人数、冲突、动议、表决、决定、owner、期限和批准状态均须来自会议材料或确认只保留治理所需个人信息草稿不得冒充批准版历史状态固定为文字快照不得由`checkbox``task`等可变交互块改写 |
| 错误 | 摘要冒充逐字稿、讨论流水账、建议写成决定、行动不可跟踪、法定人数不明却宣称决定有效、草稿冒充批准、静默改历史或泄露无关个人信息,任一出现即失败 |
## 适用与消歧
用于某次已发生会议的可引用治理记录,使缺席者、执行者和审核者确认决定、未决与行动。逐字 / 逐发言人 / 可回放内容只是 transcript 源材料;会前准备走 `memo-brief.md`;非会议状态走 `weekly-report.md`;党政机关法定“纪要”走 `official-redhead.md`
## 子类型与治理证据
普通工作会可精简为会议身份、决定、未决和行动;项目决策会补必要理由与审阅材料;董事会、委员会、表决或法定会议按章程 / 适用规则记录出席、法定人数、利益冲突、动议、票决、精确决议及认证。
证据可来自 agenda、出席记录、实际审阅材料、动议 / 投票和录音 / 逐字稿,但正文只链接关键来源,不复制附件淹没决定。来源冲突并列保留并交主持人 / 参会者确认。
## 结构与高质量写法
标明名称 / 类型、日期时间、地点 / 方式、主持 / 记录和草稿 / 已批准状态;每个议题围绕结果而非发言顺序。行动项写交付物 / 动作、责任人 / 单位、时间要求和状态。缺失信息用`[决议原文待确认]``[owner 待确认]`等具体占位;法定人数或批准不明时不得宣称有效,保持草稿并进入确认流程。

View File

@@ -1,25 +0,0 @@
# Genre Contract: Memo / Brief (`workplace.memo_brief`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;直接、克制、按具名读者控制信息密度,首屏给结论、状态或 ask不设固定篇幅 |
| 内容逻辑 | 先选信息、决策或会前三种模式之一,再按“核心事项 / ask → 必要事实 → 影响 / 取舍 → 风险 / 未知 → 动作”推进;只有真实选择才写选项 |
| 事实 / 边界 | 事实、数字、立场、审批状态和时点均须可核验未知与假设就近标记Memo 只可作完整 Proposal 的决策封面,不替代其论证 |
| 错误 | 首屏无结论或 ask、把完整 Proposal 压成摘要、编造审批 / 立场、用固定篇幅删证据、细节不解释影响,任一出现即失败 |
## 适用与消歧
用于让具名内部读者快速知悉、判断或完成会前准备。请求批准完整方向、预算、资源或执行承诺走 `proposal.md`;按周期判断相对目标的位置走 `weekly-report.md`;“摘要 / 简报”单词本身不触发本体裁。
## 子类型与证据
- 信息 Brief变化 → 影响 → 当前状态 / 风险 → 下一步;无须行动时明确“仅供知悉”。
- 决策 Memo决定事项 / 时点 → 现状 → 真实选项及同口径影响 → 推荐与证据 → 明确决策入口。
- 会前 Brief会议目标 → 已核验的参与方立场 / 利益 → 要点与禁区 → 期望结果;未知立场不得补造。
- 按需标读者、作者 / 责任团队、日期和信息截至时间。持续更新时说明相对上版的变化及下次更新点。
## 结构与高质量写法
按重要性而非材料顺序组织,一个段落一个观点;关键判断不藏在附件。建议写清谁做什么、为什么以及怎样判断完成,并呈现足以改变判断的风险、反例和不确定性。缺关键事实时用`[关键结论待确认]``[数据口径待核]`等具体占位,或收窄为待核问题清单;仍要求据此批准时必须 `blocked`

View File

@@ -1,72 +0,0 @@
# Genre Contract: Official Document / 公文内容稿 (`workplace.official_redhead`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | 固定 `formal`;庄重、准确、简洁、直接。禁 emoji、网感、营销话术、情绪化评价、空话和机械编号 |
| 允许 block | `title`(完整文稿最多 1 个)、`p``h1``h2``h3``h4`;标题层级连续且不超过四级 |
| 少用 block | `ul``ol`容器及`li`子块仅用于真实并列项,不替代公文层级序号;`table`容器及`thead``tbody``tfoot``tr`子块仅用于非表格难以清楚表达的多对象同字段信息 |
| 禁止 block | 禁止未列入允许 / 少用清单的类型,包括`callout``grid`容器及`column`子块、`checkbox``whiteboard``blockquote``pre`、根级`code``hr``img``figure`;禁装饰色、伪红头和伪印章 |
| 内容逻辑 | 按行文目的、机关关系和受众确定唯一文种,再按“必要依据 / 缘由 → 核心事项 / 决定 → 可执行要求 → 必要结语”推进 |
| 事实 / 边界 | 只写已给定或已核验的事实、依据、权限和决定;未知项具体占位,关键缺口使 `Publish Gate = blocked`;飞书只交付内容审校稿,不宣称已签发或生效 |
| 错误 | 禁止文种或行文关系错误、报告夹请示、请示一文多事 / 多头主送、批复无对应请示、引用 / 文号 / 序号 / 附件不规范,以及编造事实、依据、权限或制发要素 |
## 适用
仅在明确要求公文、红头 / 套红、正式发文,或法定文种与机关行文关系、发文字号、签发人、主送机关等制发要素共同出现时使用。“红头文件”是制发信号,不是文种;普通公司通知、制度、检查 / 整改材料走 `formal-doc.md`,普通会议记录走 `meeting-minutes.md`
## 文种选择
按“行文目的 → 发文与受文机关关系 → 受众范围”判断,不按单个关键词判断。
| 文种 | 适用意图 |
|-|-|
| 决议 | 会议讨论通过重大决策 |
| 决定 | 对重要事项作出决策部署、奖惩或变更 / 撤销决定 |
| 命令(令) | 公布法规规章、施行重大强制措施、授予衔级或嘉奖 |
| 公报 | 权威公布重要决定或重大事项 |
| 公告 | 向国内外宣布重要或法定事项 |
| 通告 | 在一定范围公布应遵守或周知的事项 |
| 意见 | 对重要问题提出见解和处理办法 |
| 通知 | 要求下级 / 有关单位执行或周知,批转、转发公文 |
| 通报 | 表彰、批评、传达重要精神或告知重要情况 |
| 报告 | 向上级汇报工作、反映情况或答复询问,不请求决定 |
| 请示 | 向上级请求指示或批准;一文一事,原则上只主送一个上级机关 |
| 批复 | 答复下级机关请示,必须有对应来文 |
| 议案 | 政府依法向同级人大或其常委会提请审议 |
| 函 | 不相隶属机关间商洽、询答、请求批准或答复审批 |
| 纪要 | 记载正式会议主要情况和议定事项,不写逐字过程 |
优先消歧:汇报且不求决定用报告,求上级决定用请示,不相隶属机关商洽用函;面向明确单位执行用通知,面向一定范围不特定对象遵守用通告,向国内外宣布重要 / 法定事项用公告,传达情况或评价用通报。
## 行文与事实
- 按隶属关系、职权和授权行文;一般不越级,特殊越级时同时抄送被越过机关。
- 上行文原则上主送一个上级机关,不抄送下级;报告不得夹带请示。除直接交办外,不主送上级负责人个人。
- 一份主文保持一个行文方向和授权状态;同一事项若既需向上请求批准又需向下要求执行,应拆分文稿或待批准后另行制发,附件不得偷渡尚未授权的执行要求。
- 下行要求不得超出发文机关权限;涉及其他地区 / 部门职权时先协商。联合行文仅限必要且主体关系适当的情形。
- 只把已确认的决定写成指令。措施按需写明主体、动作、对象、期限、标准和反馈去向;对不相隶属机关使用`商请``请予``函复`等匹配关系的措辞。
- 缺少授权、关键依据、核心事实、适用范围或审批决定时不得发布;不得猜测文号、签发人、密级或紧急程度。
## 内容结构
- 标题一般使用“发文机关 + 事由 + 文种”,内含法规、规章或被印发文件名称时使用书名号。
- 主送机关使用全称、规范简称或同类机关统称。附件说明与附件顺序、名称逐字一致;多个附件用阿拉伯数字编号,名称末尾不加标点。
| 文种 | 常用结构 |
|-|-|
| 通知 | 缘由 / 依据 → 事项 → 对象 / 时间 → 已确认要求 |
| 请示 | 缘由 / 依据 → 单一请示事项与倾向意见 → `妥否,请批示` |
| 批复 | 准确引用来文 → 明确意见 → 执行要求 → `此复` |
| 函 | 事项 / 依据 → 商请或答复 → `请予函复` / `特此函复` |
| 报告 | 情况 → 事实 / 成效 → 问题 → 后续安排 → `特此报告` |
| 纪要 | 会议基本信息 → 主要情况 → 议定事项 / 责任 / 时限 |
## 文号、引用与序号
- 普通发文字号采用“机关代字 + 完整年份 + 顺序号”,如 `×政发20268号`;年份用六角括号,顺序号不加“第”、不编虚位。命令(令)的令号可用 `第×号`
- 首次引用其他公文时写完整标题和文号:`《××机关关于印发〈××办法〉的通知》×20268号`。不只写文号,不用论文式参考文献编号。
- 文件、法律法规名称使用书名号;直接引文使用中文双引号,内层用单引号。引文须核对原文、效力、制定机关和适用范围;无法核实则标记 `[引文待核]`
- 正文层级依次使用 `一、``(一)``1.``1`,不得写成 `1、``(一)、`,不得跳级;超过四级时重组内容。
- 成文日期写为 `2026年7月13日`,月日不补零。标点和数字按 GB/T 15834、GB/T 15835 使用;全称及规范简称前后一致。

View File

@@ -1,25 +0,0 @@
# Genre Contract: PRD / 产品需求 (`workplace.prd`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `rich`;具体、行为化、术语和状态一致;在有明确内容作用时用场景、状态流、表格、图示和其他 rich block 降低理解与验收成本,但不让视觉组件替代需求、证据或验收,不用固定大模板制造完整感 |
| 内容逻辑 | 方向已定后按“用户问题 / 证据 → 目标 / 结果 → 范围 / 非目标 → 场景 → 行为需求 / 验收 → 异常 / 边界 → 适用质量约束 → 依赖 / 开放问题”推进 |
| 事实 / 边界 | 用户需要、指标、研究、阈值、可行性、owner、状态和排期须可追溯需求描述可观察结果acceptance criteria 验结果;安全、隐私、无障碍等仅按实际风险和标准纳入 |
| 错误 | 功能清单无用户问题、Proposal 论证吞没需求、范围 / 非目标缺失、需求暗藏实现、验收不可测、正常路径无异常、伪造研究 / 阈值 / 批准或机械填质量模板,任一出现即失败 |
## 适用与消歧
用于方向与投入原则已定后,让产品、设计、研发和测试就用户问题、范围、可观察行为与完成标准形成共识。是否立项 / 选择方向 / 批资源走 `proposal.md`;架构、接口和实现取舍走 `technical-doc.md`;已批准重复操作走 `sop-tutorial.md`。“需求 / 功能”单词本身不触发。
## 证据与需求写法
- 明确目标用户、任务情境、问题及研究 / 行为 / 支持证据;内部偏好和预设功能不冒充用户需要。
- 产品目标连接可观测结果,指标标口径、来源和时间窗。未知目标值用`[目标值待产品 / 数据确认]`并给确认 owner / 时点,不编使用量或阈值。
- 关键需求写成 actor + trigger / precondition + observable outcome + failure / edge术语和状态一致。用户故事格式只是工具不是章节配额。
- 每个质量约束给可验证门槛或明确待确认项;不适用时不填模板。需求、验收 / 测试和来源保持追踪。
## 结构与高质量写法
先定范围、非目标、优先级、依赖、假设和开放问题,防止 scope creep再按关键场景写正常、异常和边界行为。把大而不可测的需求拆到可验收粒度不用“体验更好 / 性能高”等形容词。没有用户证据时收窄为假设和研究计划;关键合规 / 安全门缺失时 `blocked`,开放问题不得藏在脚注。

View File

@@ -1,24 +0,0 @@
# Genre Contract: Proposal / 方案提案 (`workplace.proposal`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;结论前置、具体、可审议,主动呈现代价、反例与不确定性,不用宏大背景或伪精确制造可批准感 |
| 内容逻辑 | 明确 decision / 决策者 / 时点,再按“改变理由与不行动基准 → 目标 → 真实选项同口径比较 → 推荐 → 资源 / 交付 → 风险 / 未知 → 决策入口”推进 |
| 事实 / 边界 | 区分事实、估算、假设和未知;收益、成本、资源、用户证据、审批和排期须可追溯;进入执行决策才写治理 / 退出条件,未批准不得写成既有承诺 |
| 错误 | 无决策者 / ask、无不行动基准、预设单一答案、选项口径不同、成本风险后置、未批先承诺、编造收益 / 审批或与 PRD 混写,任一出现即失败 |
## 适用与消歧
用于请求具名决策者批准、驳回或选择方向、预算、资源、试点或执行承诺。方向已定并定义产品行为 / 验收走 `prd.md`;短决策封面走 `memo-brief.md`;已批准安排的发布走 `formal-doc.md`。“方案”单词本身不触发本体裁。
## 子类型与证据
可用于概念 / 方向、投资 / 预算、资源申请、变更、试点 / 实验和执行承诺提案;深度随阶段、金额、风险和不可逆性裁剪。必须给 case for change、目标 / 成功标准、不行动或最小变化基准,以及足以判断的成本、收益、依赖、风险和敏感因素。
存在真实选择时纳入可行替代并以相同范围、时间和评价标准比较;没有真实替代时说明约束如何收敛,不能造假选项。不可量化影响可定性,但须说明原因及其决策影响。
## 结构与高质量写法
先把选择题写对,再论证推荐;显式记录被放弃选项和推荐代价。数字不足时使用范围、依据和验证计划,不补精确点估。进入执行决策时按需补 owner、里程碑、治理、衡量、退出 / 复盘;缺决策权、关键成本或安全合规依据时收窄为探索稿,仍要求批准则 `blocked`

View File

@@ -1,32 +0,0 @@
# Genre Contract: Research Report / 调研报告 (`report.research_report`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;证据驱动、校准、术语一致;摘要独立可读,语气强度不得超过证据强度 |
| 内容逻辑 | 先确定研究问题与研究类型,再交付当前答案、证据强度和可推广边界;按问题或主题组织发现,解释、建议和验证计划必须回链发现 |
| 事实 / 边界 | 区分原始事实或参与者陈述、分析推断、假设和建议;方法披露足以评估偏差;适用时确认委托、利益、同意、匿名或保密、敏感数据用途;研究材料须确认使用权、去标识、来源与说明,复杂视觉附文字等价信息;未知不补造 |
| 错误 | 无明确问题;方法黑箱;资料摘要冒充发现;样本外推;醒目个案冒充模式;事实、解释和建议混写;相关写成因果;合规状态、授权或行业共识靠猜 |
## 适用与消歧
以明确研究问题、研究设计或材料、发现和限制为主要交付。`调研``研究过``访谈``问卷`单独只用于召回;只解读既定指标走 [`data-report.md`](data-report.md),比较特定战略或资源选项走 [`business-analysis.md`](business-analysis.md),方法并非判断重点的问题框架综合走 [`white-paper.md`](white-paper.md)。
## 子类型
- **定量 / 定性 / 混合研究**:根据问题选择总体、抽样或招募、工具、采集和分析方法;不用一种方法的规范冒充全部研究标准。
- **用户研究 / 项目或政策评估**:说明场景、参与者、干预或对象、成功标准、观察窗口和用途。
- **证据综合**:只有检索范围、纳排和综合方法明确时才作为研究发现;普通资料汇总不得升级为系统结论。
## 证据与方法
- 明确对象、用途、非目标和适用情境;按需披露委托 / 执行方、总体与纳排、抽样 / 招募、样本量、工具 / 题项、采集方式 / 语言 / 时点、响应 / 脱落、加权、编码 / 分析和质量控制。
- 写明偏差、缺失、反例、负结果、替代解释及其可能方向;透明报告不等于设计无偏,也不证明结论可复现。
- 人员或敏感研究在适用规则下确认知情同意、撤回与伤害风险、匿名 / 保密、访问和数据用途;未获授权不公开可识别材料、原始数据或代码。
- 引文只说明有出处的体验或机制,不把单个引文写成频率;结论只推广到设计和样本支持的人群、时间与环境。
- 无原始材料只能产出研究范围或计划,不能生成 findings方法或样本缺失时用 `[抽样方法待核]``[采集时点待核]` 并收窄为探索性观察。关键伦理、授权或方法缺口会改变结论时标记 `blocked`
## 结构与高质量写法
独立答案、证据强度与关键限制 → 问题 / 范围 / 既有知识 → 方法 / 样本 → 按问题或主题组织的发现 → 解释、反例与替代解释 → 有边界的建议 / 验证 → 局限、来源与必要附录。摘要覆盖目的、方法、发现、含义和限制;正文以“主张 → 证据 → 限定”推进,不按作业时间线罗列过程,不用组件数量代替研究质量。

View File

@@ -1,25 +0,0 @@
# Genre Contract: Retrospective / 复盘 (`workplace.retrospective`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;坦诚、无责备、因果克制,围绕证据和下一轮改变,不用“加强沟通 / 持续关注”代替可验证实验 |
| 内容逻辑 | 界定已结束周期 / 事件,再按“目标 / 证据 → 预期与实际 → 聚类观察 → 洞见 / 待验证因果 → 保留项 → 少量改进实验 → 复查”推进 |
| 事实 / 边界 | 事实 / 观察、解释 / 假设、洞见和行动分层;结论回链事件、指标或交付物;根因仅在证据充分时声明,否则写可证伪假设;保护必要隐私 |
| 错误 | 周报换标题、成绩陈列 / 情绪宣泄、个人归罪、单一根因臆测、行动无 owner / 验证 / 跟踪、不回看上轮或把模板便签当结论,任一出现即失败 |
## 适用与消歧
用于回看明确迭代、阶段、项目或事件,形成可复用学习并改变下一轮做法。当前状态与升级需求走 `weekly-report.md`;仍在未知中止损、取证、恢复或调查生产事故走 `technical-doc.md`。生产事故可由 Technical 主文承载影响 / 时间线 / 根因 / 恢复,再附本体裁的团队学习层。
## 证据与因果
- 开头界定范围、时间、目标 / 原计划、参与视角和已知证据;不得补造指标、时间线、共识、原因或行动。
- 同时识别应保留与应改变的条件,按影响聚类;以系统、流程、工具、接口和当时条件为对象,不把惩罚叙事冒充根因。
- 个人工作心得 / 成长反思以一个真实事件或转折为证据,呈现“当时判断 → 反证 / 后果 → 新认识 → 下一次可观察行为”;不代写材料没有提供的情绪、动机、心路或成长。
- 证据不足时写“促成条件 / 假设 + 验证方式”,不能用确定语气。缺基线用`[基线待补]`,涉及安全 / 法务而证据不足时转 Technical 并 `blocked`
## 结构与高质量写法
便签、4Ls、Start / Stop / Continue 只是收集手段成稿须综合为主题和判断。改进实验写动作、owner、目标时间、验证条件和跟踪位置优先改变系统而不是要求人“更小心”按需补上轮行动效果和下轮复查点。项目收尾可增加成本、范围、相关方和知识移交但不机械扩章。

View File

@@ -1,37 +0,0 @@
# Genre Contract: Consumer / 消费决策内容 (`router.consumer`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `rich`;具体、可信、可亲近,体验感服务于选择,不用热情语气替代测试、价格和适用条件 |
| 内容逻辑 | 围绕具体消费场景,用“需求 / 使用条件 → 评价标准 → 体验或测试证据 → 权衡 → 适合谁 / 不适合谁”推进;合集和比较须共享标准,不按品牌逐段堆卖点 |
| 事实 / 边界 | 只声称真实体验或有方法支撑的测试;披露赠品、佣金、赞助和其他重要关系;标明版本、时间、价格口径及限制;用户 / 专家引语、图片和前后对比须有授权、来源、语境与真实性依据,非文字证据须有文字等价信息;遵守目标法域和平台当期消费者、广告与高风险品类规则,关键利益关系、核心功效、安全条件或报价条款缺失时 blocked |
| 错误 | 编造使用经历、未披露商业关系、无方法的评分 / 排名、把主观偏好写成客观最佳、隐藏不适用人群或总成本、用极端个案概括功效、过期信息仍当现状,任一出现即失败 |
## 适用与消歧
用于帮助读者购买、比较、避坑或判断某种生活方式是否适合自己。仅出现小红书、微信等平台名不触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf消费选择任务作为该 leaf contract 的硬约束,不再并读 Consumer。以公共事件核实为主走 Media以价值判断为主走 Opinion以品牌拥有的转化内容走 Marketing。
“测评”必须继续区分独立比较、真实个人体验和品牌演示:前两者可走本合同,品牌控制结论或行动入口时走 Marketing并保留显著披露。
## 子类型
| 子类型 | 读者任务与推进 |
|-|-|
| 单品体验 / 好物分享 | 判断某物在真实场景是否值得;使用背景 → 观察 → 优缺点 → 适用人群 |
| 对比测评 / 排名 | 在同一任务下选择;方法与样本 → 共同标准 → 结果 → 权衡与不确定性 |
| 合集 / 清单 | 快速缩小候选范围;选择门槛 → 分组理由 → 各项差异 → 最终选择路径 |
| 探店 / 服务体验 | 判断是否到访或购买服务;时间地点 → 实际流程 / 价格 → 体验证据 → 限制 |
| 生活方式内容 | 判断实践成本与可复制性;目标 → 做法 → 真实投入 / 结果 → 适用边界 |
## 证据、披露与合规
- 第一人称体验交代使用时长、频率、版本和条件;未亲测就明确资料来源,不伪装成亲历。比较结论说明样本、标准、测量方法及未覆盖变量。
- 把“真实 / 有效”“适合当前读者”“值得当前价格”分开判断。强参数品只保留会改变选择的指标,并解释版本口径和决策影响;使用评分 / 排名时公开标准、权重、主观边界和反转条件,安全或资格等一票否决项不得被平均分稀释。
- 商业关系和激励在读者接触推荐时清楚出现,不能藏在模糊标签或文末。披露、重大限制和安全警示须就近可见,不能只藏在链接或视觉装饰中。
- 健康、安全、金融、未成年人等高风险内容只写证据支持且适用法域允许的范围;不能核实的功效或个体化建议删除。规则冲突时按交付地区、渠道和发布时间核验,不把单一国家指南写成全球义务。
## 结构与高质量写法
先告诉读者评判基准,再给结论,才能让“推荐”可复核。优点与代价写在同一决策语境内,价格同时说明时间、地区、规格和附加成本。结尾给条件化选择,而不是人人适用的口号;关键参数待补时用具体占位并暂停对应结论,不能靠语气填空。

View File

@@ -1,36 +0,0 @@
# Genre Contract: Creative / 叙事创作 (`router.creative`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `rich`;语言、节奏和视角服务指定叙事体验,表达自由不替代人物动机、因果和场景可读性 |
| 内容逻辑 | 本合同只覆盖叙事创作;先确认体验、篇幅、视角和约束,用“人物欲望 → 阻力 → 选择 → 代价 → 变化”形成场景因果;剧本和互动叙事分别服从可演行动与有后果分支 |
| 事实 / 边界 | 用户授权的虚构可创造,但真实背景、引用、既有作品正史和人物身份不得伪造;图片、题记、原作片段和创作参考须遵守来源与使用权限,非文字参考及互动分支图须附文字等价的关系、路径和状态说明;区分明确设定、合理创作补白和待确认约束;核心世界观 / 权利边界冲突且无法安全收窄时 blocked |
| 错误 | 只堆设定不发生选择、人物为推进情节突然失去动机、冲突靠偶然或外力无代价解决、视角 / 时态无意漂移、剧本写成解释性小说、互动分支无状态差异、把诗歌静默纳入交付,任一出现即失败 |
## 适用与消歧
仅用于网文、短篇故事、同人叙事、互动小说、剧本和故事大纲等以事件、人物选择和变化为核心的创作。诗歌、歌词、纯抒情散文不在本合同范围收到这类请求时先确认目标或使用相应专用规则不因“Creative”一级名称而静默扩写。
以论点和证据表达判断走 Opinion以真实个人经历建立专业信誉走 Personal Brand。世界观说明若目标只是知识解释不因带角色名就成为故事。
## 子类型
| 子类型 | 读者任务与推进 |
|-|-|
| 短篇 / 网文 | 获得连续叙事体验;触发变化 → 升级阻力 → 关键选择 → 后果 / 回响 |
| 同人叙事 | 在约定正史与角色核心上体验新情境;明确时间点 / 偏离点 → 角色选择 → 新后果 |
| 剧本 / 短剧 / 叙事短视频 | 看见可拍、可演的行动与冲突;媒介 / 时长 / 制作约束 → 场景目标 → 视觉、行动、声音 / 对话 → 转折 → 场景状态变化 |
| 互动小说 | 作出有信息依据且有后果的选择;状态 → 选择 → 反馈 → 状态改变 → 后续分支 |
| 故事大纲 | 判断故事能否成立并继续创作;前提 → 人物弧 → 节点因果 → 高潮选择 → 结局变化 |
## 设定与真实性
- 先锁定用户给定的人物、关系、禁区、正史时间点和期望体验;未指定的创作空间可以补白,但不得覆盖明确约束。关键歧义有多个会显著改变成品的方向时先询问。
- 使用真实地点、历史、科学或文化材料时核验会影响情节的事实;有意架空应让读者能辨认其虚构约定。同人创作不把自设冒充正史,也不虚构原作引语。
- 真实人物、未公开经历、受保护素材和委托作品按授权边界处理;不能确认可用性时改为原创替代或保持 blocked。
## 结构与高质量写法
每个场景都让人物为目标采取行动,并在离场时改变信息、关系、资源或风险。细节同时承担感官、人物或伏笔功能,背景通过当前冲突释放,不集中讲解。对话要改变局面而非重复旁白;剧本动作、声音和调度须在声明的演员、场地、道具与媒介条件下可实现,不用固定“前三秒 / 每分钟一反转”公式代替因果。结局兑现前文建立的选择与代价。大纲可显式呈现结构,成稿则把结构转化为可体验的场景。

View File

@@ -1,39 +0,0 @@
# Genre Contract: Knowledge / 知识与教程 (`router.knowledge`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;具体、可操作、按读者水平解释;科普可生动、有好奇心,但不得牺牲准确性或编造戏剧性 |
| 内容逻辑 | 先选择唯一主模式和读者起点,再承诺一个理解、学习、一次操作、检索或选择结果;第一屏给适用对象、目标和关键前置,概念、步骤、练习 / 验证、反馈与例外按需渐进展开 |
| 事实 / 边界 | 事实、版本、命令、UI 路径和链接须核验;示例与规则分开;截图 / 案例不得泄露敏感信息;已知自助路径可写,组织受控重复作业走 SOP设计、精确技术契约或未知诊断走 Technical |
| 错误 | 不声明读者起点;教程变理论课;学习计划无基线 / 完成标准 / 调整规则;只有原则无步骤 / 例子步骤无结果或验证版本、权限、环境缺失编造命令、UI 或链接FAQ 脱离真实问题;资源合集无标准 / 注释 / 维护;视觉成为唯一信息;把未知排障写成确定答案 |
## 适用与消歧
适用自主理解、学习 / 备考规划、一次已知任务或检索复用。`科普``教程``指南``攻略``学习计划``FAQ``知识库``资源合集`只用于召回;“知识库”是容器或渠道,不决定文章体裁。组织要求多人按批准版本重复执行并留痕走 [`sop-tutorial.md`](sop-tutorial.md)未来设计、API 精确契约、生产状态变更或未知根因走 [`technical-doc.md`](technical-doc.md);研究或数据形成新洞察走 Report。
## 主模式
| 模式 / 读者任务 | 结构推进 |
|-|-|
| Explanation / 科普:建立正确心智模型 | 现象 / 误区 → 概念模型 → 机制与证据 → 例子 → 争议、限制与适用边界 |
| Tutorial通过受引导练习获得技能 | 学习目标 → 起点 / 环境 → 安全练习 → checkpoint → 复盘与下一步 |
| Learning plan在现实约束下持续提高 | 基线诊断 → 可观察的阶段目标 → 练习 / 资料 / 时间 → 完成标准与反馈 → 调整规则 |
| How-to完成一个已知目标 | 目标 → 前置 → 最短有效步骤与可观察结果 → 变体 / 已知错误 → 完成验证 |
| FAQ / known troubleshooting快速找到已验证答案 | 按真实问题或症状分组 → 直接答案 → 必要条件 / 操作 → 相关内容;需要新假设或根因调查时转 Technical |
| Resource guide按标准选择资源 | 使用场景 / 筛选标准 → 分类 → 每项适配、代价与访问条件 → 维护信息 |
| Reference / KB article检索并复用事实或解法 | 上下文 / 适用版本 → 事实或 issue-resolution → 限制 / 相关项 → 时效性强时标 owner / last verified |
## 事实、步骤与维护
- 明确受众的已有知识、范围 / 非范围、版本、环境、权限与风险;术语在首次需要时解释,不先灌输完整理论。
- 学习计划按阶段 / 能力、可用时间、既有任务和可得资料控制强度;目标拆成可观察表现,每阶段合写练习、完成标准、反馈和调整条件。会显著改变安排的缺口先问或条件化,不补造基础 / 时间。
- 顺序任务一项写一个清楚动作,紧邻给可观察结果;命令、输入、输出和成功验证须能在声明环境中复现,危险或不可逆警告必须在动作前。
- FAQ 只收真实用户问题或检索需求;否则按用户任务重组。资源指南先写选择标准,再给有描述的精选链接,不用外链代替核心上下文。
- 时效性内容标适用版本 / 时间并说明维护边界;复杂视觉须有可传达同等信息的正文,图片、案例和代码不得成为无解释的唯一依据。
- 版本或权限不明时用 `[适用版本待核]``[所需权限待确认]` 并只写不受影响部分;未验证命令或链接不进入发布稿。缺口可能造成损失、安全风险或关键分叉时标记 `blocked`
## 高质量写法
第一屏让读者知道能理解、学会、完成或找到什么;用读者语言、具体动词和可验证结果推进,每节只增加必要的新理解或动作。先给最短可行路径,再在需要处补原理、变体和进一步阅读;示例只服务迁移,不扩张为用户未要求的全套内容,也不用丰富组件掩盖解释不足。

View File

@@ -1,40 +0,0 @@
# Genre Contract: Marketing / 营销与公关 (`router.marketing`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `rich`;清楚、有吸引力且可行动,表达强度不得超过承诺、证据和授权,紧迫感不得制造误导 |
| 内容逻辑 | 先锁定受众、漏斗阶段和唯一主要读者结果;转化内容用“场景 / 问题 → 有边界的价值主张 → 证据 → 关键条件 / 异议 → 一个 CTA”推进公关稿按已授权事实、相关方影响、组织回应和后续更新推进 |
| 事实 / 边界 | 所有客观、比较、功效和稀缺性主张发布前有相称证据;价格、资格、期限和限制就近可见;广告身份与商业关系按目标法域 / 平台规则披露;评价、案例、引语、图片和活动素材须真实、可核且获授权,非文字证据须有文字等价信息;核心主张证据、适用法域、发布授权、关键交易条件或任务要求的行动入口缺失时 blocked |
| 错误 | 无证据的“最佳 / 保证 / 第一”、隐藏限制或自动续费、伪造倒计时 / 库存 / 评价、把广告伪装成独立报道、未经授权承诺赔付或责任、转化内容多个 CTA 争抢、用复杂 block 掩盖价值缺口,任一出现即失败 |
## 适用与消歧
用于组织拥有或授权、目标是认知、转化、留存或公共关系管理的内容。由新闻机构独立选题、核实和报道的内容走 Media组织自有新闻稿、媒体通稿、品牌声明和回应口径走 Marketing即使采用新闻结构也不变成独立报道。
个人真实体验用于帮助消费选择时走 Consumer明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf营销目标、商业关系和交易条件作为该 leaf contract 的硬约束,不再并读 Marketing。出现“新闻稿、软文、活动文案”只作召回信号仍须确认发布主体、受众、行动和商业关系。
内部营销策划、增长方案或活动执行计划不因“营销”进入本合同:比较打法走 Business Analysis请求预算 / 资源 / 战役批准走 Proposal已定打法的协同落地走 Execution Plan只有最终面向受众的传播、招募或转化成稿走 Marketing。
## 子类型
| 子类型 | 读者任务与推进 |
|-|-|
| 广告 / 短文案 | 迅速判断是否值得行动;受众场景 → 单一利益 → 可信理由 → 条件 → CTA |
| 详情页 / 落地页 | 完成比较与转化;价值主张 → 关键能力 → 证据 → 方案 / 条款 → 异议 → CTA |
| 活动 / 私域话术 | 判断是否参与并知道下一步;对象 → 收益 → 时间地点 / 门槛 → 风险限制 → 行动 |
| 新闻稿 / 媒体通稿 | 获取组织已授权消息;可发布事实 → 为什么重要 → 引语 / 背景 → 联系与更新安排 |
| 声明 / 危机回应 | 理解已知事实和组织行动;事件范围 → 已确认影响 → 当前措施 → 未知项 → 下次更新时间 |
## 证据、授权与合规
- 建立“主张—证据”对应:定量效果说明口径、样本和时间,比较主张保证对象与标准可比;图片、引语、评价和案例保留来源、必要语境及授权记录。
- 披露和限制应让普通受众在作决定前看见并理解,不能由链接、模糊缩写或弱提示代替。规则随法域、媒介、品类和时间变化,交付前核验当期法律、监管与平台要求。
- 公关内容只写已获授权的事实和承诺;事故原因、责任、补偿、调查结论未核定时明确 unknown。关键批准或法律审阅未完成不生成可直接外发版本。
## 结构与高质量写法
价值主张具体到受众、场景和结果,证据紧跟对应主张。文案须锚定品牌独有资产、产品细节或品类语境;换成竞品名仍成立就返工。多版本应改变受众状态、主张、证据或场景并说明选择条件,不做同义改写。
删除不改变理解或行动的品牌空话,不把真实痛点升级为羞耻、身份不足或恐惧操控。有转化目标时,次级入口均服务同一主要行动;优惠资格和截止时间采用可比较字段,待补价格、库存或链接用语义化占位,并让受影响结论保持 blocked。

View File

@@ -1,36 +0,0 @@
# Genre Contract: Media / 资讯媒体 (`router.media`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;准确、中立、紧凑,信息密度服从读者快速理解,不用戏剧化措辞替代事实强度 |
| 内容逻辑 | 先确定快讯 / 报道、解释、人物特写或访谈的读者任务;关键信息优先,随后给证据、必要背景、相关方视角和仍未知事项,段落按重要性、因果或时间关系推进 |
| 事实 / 边界 | 区分已核事实、来源说法、推断和 unknown准确优先于抢发关键主张可追溯负面涉及方获得合理回应机会引语须忠实可核图片和原始材料须有使用权限、来源、语境说明及文字等价信息更正、披露和关键缺口须直接可见核心事实、来源真实性、发布权限缺失或严重负面指控尚未提供回应机会时保持草稿并 blocked |
| 错误 | 把组织自有通稿伪装成独立报道、标题超出证据、单一匿名来源承载重大指控、引语失真、事实与评论混写、遗漏重大反方或不确定性、图片无权利 / 来源 / 文字等价信息,任一出现即失败 |
## 适用与消歧
本合同用于以独立采集、核实和公共理解为职责的新闻内容。请求出现“新闻稿、媒体稿、报道”只作召回信号:编辑方能独立核实、选择角度并承担报道判断时走 Media由组织拥有、批准并面向媒体或公众发布的新闻稿、品牌声明和公关口径走 Marketing。
以立场说服为主走 Opinion以购买决策和亲身体验为主走 Consumer内部事实简报不因写得像新闻而改变读者任务。渠道名、标题风格或“像媒体一样写”均不能单独触发明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf资讯核实边界作为该 leaf contract 的硬约束。
## 子类型
| 子类型 | 读者任务与推进 |
|-|-|
| 快讯 / 硬新闻 | 尽快知道发生了什么及其可信程度;核心事实 → 来源与范围 → 必要背景 → 下一确认点 |
| 解释报道 | 理解为什么发生、如何运作及争议在哪里;问题 → 机制 / 时间线 → 多方证据 → 已知边界 |
| 人物 / 特写 | 通过可核场景和经历理解人物或议题;场景 → 关键变化 → 证据与他者视角 → 公共意义 |
| 访谈 / 问答 | 准确获取受访者观点及上下文;交代身份与场景,忠实编辑问答,不补造连接语或立场 |
## 证据与真实性
- 为可能引发争议的事实保留可追溯材料,记录来源身份、接近事实的方式、核实状态和使用限制;匿名只在有公共价值且无法安全具名时采用,并说明读者判断所需的来源范围。
- 引语逐字可核;压缩、翻译和转述不得改变含义。无法确认的数字、时间、身份或因果就近标明 unknown不用“据悉”“有消息称”遮蔽来源质量。
- 开盒、网暴、羞辱、未成年人或其他可能放大伤害的事件只保留理解事实、责任和传播机制所需的最少信息;不为证明热点而复刻身份线索、攻击性内容或未核传言。
- 更正要说明改了什么;新证据改变核心判断时更新标题和结论。发布前无法核实的核心主张不得靠占位符放行。
## 结构与高质量写法
标题和导语只承诺正文已证明的内容。每段承担一个信息动作,并在首次出现时交代人物、机构、时间和口径;背景只保留改变理解的部分。多方说法按证据权重而非形式上的各打一板排列,不把可验证事实写成“双方观点”,也不把尚无结论写成确定因果。

View File

@@ -1,38 +0,0 @@
# Genre Contract: Opinion / 观点评论 (`router.opinion`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `rich`;立场鲜明但措辞精确、公平,论证密度高于情绪密度,锋利不等于侮辱或夸张 |
| 内容逻辑 | 明确可争辩的中心判断及其重要性,用理由和证据推进;对有实质争议的主张呈现最强相关反论并回应,结论说明判断边界或行动含义 |
| 事实 / 边界 | 区分事实、推断、价值判断、预测和个人经验;事实可追溯,证据强度匹配主张强度,不把相关性写成因果或把个案外推为普遍规律;引语、图片和作品片段须有可核来源、必要语境与使用权限,非文字证据须有文字等价信息,重要利益关系须显著披露;关键事实缺失时收窄主张,无法成立则 blocked |
| 错误 | 只有态度没有论点、稻草人反驳、选择性证据、人格攻击、标题先定罪、把经验冒充统计、隐藏重大反例或利益关系、结论超出论证,任一出现即失败 |
## 适用与消歧
用于帮助读者评估一个判断、立场或解释框架。事件复述和独立核实走 Media围绕购买选择的测评走 Consumer组织为行动或转化发声走 Marketing。出现“评论、专栏、观点”只是召回词正文必须有可辨认的判断和论证任务。
文化评论关注作品、现象的意义和判断;若主要提供剧情复述或故事体验,不走本合同。个人经历可以作为观察入口,但若目标是展示经历与能力,走 Personal Brand。
## 子类型
| 子类型 | 读者任务与推进 |
|-|-|
| 时评 / 公共议题评论 | 判断事件意味着什么;争点 → 判断 → 证据与机制 → 反论 → 后果 / 建议 |
| 商业 / 行业评论 | 评估策略、趋势或制度;基线 → 驱动因素 → 证据 → 替代解释 → 适用条件 |
| 文化评论 | 理解作品或现象的价值;分析对象 → 解释框架 → 细读证据 → 限度 → 判断 |
| 专栏 / 随笔 | 从观察或经验形成可迁移洞见;具体场景 → 反思 → 关联 → 有边界的结论 |
## 证据与论证
- 开头尽早写出“我主张什么”和“为什么现在值得讨论”,避免用大段背景延迟论点。每个理由回答一个潜在质疑,并由事实、例子、机制或可靠来源支持。
- 反论选择真正能动摇中心判断的版本,不挑最弱说法;回应可以承认条件、修改范围或解释为何仍不改变结论。观点平衡不是机械分配篇幅。
- 公共争议先拆清事实真伪、规则 / 权利、价值取舍、责任归属和 unknown再分别判断行动建议须对应具体主体、权限 / 义务、可用杠杆与代价,不用“多方协同”抹平责任边界。
- 预测写明前提和时间范围;价值判断说明采用的标准。涉及他人动机、违法或伤害的判断不得凭语气升级为事实。
## 结构与高质量写法
段落之间形成“主张 → 理由 → 证据 → 推论”的可追链条,过渡词只标真实关系。文化评论选择一个能统摄正文的主分析轴,把情节、语言、镜头、声音、表演或结构写成“形式选择 → 产生效果 → 支持何种解释 / 评价”的证据链;比较或综述可有多个对象,但不能退化成剧情复述或维度清单,也不把效果直接冒充创作者意图。
结尾不复述全文,而是给出经反论校准后的判断、仍然未知的部分,或读者下一步应重新考虑什么。随笔可弱化显式论证标记,但不能牺牲观察与结论之间的可理解联系。

View File

@@ -1,36 +0,0 @@
# Genre Contract: Personal Brand / 个人品牌 (`router.personal_brand`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;可信、具体、有辨识度,声音服从目标读者和真实经历,不用自我评价替代成果证据 |
| 内容逻辑 | 从目标读者和目标机会出发,用“身份 / 价值定位 → 相关经历 → 可验证贡献 → 做事方式 → 下一步意图”组织;每项经历说明情境、本人动作、结果及与目标的关系 |
| 事实 / 边界 | 职位、时间、职责、学历、技能、作品和指标须真实可核,个人贡献与团队成果分开;尊重保密、个人信息、雇主和作品权利,作品、图片和推荐语须确认归属、使用权限与必要语境,非文字证据须有文字等价信息;关键身份、时间、归属或公开权限缺失时用具体占位,无法安全表述则 blocked |
| 错误 | 夸大头衔 / 技能 / 指标、把团队成果全归个人、关键词堆砌、伪造推荐语或客户、泄露敏感信息、作品无归属 / 权限、同一经历前后矛盾、渠道语气改变事实,任一出现即失败 |
## 适用与消歧
用于让招聘方、合作方、客户或专业社群判断“这个人是谁、做过什么、能带来什么”。仅出现平台名称不触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf个人身份、经历和信誉目标作为该 leaf contract 的硬约束,不再并读 Personal Brand。
以项目经验得失来改进下一轮走 Retrospective以购买体验帮助他人选择走 Consumer以组织身份转化客户走 Marketing。出现“介绍、主页、复盘”不能单独触发须确认目标是个人能力和信誉呈现。
## 子类型
| 子类型 | 读者任务与推进 |
|-|-|
| 简历 / CV | 快速判断岗位匹配;摘要 → 相关经历与成果 → 技能 / 教育 → 必要补充 |
| 求职信 / 自我介绍 / 简介 | 理解动机与差异化价值;目标 → 相关证据 → 工作方式 → 明确下一步 |
| 个人主页 | 建立清晰定位并找到入口;一句定位 → 代表证据 → 领域 / 服务 → 联系或作品 |
| 作品集 / 案例集 | 判断能力如何形成结果;问题 → 约束与本人角色 → 过程决策 → 结果与反思 |
| 个人成长回顾 | 理解身份与能力变化;起点 → 关键选择 → 证据 → 学到什么 → 下一方向 |
## 证据与真实性
- 成果优先写可核结果及其口径,不能量化时写可观察变化、交付物或他人采用情况,不编造数字。明确“负责、协作、支持、批准”等角色差异。
- 时间线、组织名、客户名、作品链接和推荐语在公开前确认准确与授权;需匿名时保留问题、本人动作和结果的判断价值,不留下可反推的敏感细节。
- 技能由近期作品、职责范围或实际使用场景支撑;自我定位可以有主张,但不能使用未获认可的资质、奖项或身份。
## 结构与高质量写法
先筛选与目标读者最相关的经历不把完整人生经历当作专业证明。经历条目以动作和影响开头背景只写理解贡献所需的约束案例说明权衡和本人判断比工具清单更能证明能力。CTA 具体到希望发生的下一步,并只提供获授权的联系方式。

View File

@@ -1,9 +0,0 @@
# Genre Router: Platform / 平台发布稿 (`route_platform`)
仅当最终交付物是小红书笔记、微信公众号文章或邮件成稿时进入本 router按目标平台选择且只读取一个 leaf。仅把平台作为研究对象、信息来源或业务渠道时不触发多平台成稿分别路由和生成。
| 关键词 | Leaf |
|-----------------|------------------------------------|
| XHS、小红书 | [`xiaohongshu.md`](xiaohongshu.md) |
| 微信、wechat | [`wechat.md`](wechat.md) |
| 邮件、email、e-mail | [`email.md`](email.md) |

View File

@@ -1,10 +0,0 @@
# Genre Router: Report (`router.report`)
用数据、样本或研究形成洞察走本类;按读者任务选择且只读一个 leaf关键词仅用于召回`报告 / 分析 / 研究 / 数据 / 白皮书`单独不决定路由。组织执行 / 批准走 Workplace自主学习走 Knowledge。
| 读者任务 / 关键词、强信号与排除 | Leaf |
|-|-|
| 回答明确研究问题,方法、样本和可推广边界决定可信度;调研报告、访谈 / 问卷 / 用户研究。仅解读既定指标时排除 | [`research-report.md`](research-report.md) |
| 解读已定义指标、趋势、分布、漏斗或实验观察值;数据报告、经营数据、指标复盘。需重新设计样本回答问题时排除 | [`data-report.md`](data-report.md) |
| 让专业读者系统理解并评估问题、框架或方案;白皮书、行业框架、技术 / 政策议题。获客、产品卖点或 CTA 为主时排除 | [`white-paper.md`](white-paper.md) |
| 比较战略、投资、市场、产品或资源选项及成本、收益、风险;商业分析、可行性、进入 / 自建或采购判断。正文要求具名决策者选择 / 批准,或形成授权、资源拨付、执行承诺入口时排除 | [`business-analysis.md`](business-analysis.md) |

View File

@@ -1,17 +0,0 @@
# Genre Router: Workplace (`router.workplace`)
组织内决策、执行、留档走本类;先按读者任务与生命周期选择且只读一个 leaf关键词仅用于召回排除信号优先于同名词。
| 读者任务 / 关键词、强信号与排除 | Leaf |
|-|-|
| 快速知悉、短判断或会前准备;备忘录、决策摘要、会前材料。完整批准论证走 Proposal周期状态走 Weekly | [`memo-brief.md`](memo-brief.md) |
| 按周期判断相对承诺的状态、偏差、风险和下一步;周报、日报、月报、项目状态。原因学习走 Retrospective完整分析走 Report | [`weekly-report.md`](weekly-report.md) |
| 请具名决策者批准方向、预算、资源或执行承诺;提案、立项、资源申请。已定产品行为走 PRD | [`proposal.md`](proposal.md) |
| 将方向已定的一次性项目、变更、专项行动或营销战役转成可协同推进的交付、依赖、里程碑与验收;项目计划、执行方案、实施计划。仍在比较方向或请求批准走 Proposal / Report重复稳定路径走 SOP | [`execution-plan.md`](execution-plan.md) |
| 将已授权的内部规则 / 安排、可复核的检查整改记录或已核定组织立场写成正式载体;制度、公司通知、整改记录、讲话底稿。待批准方向走 Proposal复杂执行走 Execution Plan法定公文走 Official`正式`单独不触发 | [`formal-doc.md`](formal-doc.md) |
| 党政机关法定公文拟制、审校或制发;明确要求公文 / 红头 / 套红 / 正式发文,或法定文种与机关行文关系、文号、主送等制发要素共同出现。`通知 / 报告 / 公告 / 纪要 / 正式 / 官方`单独不触发 | [`official-redhead.md`](official-redhead.md) |
| 记录已发生会议的决定、异议、行动和批准状态;会议记录、行动项。逐字稿不走本 leaf法定公文纪要走 Official | [`meeting-minutes.md`](meeting-minutes.md) |
| 从已结束周期 / 事件提炼证据化学习并改变下一轮;复盘、回顾、经验教训。当前状态走 Weekly活跃未知事故走 Technical | [`retrospective.md`](retrospective.md) |
| 方向已定定义用户问题、范围、产品行为与验收PRD、用户故事、验收标准。是否投入走 Proposal实现取舍走 Technical | [`prd.md`](prd.md) |
| 评审未来技术设计、查询精确契约或调查未知故障RFC、API、架构、事故调查。产品行为走 PRD已定重复路径走 SOP | [`technical-doc.md`](technical-doc.md) |
| 按已批准、可验证路径重复达到终态或为已知事件类别预置响应路径SOP、runbook、值班 / 操作手册、BCP / 处置预案。应急预案若主要发布权威职责走 Formal法定制发走 Official活跃未知事故走 Technical一次学习教程走 Knowledge | [`sop-tutorial.md`](sop-tutorial.md) |

View File

@@ -1,41 +0,0 @@
# Genre Contract: SOP / Runbook (`workplace.sop_tutorial`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;命令式、具体、顺序稳定,一步一动作并紧邻可观察判据,不写无条件的“适当 / 必要时” |
| 内容逻辑 | 先定 routine / controlled / high-risk并识别是否为响应预案再按“版本 → 触发 / 范围 / 终态 → 角色 / 前置 → 动作 / 判据 / 证据 → 异常 / 停止 / 恢复 → 完成记录 / 复审”推进 |
| 事实 / 边界 | owner、版本、环境、资格、权限、工具、命令、阈值、预期结果和恢复路径均须已验证警告在动作前命令成功不等于业务终态流程图 / 示意不能替代可执行步骤、判据与异常路径,须有文字等价;关键未知使可发布稿 `blocked` |
| 错误 | 教程冒充 SOP、未分风险、缺 owner / 版本 / 前置、一条多动作、编造入口 / 阈值 / 权限 / 命令、停止后状态未知、只写“必要时回滚”或未验证终态;响应预案无分级触发、替补指挥、降级路径或解除条件,任一出现即失败 |
## 适用与风险分类
用于组织规定的重复作业、沿已批准路线取得确定终态的 runbook或针对已知事件类别预置并授权的响应 / 业务连续性路径。一次性自助 how-to / 学习走 Knowledge未来设计取舍、活跃未知故障或临场根因调查走 `technical-doc.md`;只建立组织权威、职责或发布要求而不提供现场步骤走 `formal-doc.md`。“教程 / 操作 / 手册 / 应急预案”单词本身不触发。
| 分类 | 增量证明义务 |
|-|-|
| `routine` | 阶段或终态验证、常见异常和升级 |
| `controlled` | 再含审批、接受 / 拒绝、偏差记录、变更复审和代表性试跑 |
| `high-risk` | 再含 precheck、hold point、go / no-go、停止条件以及可执行 rollback / fallback / roll-forward 和恢复验证 |
## 响应预案增量
- 涉及人身安全或法定直报时,其优先级高于业务与财产;按已核风险设置进入、升级、降级和解除条件,明确指挥 / 决策权限、替补角色、首轮动作、信息报送与对外口径边界。联络序列、等待时长和重试次数须预先批准;未知时保留占位,仅放行无需等待授权的安全动作。
- 预设负责人失联、断网断电、主资源不可用等降级场景及可达的安全终态恢复须验证真实业务终态。发布前按风险做桌面推演或代表性演练高风险场景包含故障注入并记录缺口、owner 和复验。
## 文控与证据
写明触发、目标终态、范围、owner / 资格、当前版本 / 环境、前置、权限、工具和输入。命令、参数、阈值、预期输出、备份 / 恢复资产和试跑结果须来自真实环境;流程变更后更新、复审并标 superseded 状态。
关键缺口就近使用`[待环境 owner 验证]`等具体占位。命令、权限、阈值、停止或恢复判据未知时只保留安全只读 precheck不得发布可执行稿`Publish Gate = blocked`
## 步骤、异常与恢复
- 每个关键步骤只写一个动作,紧邻可观察结果、阈值与证据;验证需要操作时另列一步。未知偏差停止于已知安全状态,记录证据并升级。
- high-risk 在不可逆动作前设置 hold point列 go / no-go 信号、决策人和信号缺失时的安全终态。rollback 写触发条件、适用范围、步骤、阈值、停止点和恢复后业务验证,不能只写命令回执。
- 有状态迁移另列不可逆点、写入归属、checkpoint / 幂等,以及完整、无重复、有序或等价验证;关闭 fallback 前必须证明新终态稳定。
## 高质量写法
让具备规定基础资格但不熟流程的人可独立复现;选择条件写在动作前,稳定原理链接出去,不混入原理课或临场诊断。按风险裁剪篇幅但不删证明义务;按适用治理要求由代表性执行者试跑,未经任何实际验证不得发布。

Some files were not shown because too many files have changed in this diff Show More